diff --git a/.github/workflows/ci-workflow.yml b/.github/workflows/ci-workflow.yml index ec5c7bd85..a9b9b0b22 100644 --- a/.github/workflows/ci-workflow.yml +++ b/.github/workflows/ci-workflow.yml @@ -35,21 +35,26 @@ jobs: settings-path: ${{ github.workspace }} # location for the settings.xml file - - name: ๐Ÿงพ Create application.yml from secret +# - name: ๐Ÿงพ Create application.yml from secret +# run: | +# mkdir -p ${{ env.RESOURCE_PATH }} +# mkdir -p ${{ env.TEST_RESOURCE_PATH }} +# echo "${{ secrets.APPLICATION_YML_DEV }}" > ${{ env.RESOURCE_PATH }}/application.yml +# echo "${{ secrets.APPLICATION_YML_TEST }}" > ${{ env.TEST_RESOURCE_PATH }}/application-test.yml + - name: ๐Ÿงพ Create application.yml from secret (base64) run: | mkdir -p ${{ env.RESOURCE_PATH }} mkdir -p ${{ env.TEST_RESOURCE_PATH }} - echo "${{ secrets.APPLICATION_YML_DEV }}" > ${{ env.RESOURCE_PATH }}/application.yml - echo "${{ secrets.APPLICATION_YML_TEST }}" > ${{ env.TEST_RESOURCE_PATH }}/application-test.yml - + echo "${{ secrets.APPLICATION_YML_DEV }}" | base64 --decode > ${{ env.RESOURCE_PATH }}/application.yml + echo "${{ secrets.APPLICATION_YML_TEST }}" | base64 --decode > ${{ env.TEST_RESOURCE_PATH }}/application-test.yml - name: ๐Ÿ‘๐Ÿป grant execute permission for gradlew run: chmod +x gradlew -# - name: ๐Ÿš€ Start Redis -# uses: supercharge/redis-github-action@1.7.0 -# with: -# redis-version: 7 + - name: ๐Ÿš€ Start Redis + uses: supercharge/redis-github-action@1.7.0 + with: + redis-version: 7 - name: ๐Ÿ˜ build with Gradle run: ./gradlew build \ No newline at end of file diff --git a/src/main/java/konkuk/thip/ThipServerApplication.java b/src/main/java/konkuk/thip/ThipServerApplication.java index 57f951a56..4ee0caadb 100644 --- a/src/main/java/konkuk/thip/ThipServerApplication.java +++ b/src/main/java/konkuk/thip/ThipServerApplication.java @@ -3,8 +3,12 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.annotation.EnableScheduling; @EnableJpaAuditing +@EnableScheduling +@EnableAsync @SpringBootApplication public class ThipServerApplication { diff --git a/src/main/java/konkuk/thip/book/adapter/in/web/BookQueryController.java b/src/main/java/konkuk/thip/book/adapter/in/web/BookQueryController.java index de8dad678..b1e7de2a9 100644 --- a/src/main/java/konkuk/thip/book/adapter/in/web/BookQueryController.java +++ b/src/main/java/konkuk/thip/book/adapter/in/web/BookQueryController.java @@ -2,8 +2,10 @@ import jakarta.validation.constraints.Pattern; import konkuk.thip.book.adapter.in.web.response.GetBookDetailSearchResponse; +import konkuk.thip.book.adapter.in.web.response.GetBookMostSearchResponse; import konkuk.thip.book.adapter.in.web.response.GetBookSearchListResponse; import konkuk.thip.book.application.port.in.BookSearchUseCase; +import konkuk.thip.book.application.port.in.BookMostSearchUseCase; import konkuk.thip.common.dto.BaseResponse; import konkuk.thip.common.security.annotation.UserId; import lombok.RequiredArgsConstructor; @@ -16,6 +18,8 @@ public class BookQueryController { private final BookSearchUseCase bookSearchUseCase; + private final BookMostSearchUseCase bookMostSearchUseCase; + //์ฑ… ๊ฒ€์ƒ‰๊ฒฐ๊ณผ ์กฐํšŒ @GetMapping("/books") @@ -36,4 +40,11 @@ public BaseResponse getBookDetailSearch(@PathVariab return BaseResponse.ok(GetBookDetailSearchResponse.of(bookSearchUseCase.searchDetailBooks(isbn,userId))); } + //๊ฐ€์žฅ ๋งŽ์ด ๊ฒ€์ƒ‰๋œ ์ฑ… ์กฐํšŒ + @GetMapping("/books/most-searched") + public BaseResponse getMostSearchedBooks(@UserId final Long userId) { + + return BaseResponse.ok(GetBookMostSearchResponse.of(bookMostSearchUseCase.getMostSearchedBooks(userId))); + } + } diff --git a/src/main/java/konkuk/thip/book/adapter/in/web/response/GetBookMostSearchResponse.java b/src/main/java/konkuk/thip/book/adapter/in/web/response/GetBookMostSearchResponse.java new file mode 100644 index 000000000..8f14f1008 --- /dev/null +++ b/src/main/java/konkuk/thip/book/adapter/in/web/response/GetBookMostSearchResponse.java @@ -0,0 +1,14 @@ +package konkuk.thip.book.adapter.in.web.response; + +import konkuk.thip.book.application.port.in.dto.BookMostSearchResult; +import lombok.Builder; + +import java.util.List; + +public record GetBookMostSearchResponse( + List bookList +) { + public static GetBookMostSearchResponse of(BookMostSearchResult bookMostSearchResult) { + return new GetBookMostSearchResponse(bookMostSearchResult.bookList()); + } +} diff --git a/src/main/java/konkuk/thip/book/adapter/out/api/BookApiNaverApiAdapter.java b/src/main/java/konkuk/thip/book/adapter/out/api/BookApiNaverApiAdapter.java index 3a2eedfe8..c52747689 100644 --- a/src/main/java/konkuk/thip/book/adapter/out/api/BookApiNaverApiAdapter.java +++ b/src/main/java/konkuk/thip/book/adapter/out/api/BookApiNaverApiAdapter.java @@ -19,7 +19,7 @@ public NaverBookParseResult findBooksByKeyword(String keyword, int start) { } @Override - public NaverDetailBookParseResult findDetailBookByKeyword(String isbn) { + public NaverDetailBookParseResult findDetailBookByIsbn(String isbn) { String xml = naverApiUtil.detailSearchBook(isbn); // ๋„ค์ด๋ฒ„ API ํ˜ธ์ถœ return NaverBookXmlParser.parseBookDetail(xml); // XML ํŒŒ์‹ฑ } diff --git a/src/main/java/konkuk/thip/book/adapter/out/persistence/BookJpaRepository.java b/src/main/java/konkuk/thip/book/adapter/out/persistence/BookJpaRepository.java index 538045ca5..1570af68f 100644 --- a/src/main/java/konkuk/thip/book/adapter/out/persistence/BookJpaRepository.java +++ b/src/main/java/konkuk/thip/book/adapter/out/persistence/BookJpaRepository.java @@ -3,8 +3,10 @@ import konkuk.thip.book.adapter.out.jpa.BookJpaEntity; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.List; import java.util.Optional; public interface BookJpaRepository extends JpaRepository { Optional findByIsbn(String isbn); + List findByIsbnIn(List isbnList); } diff --git a/src/main/java/konkuk/thip/book/adapter/out/persistence/BookQueryPersistenceAdapter.java b/src/main/java/konkuk/thip/book/adapter/out/persistence/BookQueryPersistenceAdapter.java new file mode 100644 index 000000000..d91fcf0fc --- /dev/null +++ b/src/main/java/konkuk/thip/book/adapter/out/persistence/BookQueryPersistenceAdapter.java @@ -0,0 +1,15 @@ +package konkuk.thip.book.adapter.out.persistence; + +import konkuk.thip.book.adapter.out.mapper.BookMapper; +import konkuk.thip.book.application.port.out.BookQueryPort; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class BookQueryPersistenceAdapter implements BookQueryPort { + + private final BookJpaRepository bookJpaRepository; + private final BookMapper bookMapper; + +} diff --git a/src/main/java/konkuk/thip/book/adapter/out/persistence/BookRedisAdapter.java b/src/main/java/konkuk/thip/book/adapter/out/persistence/BookRedisAdapter.java new file mode 100644 index 000000000..66f762426 --- /dev/null +++ b/src/main/java/konkuk/thip/book/adapter/out/persistence/BookRedisAdapter.java @@ -0,0 +1,135 @@ +package konkuk.thip.book.adapter.out.persistence; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.core.type.TypeReference; +import com.fasterxml.jackson.databind.ObjectMapper; +import konkuk.thip.book.application.port.in.dto.BookMostSearchResult; +import konkuk.thip.book.application.port.out.BookRedisCommandPort; +import konkuk.thip.book.application.port.out.BookRedisQueryPort; +import konkuk.thip.common.exception.ExternalApiException; +import konkuk.thip.common.exception.code.ErrorCode; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.core.ZSetOperations; +import org.springframework.stereotype.Component; + +import java.time.Duration; +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; + +import static konkuk.thip.common.exception.code.ErrorCode.JSON_PROCESSING_ERROR; + +@Component +@RequiredArgsConstructor +public class BookRedisAdapter implements BookRedisQueryPort, BookRedisCommandPort { + + private final RedisTemplate redisTemplate; + private final DateTimeFormatter DAILY_KEY_FORMATTER = + DateTimeFormatter.ofPattern("yyyyMMdd"); + + @Value("${app.redis.search-count-prefix}") + private String searchCountPrefix; + + @Value("${app.redis.search-rank-prefix}") + private String searchRankPrefix; + + @Value("${app.redis.search-rank-detail-prefix}") + private String searchRankDetailPrefix; + + private final ObjectMapper objectMapper; + + @Override + public List> getBookSearchRank(LocalDate date, int topN) { + return getTopNFromZSet(searchRankPrefix, date, topN); + } + + @Override + public List> getBookSearchCountTopN(LocalDate date, int topN) { + return getTopNFromZSet(searchCountPrefix, date, topN); + } + + private List> getTopNFromZSet(String prefix, LocalDate date, int topN) { + String dateStr = date.format(DAILY_KEY_FORMATTER); + String redisKey = prefix + dateStr; + Set> topNSet = redisTemplate.opsForZSet() + .reverseRangeWithScores(redisKey, 0, topN - 1); + + if (topNSet == null) return Collections.emptyList(); + return topNSet.stream() + .map(tuple -> Map.entry(tuple.getValue(), tuple.getScore())) + .collect(Collectors.toList()); + } + + @Override + public List getYesterdayBookRankInfos(LocalDate date) { + String redisKey = searchRankDetailPrefix + date.format(DAILY_KEY_FORMATTER); + String json = redisTemplate.opsForValue().get(redisKey); + + if (json == null || json.isBlank()) { + return List.of(); + } + try { + return objectMapper.readValue( + json, + new TypeReference>() {} + ); + } catch (JsonProcessingException e) { + throw new ExternalApiException(ErrorCode.JSON_PROCESSING_ERROR); + } + } + + @Override + public void incrementBookSearchCount(String isbn, LocalDate date) { + String redisKey = makeRedisKey(searchCountPrefix, date); + redisTemplate.opsForZSet().incrementScore(redisKey, isbn, 1.0); + } + + @Override + public void saveBookSearchRank(List isbns, List scores, LocalDate date) { + String redisKey = makeRedisKey(searchRankPrefix, date); + for (int i = 0; i < isbns.size(); i++) { + redisTemplate.opsForZSet().add(redisKey, isbns.get(i), scores.get(i)); + } + redisTemplate.expire(redisKey, Duration.ofDays(7)); + } + + @Override + public void saveBookSearchRankDetail(List bookRankDetails, LocalDate date) { + String redisKey = makeRedisKey(searchRankDetailPrefix, date); + String detailJson; + try { + detailJson = objectMapper.writeValueAsString(bookRankDetails); + } catch (JsonProcessingException e) { + throw new ExternalApiException(JSON_PROCESSING_ERROR); + } + redisTemplate.opsForValue().set(redisKey, detailJson); + } + + @Override + public void deleteBookSearchRank(LocalDate date) { + deleteZSetKey(searchRankPrefix, date); + } + + @Override + public void deleteBookSearchCount(LocalDate date) { deleteZSetKey(searchCountPrefix, date); } + + @Override + public void deleteBookSearchRankDetail(LocalDate date) { deleteZSetKey(searchRankDetailPrefix, date); } + + private void deleteZSetKey(String prefix, LocalDate date) { + String redisKey = makeRedisKey(prefix, date); + redisTemplate.delete(redisKey); + } + + private String makeRedisKey(String prefix, LocalDate date) { + String dateStr = date.format(DAILY_KEY_FORMATTER); + return prefix + dateStr; + } + +} diff --git a/src/main/java/konkuk/thip/book/application/port/in/BookMostSearchUseCase.java b/src/main/java/konkuk/thip/book/application/port/in/BookMostSearchUseCase.java new file mode 100644 index 000000000..336f8a7b4 --- /dev/null +++ b/src/main/java/konkuk/thip/book/application/port/in/BookMostSearchUseCase.java @@ -0,0 +1,7 @@ +package konkuk.thip.book.application.port.in; + +import konkuk.thip.book.application.port.in.dto.BookMostSearchResult; + +public interface BookMostSearchUseCase { + BookMostSearchResult getMostSearchedBooks(Long userId); +} diff --git a/src/main/java/konkuk/thip/book/application/port/in/dto/BookMostSearchResult.java b/src/main/java/konkuk/thip/book/application/port/in/dto/BookMostSearchResult.java new file mode 100644 index 000000000..0862bef8e --- /dev/null +++ b/src/main/java/konkuk/thip/book/application/port/in/dto/BookMostSearchResult.java @@ -0,0 +1,22 @@ +package konkuk.thip.book.application.port.in.dto; + +import konkuk.thip.book.adapter.in.web.response.GetBookMostSearchResponse; +import lombok.Builder; + +import java.util.List; + +public record BookMostSearchResult( + List bookList +) { + @Builder + public record BookRankInfo( + int rank, + String title, + String imageUrl, + String isbn + ) {} + + public static BookMostSearchResult of(List bookList) { + return new BookMostSearchResult(bookList); + } +} \ No newline at end of file diff --git a/src/main/java/konkuk/thip/book/application/port/out/BookApiQueryPort.java b/src/main/java/konkuk/thip/book/application/port/out/BookApiQueryPort.java index 1bbf8fdc4..145dee188 100644 --- a/src/main/java/konkuk/thip/book/application/port/out/BookApiQueryPort.java +++ b/src/main/java/konkuk/thip/book/application/port/out/BookApiQueryPort.java @@ -5,5 +5,5 @@ public interface BookApiQueryPort { NaverBookParseResult findBooksByKeyword(String keyword, int start); - NaverDetailBookParseResult findDetailBookByKeyword(String isbn); + NaverDetailBookParseResult findDetailBookByIsbn(String isbn); } diff --git a/src/main/java/konkuk/thip/book/application/port/out/BookQueryPort.java b/src/main/java/konkuk/thip/book/application/port/out/BookQueryPort.java index a795fd146..186919761 100644 --- a/src/main/java/konkuk/thip/book/application/port/out/BookQueryPort.java +++ b/src/main/java/konkuk/thip/book/application/port/out/BookQueryPort.java @@ -1,5 +1,4 @@ package konkuk.thip.book.application.port.out; public interface BookQueryPort { - } diff --git a/src/main/java/konkuk/thip/book/application/port/out/BookRedisCommandPort.java b/src/main/java/konkuk/thip/book/application/port/out/BookRedisCommandPort.java new file mode 100644 index 000000000..c2bc9e749 --- /dev/null +++ b/src/main/java/konkuk/thip/book/application/port/out/BookRedisCommandPort.java @@ -0,0 +1,15 @@ +package konkuk.thip.book.application.port.out; + +import konkuk.thip.book.application.port.in.dto.BookMostSearchResult; + +import java.time.LocalDate; +import java.util.List; + +public interface BookRedisCommandPort { + void incrementBookSearchCount(String isbn, LocalDate date); + void saveBookSearchRank(List isbns, List scores, LocalDate date); + void saveBookSearchRankDetail(List bookRankDetails, LocalDate date); + void deleteBookSearchRank(LocalDate date); + void deleteBookSearchCount(LocalDate date); + void deleteBookSearchRankDetail(LocalDate date); +} diff --git a/src/main/java/konkuk/thip/book/application/port/out/BookRedisQueryPort.java b/src/main/java/konkuk/thip/book/application/port/out/BookRedisQueryPort.java new file mode 100644 index 000000000..c149107e6 --- /dev/null +++ b/src/main/java/konkuk/thip/book/application/port/out/BookRedisQueryPort.java @@ -0,0 +1,13 @@ +package konkuk.thip.book.application.port.out; + +import konkuk.thip.book.application.port.in.dto.BookMostSearchResult; + +import java.time.LocalDate; +import java.util.List; +import java.util.Map; + +public interface BookRedisQueryPort { + List> getBookSearchRank(LocalDate date, int topN); + List> getBookSearchCountTopN(LocalDate date, int topN); + List getYesterdayBookRankInfos(LocalDate date); +} diff --git a/src/main/java/konkuk/thip/book/application/service/BookMostSearchRankService.java b/src/main/java/konkuk/thip/book/application/service/BookMostSearchRankService.java new file mode 100644 index 000000000..cc3c8b4eb --- /dev/null +++ b/src/main/java/konkuk/thip/book/application/service/BookMostSearchRankService.java @@ -0,0 +1,80 @@ +package konkuk.thip.book.application.service; + +import konkuk.thip.book.adapter.out.api.dto.NaverDetailBookParseResult; +import konkuk.thip.book.application.port.in.dto.BookMostSearchResult; +import konkuk.thip.book.application.port.out.BookApiQueryPort; +import konkuk.thip.book.application.port.out.BookCommandPort; +import konkuk.thip.book.application.port.out.BookRedisCommandPort; +import konkuk.thip.book.application.port.out.BookRedisQueryPort; +import konkuk.thip.book.domain.Book; +import konkuk.thip.common.exception.EntityNotFoundException; +import lombok.RequiredArgsConstructor; +import org.springframework.scheduling.annotation.Async; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class BookMostSearchRankService { + + private final BookApiQueryPort bookApiQueryPort; + private final BookRedisQueryPort bookRedisQueryPort; + private final BookRedisCommandPort bookRedisCommandPort; + private final BookCommandPort bookCommandPort; + + // ๋งค์ผ 0์‹œ ์‹คํ–‰ + @Async + @Scheduled(cron = "0 0 0 * * *") + public void updateDailySearchRank() { + + LocalDate yesterday = LocalDate.now().minusDays(1); + + // ์ „๋‚  ๊ฒ€์ƒ‰ ์นด์šดํŠธ Top 5 ์กฐํšŒ + List> top5 = bookRedisQueryPort.getBookSearchCountTopN(yesterday, 5); + + // ๊ธฐ์กด ๋žญํ‚น ์ฑ… ์ƒ์ œ์ •๋ณด ํ‚ค ์‚ญ์ œ + bookRedisCommandPort.deleteBookSearchRankDetail(yesterday); + // ๊ธฐ์กด ๋žญํ‚น ํ‚ค ์‚ญ์ œ + bookRedisCommandPort.deleteBookSearchRank(yesterday); + // ์ „๋‚  ์นด์šดํŠธ ํ‚ค ์‚ญ์ œ + bookRedisCommandPort.deleteBookSearchCount(yesterday); + + // Top 5 ์ €์žฅ + if (!top5.isEmpty()) { //PM๊ณผ ์ƒ์˜ ํ›„ ๊ฒฐ์ • + List isbns = top5.stream().map(Map.Entry::getKey).collect(Collectors.toList()); + List scores = top5.stream().map(Map.Entry::getValue).collect(Collectors.toList()); + bookRedisCommandPort.saveBookSearchRank(isbns, scores, yesterday); + + // Top 5 ์ƒ์„ธ์ •๋ณด ์ €์žฅ ์ถ”๊ฐ€ + List bookRankDetails = new ArrayList<>(); + int rank = 1; + for (String isbn : isbns) { + try { + Book book = bookCommandPort.findByIsbn(isbn); + bookRankDetails.add(BookMostSearchResult.BookRankInfo.builder() + .rank(rank++) + .title(book.getTitle()) + .imageUrl(book.getImageUrl()) + .isbn(isbn) + .build()); + } catch (EntityNotFoundException e) { + // DB์— ์—†์œผ๋ฉด Naver API์—์„œ ์ƒ์„ธ ์ •๋ณด ์กฐํšŒ + NaverDetailBookParseResult naverResult = bookApiQueryPort.findDetailBookByIsbn(isbn); + bookRankDetails.add(BookMostSearchResult.BookRankInfo.builder() + .rank(rank++) + .title(naverResult.title()) + .imageUrl(naverResult.imageUrl()) + .isbn(isbn) + .build()); + } + } + bookRedisCommandPort.saveBookSearchRankDetail(bookRankDetails, yesterday); + } + } +} diff --git a/src/main/java/konkuk/thip/book/application/service/BookMostSearchService.java b/src/main/java/konkuk/thip/book/application/service/BookMostSearchService.java new file mode 100644 index 000000000..8555506f2 --- /dev/null +++ b/src/main/java/konkuk/thip/book/application/service/BookMostSearchService.java @@ -0,0 +1,31 @@ +package konkuk.thip.book.application.service; + +import konkuk.thip.book.application.port.in.BookMostSearchUseCase; +import konkuk.thip.book.application.port.in.dto.BookMostSearchResult; +import konkuk.thip.book.application.port.out.BookRedisQueryPort; +import konkuk.thip.user.application.port.out.UserCommandPort; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.time.LocalDate; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class BookMostSearchService implements BookMostSearchUseCase { + + private final BookRedisQueryPort bookRedisQueryPort; + private final UserCommandPort userCommandPort; + + @Override + public BookMostSearchResult getMostSearchedBooks(Long userId) { + + userCommandPort.findById(userId); + + LocalDate yesterday = LocalDate.now().minusDays(1); + + List bookRankInfos = bookRedisQueryPort.getYesterdayBookRankInfos(yesterday); + return BookMostSearchResult.of(bookRankInfos); + + } +} \ No newline at end of file diff --git a/src/main/java/konkuk/thip/book/application/service/BookSavedService.java b/src/main/java/konkuk/thip/book/application/service/BookSavedService.java index 258b7a76f..4872f1674 100644 --- a/src/main/java/konkuk/thip/book/application/service/BookSavedService.java +++ b/src/main/java/konkuk/thip/book/application/service/BookSavedService.java @@ -44,7 +44,7 @@ public BookIsSavedResult changeSavedBook(String isbn, boolean isSave, Long userI } // ์ €์žฅ ์š”์ฒญ์ด๋ฉด ๋„ค์ด๋ฒ„ API๋กœ ์ฑ… ์ •๋ณด ์กฐํšŒ ํ›„ ์ €์žฅ - NaverDetailBookParseResult naverResult = bookApiQueryPort.findDetailBookByKeyword(isbn); + NaverDetailBookParseResult naverResult = bookApiQueryPort.findDetailBookByIsbn(isbn); Book newBook = Book.withoutId( naverResult.title(), naverResult.isbn(), diff --git a/src/main/java/konkuk/thip/book/application/service/BookSearchService.java b/src/main/java/konkuk/thip/book/application/service/BookSearchService.java index e0afa354f..d1ec358f8 100644 --- a/src/main/java/konkuk/thip/book/application/service/BookSearchService.java +++ b/src/main/java/konkuk/thip/book/application/service/BookSearchService.java @@ -7,6 +7,7 @@ import konkuk.thip.book.application.port.in.dto.BookDetailSearchResult; import konkuk.thip.book.application.port.out.BookCommandPort; import konkuk.thip.book.application.port.out.BookApiQueryPort; +import konkuk.thip.book.application.port.out.BookRedisCommandPort; import konkuk.thip.book.domain.Book; import konkuk.thip.common.exception.BusinessException; import konkuk.thip.common.exception.EntityNotFoundException; @@ -41,6 +42,7 @@ public class BookSearchService implements BookSearchUseCase { private final RecentSearchCommandPort recentSearchCommandPort; private final BookCommandPort bookCommandPort; private final UserCommandPort userCommandPort; + private final BookRedisCommandPort bookRedisCommandPort; @Override @@ -83,7 +85,10 @@ public BookDetailSearchResult searchDetailBooks(String isbn,Long userId) { User user = userCommandPort.findById(userId); //์ฑ… ์ƒ์„ธ์ •๋ณด - NaverDetailBookParseResult naverDetailBookParseResult = bookApiQueryPort.findDetailBookByKeyword(isbn); + NaverDetailBookParseResult naverDetailBookParseResult = bookApiQueryPort.findDetailBookByIsbn(isbn); + + //์ฑ… ๊ฒ€์ƒ‰์ˆœ์œ„ ์ •๋ณด ์—…๋ฐ์ดํŠธ + bookRedisCommandPort.incrementBookSearchCount(isbn,LocalDate.now()); Book book; try { diff --git a/src/main/java/konkuk/thip/common/exception/code/ErrorCode.java b/src/main/java/konkuk/thip/common/exception/code/ErrorCode.java index 5de2c7ec8..42da90c96 100644 --- a/src/main/java/konkuk/thip/common/exception/code/ErrorCode.java +++ b/src/main/java/konkuk/thip/common/exception/code/ErrorCode.java @@ -23,6 +23,8 @@ public enum ErrorCode implements ResponseCode { AUTH_LOGIN_FAILED(HttpStatus.UNAUTHORIZED, 40104, "๋กœ๊ทธ์ธ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค."), AUTH_UNSUPPORTED_SOCIAL_LOGIN(HttpStatus.UNAUTHORIZED, 40105, "์ง€์›ํ•˜์ง€ ์•Š๋Š” ์†Œ์…œ ๋กœ๊ทธ์ธ์ž…๋‹ˆ๋‹ค."), + JSON_PROCESSING_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, 50100, "JSON ์ง๋ ฌํ™”/์—ญ์ง๋ ฌํ™”์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค."), + /* 60000๋ถ€ํ„ฐ ๋น„์ฆˆ๋‹ˆ์Šค ์˜ˆ์™ธ */ /** * 60000 : alias error diff --git a/src/main/java/konkuk/thip/config/RedisConfig.java b/src/main/java/konkuk/thip/config/RedisConfig.java new file mode 100644 index 000000000..088dde588 --- /dev/null +++ b/src/main/java/konkuk/thip/config/RedisConfig.java @@ -0,0 +1,37 @@ + +package konkuk.thip.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +@Configuration +public class RedisConfig { + + @Value("${spring.data.redis.host}") + private String host; + + @Value("${spring.data.redis.port}") + private int port; + + @Bean + public RedisTemplate redisTemplate(RedisConnectionFactory connectionFactory) { + RedisTemplate redisTemplate = new RedisTemplate<>(); + redisTemplate.setConnectionFactory(connectionFactory); + // ์ผ๋ฐ˜์ ์ธ key:value์˜ ๊ฒฝ์šฐ ์‹œ๋ฆฌ์–ผ๋ผ์ด์ € + redisTemplate.setKeySerializer(new StringRedisSerializer()); + redisTemplate.setValueSerializer(new GenericJackson2JsonRedisSerializer()); + return redisTemplate; + } + + @Bean + public RedisConnectionFactory redisConnectionFactory() { + return new LettuceConnectionFactory(host, port); + } + +} diff --git a/src/test/java/konkuk/thip/ThipServerApplicationTests.java b/src/test/java/konkuk/thip/ThipServerApplicationTests.java index 3acca870f..82ff3c417 100644 --- a/src/test/java/konkuk/thip/ThipServerApplicationTests.java +++ b/src/test/java/konkuk/thip/ThipServerApplicationTests.java @@ -12,4 +12,5 @@ class ThipServerApplicationTests { void contextLoads() { } + } diff --git a/src/test/java/konkuk/thip/book/adapter/in/web/BookMostSearchedBooksControllerTest.java b/src/test/java/konkuk/thip/book/adapter/in/web/BookMostSearchedBooksControllerTest.java new file mode 100644 index 000000000..d90a218ad --- /dev/null +++ b/src/test/java/konkuk/thip/book/adapter/in/web/BookMostSearchedBooksControllerTest.java @@ -0,0 +1,172 @@ +package konkuk.thip.book.adapter.in.web; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import konkuk.thip.book.application.port.in.dto.BookMostSearchResult; +import konkuk.thip.user.adapter.out.jpa.AliasJpaEntity; +import konkuk.thip.user.adapter.out.jpa.UserJpaEntity; +import konkuk.thip.user.adapter.out.jpa.UserRole; +import konkuk.thip.user.adapter.out.persistence.AliasJpaRepository; +import konkuk.thip.user.adapter.out.persistence.UserJpaRepository; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.List; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; + +@SpringBootTest +@AutoConfigureMockMvc(addFilters = false) +@ActiveProfiles("test") +class BookMostSearchedBooksControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private UserJpaRepository userJpaRepository; + + @Autowired + private AliasJpaRepository aliasJpaRepository; + + @Autowired + private RedisTemplate redisTemplate; + + @Autowired + private ObjectMapper objectMapper; + + + private LocalDate yesterday; + + + private final DateTimeFormatter DAILY_KEY_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); + + @Value("${app.redis.search-rank-detail-prefix}") + private String searchRankDetailPrefix; + + @BeforeEach + void setUp() { + + AliasJpaEntity alias = aliasJpaRepository.save(AliasJpaEntity.builder() + .value("์ฑ…๋ฒŒ๋ ˆ") + .color("blue") + .imageUrl("http://image.url") + .build()); + + UserJpaEntity user = userJpaRepository.save(UserJpaEntity.builder() + .oauth2Id("kakao_432708231") + .nickname("User1") + .imageUrl("https://avatar1.jpg") + .role(UserRole.USER) + .aliasForUserJpaEntity(alias) + .build()); + + } + + @AfterEach + void tearDown() { + userJpaRepository.deleteAll(); + aliasJpaRepository.deleteAll(); + } + + @Test + @DisplayName("์–ด์ œ ๋žญํ‚น Top 5๋ฅผ ์ •์ƒ์ ์œผ๋กœ ์กฐํšŒํ•œ๋‹ค") + void getMostSearchedBooks_returnsRankList() throws Exception { + // given: ์–ด์ œ ๋‚ ์งœ์˜ ๋žญํ‚น ์ƒ์„ธ์ •๋ณด(JSON)๋ฅผ Redis์— ์ €์žฅ + yesterday = LocalDate.now().minusDays(1); + String detailKey = searchRankDetailPrefix + yesterday.format(DAILY_KEY_FORMATTER); + + List bookRankInfos = List.of( + BookMostSearchResult.BookRankInfo.builder() + .rank(1) + .title("์ฑ…1") + .imageUrl("http://image1.jpg") + .isbn("9788954682152") + .build(), + BookMostSearchResult.BookRankInfo.builder() + .rank(2) + .title("์ฑ…2") + .imageUrl("http://image2.jpg") + .isbn("9788991742178") + .build(), + BookMostSearchResult.BookRankInfo.builder() + .rank(3) + .title("์ฑ…3") + .imageUrl("http://image3.jpg") + .isbn("9791198783400") + .build() + ); + String json = objectMapper.writeValueAsString(bookRankInfos); + redisTemplate.opsForValue().set(detailKey, json); + + Long userId = userJpaRepository.findAll().get(0).getUserId(); + + // when + ResultActions result = mockMvc.perform(get("/books/most-searched") + .requestAttr("userId", userId) + .contentType(MediaType.APPLICATION_JSON) + ); + + // then + result.andExpect(status().isOk()) + .andExpect(jsonPath("$.data.bookList").isArray()) + .andExpect(jsonPath("$.data.bookList.length()").value(3)); + + String responseJson = result.andReturn().getResponse().getContentAsString(); + JsonNode bookList = objectMapper.readTree(responseJson).path("data").path("bookList"); + + assertThat(bookList).isNotNull(); + assertThat(bookList.size()).isEqualTo(3); + + // ์ˆœ์„œ ๋ฐ ํ•„๋“œ ๊ฒ€์ฆ + assertThat(bookList.get(0).path("isbn").asText()).isEqualTo("9788954682152"); + assertThat(bookList.get(1).path("isbn").asText()).isEqualTo("9788991742178"); + assertThat(bookList.get(2).path("isbn").asText()).isEqualTo("9791198783400"); + } + + @Test + @DisplayName("๋žญํ‚น ๋ฐ์ดํ„ฐ๊ฐ€ ์—†์œผ๋ฉด ๋นˆ ๋ฆฌ์ŠคํŠธ๋ฅผ ๋ฐ˜ํ™˜ํ•œ๋‹ค") + void getMostSearchedBooks_returnsEmptyList_whenNoData() throws Exception { + // given: ์–ด์ œ ๋‚ ์งœ์˜ ๋žญํ‚น ์ƒ์„ธ์ •๋ณด ํ‚ค๋ฅผ ์‚ญ์ œ + yesterday = LocalDate.now().minusDays(1); + String detailKey = searchRankDetailPrefix + yesterday.format(DAILY_KEY_FORMATTER); + redisTemplate.delete(detailKey); + + Long userId = userJpaRepository.findAll().get(0).getUserId(); + + // when + ResultActions result = mockMvc.perform(get("/books/most-searched") + .requestAttr("userId", userId) + .contentType(MediaType.APPLICATION_JSON) + ); + + // then + result.andExpect(status().isOk()) + .andExpect(jsonPath("$.data.bookList").isArray()) + .andExpect(jsonPath("$.data.bookList.length()").value(0)); + + String responseJson = result.andReturn().getResponse().getContentAsString(); + JsonNode bookList = objectMapper.readTree(responseJson).path("data").path("bookList"); + + assertThat(bookList).isNotNull(); + assertThat(bookList.size()).isEqualTo(0); + } + + +} diff --git a/src/test/java/konkuk/thip/book/application/service/BookSearchRankServiceTest.java b/src/test/java/konkuk/thip/book/application/service/BookSearchRankServiceTest.java new file mode 100644 index 000000000..397fe0e32 --- /dev/null +++ b/src/test/java/konkuk/thip/book/application/service/BookSearchRankServiceTest.java @@ -0,0 +1,89 @@ +package konkuk.thip.book.application.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.LocalDate; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.Map; + +import konkuk.thip.book.application.port.out.BookRedisCommandPort; +import konkuk.thip.book.application.port.out.BookRedisQueryPort; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.test.context.ActiveProfiles; + +@SpringBootTest +@ActiveProfiles("test") +public class BookSearchRankServiceTest { + + @Autowired + private BookRedisQueryPort bookRedisQueryPort; + + @Autowired + private BookRedisCommandPort bookRedisCommandPort; + + @Autowired + private RedisTemplate redisTemplate; + + private final DateTimeFormatter DAILY_KEY_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); + + private LocalDate yesterday; + + @Value("${app.redis.search-count-prefix}") + private String searchCountPrefix; + + + @BeforeEach + void setup() { + + // given: ์–ด์ œ ๋‚ ์งœ์˜ ์นด์šดํŠธ ํ‚ค์— ํ…Œ์ŠคํŠธ์šฉ ๋ฐ์ดํ„ฐ 3๊ฐœ๋ฅผ ์ €์žฅ + yesterday = LocalDate.now().minusDays(1); + String countKey = searchCountPrefix + yesterday.format(DAILY_KEY_FORMATTER); + + redisTemplate.opsForZSet().add(countKey, "9788954682152", 10); + redisTemplate.opsForZSet().add(countKey, "9788991742178", 5); + redisTemplate.opsForZSet().add(countKey, "9791198783400", 7); + + } + + @Test + @DisplayName("์–ด์ œ์˜ ๊ฒ€์ƒ‰ ์นด์šดํŠธ Top 5๋ฅผ ์ง‘๊ณ„ํ•˜์—ฌ ๋žญํ‚น ํ‚ค์— ์ €์žฅํ•œ๋‹ค") + void updateDailySearchRank_shouldAggregateAndSaveTop5() { + + // given ์–ด์ œ ๋‚ ์งœ์˜ ์นด์šดํŠธ ํ‚ค์— ๋ฐ์ดํ„ฐ๊ฐ€ ์ €์žฅ๋˜์–ด ์žˆ์Œ + List> top5 = bookRedisQueryPort.getBookSearchCountTopN(yesterday, 5); + + // when ๊ธฐ์กด ๋žญํ‚น/์นด์šดํŠธ ํ‚ค๋ฅผ ์‚ญ์ œํ•˜๊ณ , Top 5๋ฅผ ๋žญํ‚น ํ‚ค์— ์ €์žฅ + bookRedisCommandPort.deleteBookSearchRank(yesterday); + bookRedisCommandPort.deleteBookSearchCount(yesterday); + + if (!top5.isEmpty()) { + List isbns = top5.stream().map(Map.Entry::getKey).toList(); + List scores = top5.stream().map(Map.Entry::getValue).toList(); + bookRedisCommandPort.saveBookSearchRank(isbns, scores, yesterday); + } + + // then ๋žญํ‚น ํ‚ค์— Top 5๊ฐ€ ๋‚ด๋ฆผ์ฐจ์ˆœ์œผ๋กœ ์ €์žฅ๋˜์–ด ์žˆ์–ด์•ผ ํ•จ + List> savedTop5 = bookRedisQueryPort.getBookSearchRank(yesterday, 5); + + assertThat(savedTop5).isNotEmpty(); + assertThat(savedTop5.size()).isLessThanOrEqualTo(5); + + // ์ ์ˆ˜ ๋‚ด๋ฆผ์ฐจ์ˆœ ํ™•์ธ + double previousScore = Double.MAX_VALUE; + for (Map.Entry entry : savedTop5) { + assertThat(entry.getValue()).isLessThanOrEqualTo(previousScore); + previousScore = entry.getValue(); + } + + // ํ…Œ์ŠคํŠธ์šฉ์œผ๋กœ ์ €์žฅ๋œ isbn ํฌํ•จ ์—ฌ๋ถ€ ํ™•์ธ + assertThat(savedTop5.stream().map(Map.Entry::getKey)) + .containsAnyOf("9788954682152", "9788991742178", "9791198783400"); + } +}