diff --git a/src/main/java/org/websoso/WSSServer/application/LibraryEvaluationApplication.java b/src/main/java/org/websoso/WSSServer/application/LibraryEvaluationApplication.java index f1ce2a66..7213a2c5 100644 --- a/src/main/java/org/websoso/WSSServer/application/LibraryEvaluationApplication.java +++ b/src/main/java/org/websoso/WSSServer/application/LibraryEvaluationApplication.java @@ -28,7 +28,9 @@ import org.websoso.WSSServer.library.service.KeywordService; import org.websoso.WSSServer.library.service.LibraryService; import org.websoso.WSSServer.novel.domain.Novel; +import org.websoso.WSSServer.novel.domain.NovelStatisticsContribution; import org.websoso.WSSServer.novel.service.NovelServiceImpl; +import org.websoso.WSSServer.novel.service.NovelStatisticsService; /** * 서재 평가는 서재와 매력 포인트, 키워드가 핵심 도메인이다. @@ -39,6 +41,7 @@ public class LibraryEvaluationApplication { private final NovelServiceImpl novelService; private final LibraryService libraryService; + private final NovelStatisticsService novelStatisticsService; private final AttractivePointService attractivePointService; private final KeywordService keywordService; @@ -55,6 +58,11 @@ public void createEvaluation(User user, UserNovelCreateRequest request) { try { UserNovel userNovel = libraryService.createLibrary(request.status(), request.userNovelRating(), request.startDate(), request.endDate(), user, novel); + novelStatisticsService.updateByDelta( + novel.getNovelId(), + NovelStatisticsContribution.EMPTY, + NovelStatisticsContribution.from(userNovel) + ); attractivePointService.createUserNovelAttractivePoints(userNovel, request.attractivePoints()); keywordService.createNovelKeywords(userNovel, request.keywordIds()); @@ -93,9 +101,16 @@ public UserNovelGetResponse getEvaluation(User user, Long novelId) { */ @Transactional public void updateEvaluation(User user, Long novelId, UserNovelUpdateRequest request) { - UserNovel userNovel = libraryService.getLibraryOrException(user, novelId); + UserNovel userNovel = libraryService.getLibraryForUpdateOrException(user, novelId); + NovelStatisticsContribution before = NovelStatisticsContribution.from(userNovel); - userNovel.updateUserNovel(request.userNovelRating(), request.status(), request.startDate(), request.endDate()); + libraryService.updateEvaluation(userNovel, request.userNovelRating(), request.status(), request.startDate(), + request.endDate()); + novelStatisticsService.updateByDelta( + novelId, + before, + NovelStatisticsContribution.from(userNovel) + ); updateAttractivePoints(userNovel, request.attractivePoints()); @@ -110,19 +125,30 @@ public void updateEvaluation(User user, Long novelId, UserNovelUpdateRequest req */ @Transactional public void deleteEvaluation(User user, Long novelId) { - UserNovel userNovel = libraryService.getLibraryOrException(user, novelId); + UserNovel userNovel = libraryService.getLibraryForUpdateOrException(user, novelId); + NovelStatisticsContribution before = NovelStatisticsContribution.from(userNovel); if (userNovel.getStatus() == null) { throw new CustomUserNovelException(NOT_EVALUATED, "this novel has not been evaluated by the user"); } if (userNovel.getIsInterest()) { - userNovel.deleteEvaluation(); + libraryService.deleteEvaluation(userNovel); + novelStatisticsService.updateByDelta( + novelId, + before, + NovelStatisticsContribution.from(userNovel) + ); attractivePointService.deleteUserNovelAttractivePoints(userNovel.getUserNovelAttractivePoints()); keywordService.deleteUserNovelKeywords(userNovel.getUserNovelKeywords()); } else { libraryService.delete(userNovel); + novelStatisticsService.updateByDelta( + novelId, + before, + NovelStatisticsContribution.EMPTY + ); } } diff --git a/src/main/java/org/websoso/WSSServer/application/LibraryInterestApplication.java b/src/main/java/org/websoso/WSSServer/application/LibraryInterestApplication.java index decacb81..5586b4a4 100644 --- a/src/main/java/org/websoso/WSSServer/application/LibraryInterestApplication.java +++ b/src/main/java/org/websoso/WSSServer/application/LibraryInterestApplication.java @@ -1,16 +1,15 @@ package org.websoso.WSSServer.application; -import static org.websoso.WSSServer.exception.error.CustomUserNovelError.NOT_INTERESTED; - import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.websoso.WSSServer.user.domain.User; -import org.websoso.WSSServer.exception.exception.CustomUserNovelException; import org.websoso.WSSServer.library.domain.UserNovel; import org.websoso.WSSServer.library.service.LibraryService; import org.websoso.WSSServer.novel.domain.Novel; +import org.websoso.WSSServer.novel.domain.NovelStatisticsContribution; import org.websoso.WSSServer.novel.service.NovelServiceImpl; +import org.websoso.WSSServer.novel.service.NovelStatisticsService; @Service @RequiredArgsConstructor @@ -18,6 +17,7 @@ public class LibraryInterestApplication { private final NovelServiceImpl novelService; private final LibraryService libraryService; + private final NovelStatisticsService novelStatisticsService; /** * 관심있어요를 남긴다. @@ -28,8 +28,15 @@ public class LibraryInterestApplication { @Transactional public void registerAsInterest(User user, Long novelId) { Novel novel = novelService.getNovelOrException(novelId); + UserNovel library = libraryService.getOrCreateLibraryForInterest(user, novel); + NovelStatisticsContribution before = NovelStatisticsContribution.from(library); - libraryService.registerInterest(user, novel); + libraryService.registerInterest(library); + novelStatisticsService.updateByDelta( + novelId, + before, + NovelStatisticsContribution.from(library) + ); } /** @@ -40,13 +47,19 @@ public void registerAsInterest(User user, Long novelId) { */ @Transactional public void unregisterAsInterest(User user, Long novelId) { - UserNovel library = libraryService.getLibraryOrNull(user, novelId); + UserNovel library = libraryService.getLibraryForUpdateOrNull(user, novelId); - if (library == null) { + if (library == null || Boolean.FALSE.equals(library.getIsInterest())) { return; } + NovelStatisticsContribution before = NovelStatisticsContribution.from(library); libraryService.unregisterInterest(library); + novelStatisticsService.updateByDelta( + novelId, + before, + NovelStatisticsContribution.from(library) + ); } } diff --git a/src/main/java/org/websoso/WSSServer/application/SearchNovelApplication.java b/src/main/java/org/websoso/WSSServer/application/SearchNovelApplication.java index 8349eec5..ab1db608 100644 --- a/src/main/java/org/websoso/WSSServer/application/SearchNovelApplication.java +++ b/src/main/java/org/websoso/WSSServer/application/SearchNovelApplication.java @@ -39,7 +39,6 @@ import org.websoso.WSSServer.feed.feed.domain.Feed; import org.websoso.WSSServer.feed.feed.repository.FeedRepository; import org.websoso.WSSServer.library.domain.Keyword; -import org.websoso.WSSServer.library.domain.UserNovel; import org.websoso.WSSServer.library.service.LibraryService; import org.websoso.WSSServer.novel.domain.Novel; import org.websoso.WSSServer.novel.domain.NovelGenre; @@ -86,9 +85,7 @@ public SearchedNovelsResponse searchNovels(User user, String query, int page, in Page novels = novelService.searchNovels(pageRequest, searchQuery); - List novelGetResponsePreviews = novels.stream() - .map(this::convertToDTO2) - .toList(); + List novelGetResponsePreviews = convertToNovelSummaries(novels.getContent()); // 로그인한 사용자이며, 검색어가 있는 경우에만 검색 기록에 저장한다. if (user != null && user.getUserId() != null && !searchQuery.isBlank()) { @@ -115,9 +112,7 @@ public FilteredNovelsResponse getFilteredNovels(List genreNames, List novelGetResponsePreviews = novels.stream() - .map(this::convertToDTO2) - .toList(); + List novelGetResponsePreviews = convertToNovelSummaries(novels.getContent()); return FilteredNovelsResponse.of(novelGetResponsePreviews, novels.getTotalElements(), novels.hasNext()); } @@ -229,31 +224,17 @@ private String sanitizeQuery(String query) { .replaceAll("[^a-zA-Z0-9가-힣]", ""); } - private NovelSummaryResponse convertToDTO2(Novel novel) { - // TODO: Repository에서 NovelSummaryResponse에 맞게 데이터를 불러오는게 좋을듯 - List userNovels = novel.getUserNovels(); - - long interestCount = userNovels.stream() - .filter(UserNovel::getIsInterest) - .count(); - long novelRatingCount = userNovels.stream() - .filter(un -> un.getUserNovelRating() != 0.0f) - .count(); - double novelRatingSum = userNovels.stream() - .filter(un -> un.getUserNovelRating() != 0.0f) - .mapToDouble(UserNovel::getUserNovelRating) - .sum(); - - float novelRatingAverage = novelRatingCount == 0 - ? 0.0f - : Math.round((float) (novelRatingSum / novelRatingCount) * 10.0f) / 10.0f; - - return NovelSummaryResponse.of( - novel, - interestCount, - novelRatingAverage, - novelRatingCount - ); + private List convertToNovelSummaries(List novels) { + List novelIds = novels.stream() + .map(Novel::getNovelId) + .toList(); + Map interestCounts = libraryService.getInterestCountsByNovelIds(novelIds); + return novels.stream() + .map(novel -> NovelSummaryResponse.of( + novel, + interestCounts.getOrDefault(novel.getNovelId(), 0L) + )) + .toList(); } private String getNovelGenreNames(List novelGenres) { diff --git a/src/main/java/org/websoso/WSSServer/dto/novel/NovelSummaryResponse.java b/src/main/java/org/websoso/WSSServer/dto/novel/NovelSummaryResponse.java index 3c00dc26..a671191d 100644 --- a/src/main/java/org/websoso/WSSServer/dto/novel/NovelSummaryResponse.java +++ b/src/main/java/org/websoso/WSSServer/dto/novel/NovelSummaryResponse.java @@ -1,6 +1,7 @@ package org.websoso.WSSServer.dto.novel; import org.websoso.WSSServer.novel.domain.Novel; +import org.websoso.WSSServer.novel.domain.NovelStatistics; public record NovelSummaryResponse( long novelId, @@ -11,8 +12,18 @@ public record NovelSummaryResponse( float novelRating, long novelRatingCount ) { - public static NovelSummaryResponse of(Novel novel, long interestCount, float novelRating, - long novelRatingCount) { + /** + * 작품과 연결된 통계로 검색 결과 요약 응답을 생성한다. + */ + public static NovelSummaryResponse of(Novel novel, long interestCount) { + NovelStatistics statistics = novel.getNovelStatistics(); + float novelRating = statistics == null + ? 0.0f + : Math.round(statistics.getAverageRating().floatValue() * 10.0f) / 10.0f; + long novelRatingCount = statistics == null + ? 0L + : statistics.getRatingCount(); + return new NovelSummaryResponse( novel.getNovelId(), novel.getNovelImage(), diff --git a/src/main/java/org/websoso/WSSServer/controller/LibraryController.java b/src/main/java/org/websoso/WSSServer/library/controller/LibraryController.java similarity index 99% rename from src/main/java/org/websoso/WSSServer/controller/LibraryController.java rename to src/main/java/org/websoso/WSSServer/library/controller/LibraryController.java index e90aac23..b35b22d9 100644 --- a/src/main/java/org/websoso/WSSServer/controller/LibraryController.java +++ b/src/main/java/org/websoso/WSSServer/library/controller/LibraryController.java @@ -1,4 +1,4 @@ -package org.websoso.WSSServer.controller; +package org.websoso.WSSServer.library.controller; import static org.springframework.http.HttpStatus.CREATED; import static org.springframework.http.HttpStatus.NO_CONTENT; diff --git a/src/main/java/org/websoso/WSSServer/library/repository/UserNovelCustomRepository.java b/src/main/java/org/websoso/WSSServer/library/repository/UserNovelCustomRepository.java index f9e20b7a..958e226e 100644 --- a/src/main/java/org/websoso/WSSServer/library/repository/UserNovelCustomRepository.java +++ b/src/main/java/org/websoso/WSSServer/library/repository/UserNovelCustomRepository.java @@ -2,16 +2,23 @@ import java.time.LocalDateTime; import java.util.List; +import java.util.Optional; import org.springframework.data.domain.Pageable; import org.websoso.WSSServer.domain.Genre; import org.websoso.WSSServer.domain.common.UserNovelSortType; import org.websoso.WSSServer.library.repository.cursor.UserNovelCursor; +import org.websoso.WSSServer.library.repository.projection.NovelInterestCount; import org.websoso.WSSServer.novel.domain.Novel; import org.websoso.WSSServer.library.domain.UserNovel; import org.websoso.WSSServer.dto.user.UserNovelCountGetResponse; +import org.websoso.WSSServer.user.domain.User; public interface UserNovelCustomRepository { + Optional findByNovelIdAndUserForUpdate(Long novelId, User user); + + List findInterestCountsByNovelIds(List novelIds); + UserNovelCountGetResponse findUserNovelStatistics(Long userId); List findTodayPopularNovelsId(Pageable pageable); diff --git a/src/main/java/org/websoso/WSSServer/library/repository/UserNovelCustomRepositoryImpl.java b/src/main/java/org/websoso/WSSServer/library/repository/UserNovelCustomRepositoryImpl.java index d30f446b..51171b0e 100644 --- a/src/main/java/org/websoso/WSSServer/library/repository/UserNovelCustomRepositoryImpl.java +++ b/src/main/java/org/websoso/WSSServer/library/repository/UserNovelCustomRepositoryImpl.java @@ -13,6 +13,7 @@ import com.querydsl.core.types.dsl.BooleanExpression; import com.querydsl.jpa.impl.JPAQuery; import com.querydsl.jpa.impl.JPAQueryFactory; +import jakarta.persistence.LockModeType; import java.time.LocalDate; import java.time.LocalDateTime; import java.util.List; @@ -27,7 +28,9 @@ import org.websoso.WSSServer.domain.common.UserNovelSortType; import org.websoso.WSSServer.dto.user.UserNovelCountGetResponse; import org.websoso.WSSServer.library.repository.cursor.UserNovelCursor; +import org.websoso.WSSServer.library.repository.projection.NovelInterestCount; import org.websoso.WSSServer.novel.domain.Novel; +import org.websoso.WSSServer.user.domain.User; @Repository @RequiredArgsConstructor @@ -36,6 +39,35 @@ public class UserNovelCustomRepositoryImpl implements UserNovelCustomRepository private static final long NO_CURSOR = 0L; private final JPAQueryFactory jpaQueryFactory; + @Override + public Optional findByNovelIdAndUserForUpdate(Long novelId, User user) { + return Optional.ofNullable(jpaQueryFactory + .selectFrom(userNovel) + .where( + userNovel.novel.novelId.eq(novelId), + userNovel.user.eq(user) + ) + .setLockMode(LockModeType.PESSIMISTIC_WRITE) + .fetchOne()); + } + + @Override + public List findInterestCountsByNovelIds(List novelIds) { + return jpaQueryFactory + .select(Projections.constructor( + NovelInterestCount.class, + userNovel.novel.novelId, + userNovel.count() + )) + .from(userNovel) + .where( + userNovel.novel.novelId.in(novelIds), + userNovel.isInterest.isTrue() + ) + .groupBy(userNovel.novel.novelId) + .fetch(); + } + @Override public UserNovelCountGetResponse findUserNovelStatistics(Long userId) { return jpaQueryFactory diff --git a/src/main/java/org/websoso/WSSServer/library/repository/UserNovelRepository.java b/src/main/java/org/websoso/WSSServer/library/repository/UserNovelRepository.java index 48ff0970..6a4c3085 100644 --- a/src/main/java/org/websoso/WSSServer/library/repository/UserNovelRepository.java +++ b/src/main/java/org/websoso/WSSServer/library/repository/UserNovelRepository.java @@ -1,11 +1,11 @@ package org.websoso.WSSServer.library.repository; -import io.lettuce.core.dynamic.annotation.Param; import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import org.websoso.WSSServer.novel.domain.Novel; import org.websoso.WSSServer.user.domain.User; @@ -32,23 +32,20 @@ public interface UserNovelRepository extends JpaRepository, Use Optional findByNovel_NovelIdAndUser(Long novelId, User user); - @Modifying(clearAutomatically = true) + @Modifying @Query(value = """ - INSERT INTO user_novel ( + INSERT IGNORE INTO user_novel ( user_id, novel_id, is_interest, user_novel_rating, status, created_date, modified_date ) VALUES ( - :userId, :novelId, true, + :userId, :novelId, false, :defaultRating, :#{#defaultStatus?.name()}, NOW(), NOW() ) - ON DUPLICATE KEY UPDATE - is_interest = true, - modified_date = NOW() """, nativeQuery = true) - void upsertInterest( + int insertLibraryIfAbsent( @Param("userId") Long userId, @Param("novelId") Long novelId, @Param("defaultRating") Float defaultRating, diff --git a/src/main/java/org/websoso/WSSServer/library/repository/projection/NovelInterestCount.java b/src/main/java/org/websoso/WSSServer/library/repository/projection/NovelInterestCount.java new file mode 100644 index 00000000..8e018cb6 --- /dev/null +++ b/src/main/java/org/websoso/WSSServer/library/repository/projection/NovelInterestCount.java @@ -0,0 +1,7 @@ +package org.websoso.WSSServer.library.repository.projection; + +public record NovelInterestCount( + Long novelId, + Long interestCount +) { +} diff --git a/src/main/java/org/websoso/WSSServer/library/service/LibraryService.java b/src/main/java/org/websoso/WSSServer/library/service/LibraryService.java index b716218a..5c95a207 100644 --- a/src/main/java/org/websoso/WSSServer/library/service/LibraryService.java +++ b/src/main/java/org/websoso/WSSServer/library/service/LibraryService.java @@ -7,6 +7,8 @@ import java.time.LocalDate; import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.PageRequest; import org.springframework.stereotype.Service; @@ -17,6 +19,7 @@ import org.websoso.WSSServer.exception.exception.CustomUserNovelException; import org.websoso.WSSServer.library.domain.UserNovel; import org.websoso.WSSServer.library.repository.UserNovelRepository; +import org.websoso.WSSServer.library.repository.projection.NovelInterestCount; import org.websoso.WSSServer.novel.domain.Novel; @Service @@ -26,9 +29,9 @@ public class LibraryService { private final UserNovelRepository userNovelRepository; // TODO: novelId로 불러옴 - @Transactional(readOnly = true) - public UserNovel getLibraryOrException(User user, Long novelId) { - return userNovelRepository.findByNovel_NovelIdAndUser(novelId, user) + @Transactional + public UserNovel getLibraryForUpdateOrException(User user, Long novelId) { + return userNovelRepository.findByNovelIdAndUserForUpdate(novelId, user) .orElseThrow(() -> new CustomUserNovelException(USER_NOVEL_NOT_FOUND, "user novel with the given user and novel is not found")); } @@ -56,7 +59,7 @@ public UserNovel getLibraryOrNull(User user, long novelId) { @Transactional public UserNovel createLibrary(ReadStatus status, Float userNovelRating, LocalDate startDate, LocalDate endDate, User user, Novel novel) { - return userNovelRepository.save(UserNovel.create( + return userNovelRepository.saveAndFlush(UserNovel.create( status, userNovelRating, startDate, @@ -65,35 +68,31 @@ public UserNovel createLibrary(ReadStatus status, Float userNovelRating, LocalDa novel)); } - /** - *

관심있어요를 등록한다.

- * 서재 내역이 없다면, 서재 내역을 생성하면서 등록한다. - * - * @param user 사용자 Entity - * @param novel 서재 Entity - */ @Transactional - public void registerInterest(User user, Novel novel) { - userNovelRepository.upsertInterest( + public UserNovel getOrCreateLibraryForInterest(User user, Novel novel) { + userNovelRepository.insertLibraryIfAbsent( user.getUserId(), novel.getNovelId(), UserNovel.DEFAULT_RATING, UserNovel.DEFAULT_STATUS ); + + return userNovelRepository.findByNovelIdAndUserForUpdate(novel.getNovelId(), user) + .orElseThrow(() -> new IllegalStateException("inserted user novel could not be found")); } - /** - *

관심있어요를 해제한다.

- * 만약, 서재 정보가 없다면 삭제한다. - * - * @param library 서재 Entity - */ @Transactional - public void unregisterInterest(UserNovel library) { - if (Boolean.FALSE.equals(library.getIsInterest())) { - return; - } + public void registerInterest(UserNovel library) { + library.markAsInterested(); + } + @Transactional + public UserNovel getLibraryForUpdateOrNull(User user, Long novelId) { + return userNovelRepository.findByNovelIdAndUserForUpdate(novelId, user).orElse(null); + } + + @Transactional + public void unregisterInterest(UserNovel library) { library.unmarkAsInterested(); if (library.isSafeToDelete()) { @@ -101,11 +100,35 @@ public void unregisterInterest(UserNovel library) { } } + @Transactional + public void updateEvaluation(UserNovel library, Float userNovelRating, ReadStatus status, LocalDate startDate, + LocalDate endDate) { + library.updateUserNovel(userNovelRating, status, startDate, endDate); + } + + @Transactional + public void deleteEvaluation(UserNovel library) { + library.deleteEvaluation(); + } + @Transactional public void delete(UserNovel library) { userNovelRepository.delete(library); } + @Transactional(readOnly = true) + public Map getInterestCountsByNovelIds(List novelIds) { + if (novelIds.isEmpty()) { + return Map.of(); + } + + return userNovelRepository.findInterestCountsByNovelIds(novelIds).stream() + .collect(Collectors.toMap( + NovelInterestCount::novelId, + NovelInterestCount::interestCount + )); + } + public int getRatingCount(Novel novel) { return userNovelRepository.countByNovelAndUserNovelRatingNot(novel, 0.0f); } @@ -146,4 +169,5 @@ public List getInterestNovels(User user) { public List getTodayPopularNovelIds(PageRequest pageRequest) { return userNovelRepository.findTodayPopularNovelsId(pageRequest); } + } diff --git a/src/main/java/org/websoso/WSSServer/novel/domain/Novel.java b/src/main/java/org/websoso/WSSServer/novel/domain/Novel.java index 5b4d1913..91ce8ae0 100644 --- a/src/main/java/org/websoso/WSSServer/novel/domain/Novel.java +++ b/src/main/java/org/websoso/WSSServer/novel/domain/Novel.java @@ -8,6 +8,7 @@ import jakarta.persistence.GeneratedValue; import jakarta.persistence.Id; import jakarta.persistence.OneToMany; +import jakarta.persistence.OneToOne; import java.util.ArrayList; import java.util.List; import java.util.Optional; @@ -41,6 +42,9 @@ public class Novel { @Column(columnDefinition = "Boolean default false", nullable = false) private Boolean isCompleted; + @OneToOne(mappedBy = "novel", fetch = FetchType.LAZY) + private NovelStatistics novelStatistics; + @OneToMany(mappedBy = "novel", fetch = FetchType.LAZY) private List userNovels = new ArrayList<>(); diff --git a/src/main/java/org/websoso/WSSServer/novel/domain/NovelStatistics.java b/src/main/java/org/websoso/WSSServer/novel/domain/NovelStatistics.java new file mode 100644 index 00000000..4e764caf --- /dev/null +++ b/src/main/java/org/websoso/WSSServer/novel/domain/NovelStatistics.java @@ -0,0 +1,47 @@ +package org.websoso.WSSServer.novel.domain; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.MapsId; +import jakarta.persistence.Table; +import jakarta.persistence.OneToOne; +import java.math.BigDecimal; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.Comment; + +@Entity +@Table(name = "novel_statistics") +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class NovelStatistics { + + @Id + @Column(name = "novel_id") + private Long novelId; + + @MapsId + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "novel_id", referencedColumnName = "novel_id") + private Novel novel; + + @Column(nullable = false, precision = 4, scale = 3) + @Comment("작품 평균 평점") + private BigDecimal averageRating = BigDecimal.ZERO; + + @Column(nullable = false, precision = 19, scale = 1) + @Comment("작품 평점 합계") + private BigDecimal ratingSum = BigDecimal.ZERO; + + @Column(nullable = false) + @Comment("작품 평점 등록 수") + private Long ratingCount = 0L; + + @Column(nullable = false) + @Comment("관심 등록 또는 하차 외 독서 상태인 사용자 수") + private Long popularity = 0L; +} diff --git a/src/main/java/org/websoso/WSSServer/novel/domain/NovelStatisticsContribution.java b/src/main/java/org/websoso/WSSServer/novel/domain/NovelStatisticsContribution.java new file mode 100644 index 00000000..23c30776 --- /dev/null +++ b/src/main/java/org/websoso/WSSServer/novel/domain/NovelStatisticsContribution.java @@ -0,0 +1,29 @@ +package org.websoso.WSSServer.novel.domain; + +import static org.websoso.WSSServer.domain.common.ReadStatus.QUIT; + +import java.math.BigDecimal; +import org.websoso.WSSServer.library.domain.UserNovel; + +public record NovelStatisticsContribution( + BigDecimal ratingSum, + long ratingCount, + long popularity +) { + + public static final NovelStatisticsContribution EMPTY = + new NovelStatisticsContribution(BigDecimal.ZERO, 0L, 0L); + + public static NovelStatisticsContribution from(UserNovel userNovel) { + float rating = userNovel.getUserNovelRating(); + boolean hasRating = Float.compare(rating, UserNovel.DEFAULT_RATING) != 0; + boolean isPopular = Boolean.TRUE.equals(userNovel.getIsInterest()) + || (userNovel.getStatus() != null && userNovel.getStatus() != QUIT); + + return new NovelStatisticsContribution( + hasRating ? new BigDecimal(Float.toString(rating)) : BigDecimal.ZERO, + hasRating ? 1L : 0L, + isPopular ? 1L : 0L + ); + } +} diff --git a/src/main/java/org/websoso/WSSServer/novel/job/NovelStatisticsCorrectionJob.java b/src/main/java/org/websoso/WSSServer/novel/job/NovelStatisticsCorrectionJob.java new file mode 100644 index 00000000..f9002026 --- /dev/null +++ b/src/main/java/org/websoso/WSSServer/novel/job/NovelStatisticsCorrectionJob.java @@ -0,0 +1,21 @@ +package org.websoso.WSSServer.novel.job; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.websoso.WSSServer.novel.service.NovelStatisticsService; + +@Slf4j +@Component +@RequiredArgsConstructor +public class NovelStatisticsCorrectionJob { + + private final NovelStatisticsService novelStatisticsService; + + @Scheduled(cron = "0 0 4 * * *", zone = "Asia/Seoul") + public void correct() { + int affectedRows = novelStatisticsService.correctAll(); + log.info("novel statistics correction done. affectedRows={}", affectedRows); + } +} diff --git a/src/main/java/org/websoso/WSSServer/novel/repository/NovelCustomRepositoryImpl.java b/src/main/java/org/websoso/WSSServer/novel/repository/NovelCustomRepositoryImpl.java index fddac530..c411cecb 100644 --- a/src/main/java/org/websoso/WSSServer/novel/repository/NovelCustomRepositoryImpl.java +++ b/src/main/java/org/websoso/WSSServer/novel/repository/NovelCustomRepositoryImpl.java @@ -1,23 +1,20 @@ package org.websoso.WSSServer.novel.repository; import static org.websoso.WSSServer.domain.QGenre.genre; -import static org.websoso.WSSServer.domain.common.ReadStatus.WATCHED; -import static org.websoso.WSSServer.domain.common.ReadStatus.WATCHING; -import static org.websoso.WSSServer.library.domain.QUserNovel.userNovel; import static org.websoso.WSSServer.novel.domain.QNovel.novel; import static org.websoso.WSSServer.novel.domain.QNovelGenre.novelGenre; import static org.websoso.WSSServer.novel.domain.QNovelPlatform.novelPlatform; +import static org.websoso.WSSServer.novel.domain.QNovelStatistics.novelStatistics; import static org.websoso.WSSServer.novel.domain.QPlatform.platform; import com.querydsl.core.types.dsl.BooleanExpression; -import com.querydsl.core.types.dsl.CaseBuilder; import com.querydsl.core.types.dsl.Expressions; import com.querydsl.core.types.dsl.NumberExpression; -import com.querydsl.core.types.dsl.NumberTemplate; import com.querydsl.core.types.dsl.StringPath; import com.querydsl.core.types.dsl.StringTemplate; import com.querydsl.jpa.impl.JPAQuery; import com.querydsl.jpa.impl.JPAQueryFactory; +import java.math.BigDecimal; import java.util.List; import java.util.stream.Stream; import lombok.RequiredArgsConstructor; @@ -43,18 +40,16 @@ public Page findSearchedNovels(Pageable pageable, String searchQuery) { List novelsByTitle = jpaQueryFactory .selectFrom(novel) - .leftJoin(novel.userNovels, userNovel) + .leftJoin(novel.novelStatistics, novelStatistics).fetchJoin() .where(titleContainsQuery(searchQuery)) - .groupBy(novel.novelId) - .orderBy(getPopularity(novel).desc()) + .orderBy(novelStatistics.popularity.desc(), novel.novelId.asc()) .fetch(); List novelsByAuthor = jpaQueryFactory .selectFrom(novel) - .leftJoin(novel.userNovels, userNovel) + .leftJoin(novel.novelStatistics, novelStatistics).fetchJoin() .where(authorContainsQuery.and(titleContainsQuery(searchQuery).not())) - .groupBy(novel.novelId) - .orderBy(getPopularity(novel).desc()) + .orderBy(novelStatistics.popularity.desc(), novel.novelId.asc()) .fetch(); List result = Stream @@ -79,33 +74,44 @@ private StringTemplate getCleanedString(StringPath stringPath) { public Page findFilteredNovels(Pageable pageable, List genres, Boolean isCompleted, Float novelRatingStart, Float novelRatingEnd, List keywords, List platformNames) { - NumberTemplate popularity = Expressions.numberTemplate(Long.class, - "(SELECT COUNT(un) FROM UserNovel un WHERE un.novel = {0} AND (un.isInterest = true OR un.status <> 'QUIT'))", - novel); - JPAQuery query = jpaQueryFactory .selectFrom(novel) - .distinct() - .join(novel.novelGenres, novelGenre) - .leftJoin(novel.novelPlatforms, novelPlatform) - .leftJoin(novelPlatform.platform, platform) + .leftJoin(novel.novelStatistics, novelStatistics).fetchJoin(); + + boolean hasGenreFilter = genres != null && !genres.isEmpty(); + boolean hasPlatformFilter = platformNames != null && !platformNames.isEmpty(); + + if (hasGenreFilter) { + query.join(novel.novelGenres, novelGenre); + } + + if (hasPlatformFilter) { + query.join(novel.novelPlatforms, novelPlatform) + .join(novelPlatform.platform, platform); + } + + if (hasGenreFilter || hasPlatformFilter) { + query.distinct(); + } + + query .where( - genres.isEmpty() - ? null - : novelGenre.genre.in(genres), + hasGenreFilter + ? novelGenre.genre.in(genres) + : null, isCompleted == null ? null : novel.isCompleted.eq(isCompleted), - getAverageRatingCondition(novel, novelRatingStart, novelRatingEnd), + getAverageRatingCondition(novelRatingStart, novelRatingEnd), keywords.isEmpty() ? null : getKeywordCount(novel, keywords).eq(keywords.size()), - platformNames == null || platformNames.isEmpty() - ? null - : platform.platformName.in(platformNames) + hasPlatformFilter + ? platform.platformName.in(platformNames) + : null ) - .orderBy(popularity.desc()); + .orderBy(novelStatistics.popularity.desc(), novel.novelId.asc()); return applyPagination(pageable, query); } @@ -114,10 +120,9 @@ public Page findFilteredNovels(Pageable pageable, List genres, Boo public List findAutocompleteNovels(String searchQuery, int limitSize) { return jpaQueryFactory .selectFrom(novel) - .leftJoin(novel.userNovels, userNovel) + .leftJoin(novel.novelStatistics, novelStatistics) .where(titleContainsQuery(searchQuery)) - .groupBy(novel.novelId) - .orderBy(getPopularity(novel).desc()) + .orderBy(novelStatistics.popularity.desc(), novel.novelId.asc()) .limit(limitSize) .fetch(); } @@ -138,25 +143,20 @@ private BooleanExpression titleContainsQuery(String searchQuery) { } - private NumberExpression getAverageRating(QNovel novel) { - return Expressions.numberTemplate(Double.class, - "(SELECT AVG(un.userNovelRating) FROM UserNovel un WHERE un.novel = {0} AND un.userNovelRating <> 0)", - novel); - } - - private BooleanExpression getAverageRatingCondition(QNovel novel, Float novelRatingStart, Float novelRatingEnd) { - if (novelRatingStart == null) { + private BooleanExpression getAverageRatingCondition(Float novelRatingStart, Float novelRatingEnd) { + if (novelRatingStart == null || novelRatingEnd == null) { return null; } - NumberExpression averageRating = getAverageRating(novel); - BooleanExpression averageRatingBetween = averageRating.between(novelRatingStart, novelRatingEnd); - - if (Float.compare(novelRatingStart, 0.0f) == 0) { - return averageRating.isNull().or(averageRatingBetween); + if (Float.compare(novelRatingStart, 0.0f) == 0 + && Float.compare(novelRatingEnd, 5.0f) == 0) { + return null; } - return averageRatingBetween; + return novelStatistics.averageRating.coalesce(BigDecimal.ZERO).between( + new BigDecimal(Float.toString(novelRatingStart)), + new BigDecimal(Float.toString(novelRatingEnd)) + ); } private NumberExpression getKeywordCount(QNovel novel, List keywords) { @@ -165,15 +165,6 @@ private NumberExpression getKeywordCount(QNovel novel, List ke novel, keywords); } - private NumberExpression getPopularity(QNovel novel) { - return new CaseBuilder() - .when(userNovel.isInterest.isTrue() - .or(userNovel.status.in(WATCHING, WATCHED))) - .then(1L) - .otherwise(0L) - .sum(); - } - private Page applyPagination(Pageable pageable, JPAQuery query) { long total = query.fetchCount(); List results = query.offset(pageable.getOffset()) diff --git a/src/main/java/org/websoso/WSSServer/novel/repository/NovelStatisticsRepository.java b/src/main/java/org/websoso/WSSServer/novel/repository/NovelStatisticsRepository.java new file mode 100644 index 00000000..f9db529a --- /dev/null +++ b/src/main/java/org/websoso/WSSServer/novel/repository/NovelStatisticsRepository.java @@ -0,0 +1,89 @@ +package org.websoso.WSSServer.novel.repository; + +import java.math.BigDecimal; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; +import org.websoso.WSSServer.novel.domain.NovelStatistics; + +@Repository +public interface NovelStatisticsRepository extends JpaRepository { + + @Modifying + @Query(value = """ + INSERT IGNORE INTO novel_statistics ( + novel_id, + average_rating, + rating_sum, + rating_count, + popularity + ) VALUES ( + :novelId, + 0.000, + 0.0, + 0, + 0 + ) + """, nativeQuery = true) + int insertIfAbsent(@Param("novelId") Long novelId); + + @Modifying + @Query(value = """ + UPDATE novel_statistics ns + SET + ns.average_rating = CASE + WHEN ns.rating_count + :ratingCountDelta = 0 THEN 0 + ELSE CAST(ROUND( + (ns.rating_sum + :ratingSumDelta) + / (ns.rating_count + :ratingCountDelta), + 3 + ) AS DECIMAL(4, 3)) + END, + ns.rating_sum = ns.rating_sum + :ratingSumDelta, + ns.rating_count = ns.rating_count + :ratingCountDelta, + ns.popularity = ns.popularity + :popularityDelta + WHERE ns.novel_id = :novelId + """, nativeQuery = true) + void updateByDelta( + @Param("novelId") Long novelId, + @Param("ratingSumDelta") BigDecimal ratingSumDelta, + @Param("ratingCountDelta") Long ratingCountDelta, + @Param("popularityDelta") Long popularityDelta + ); + + @Modifying + @Query(value = """ + INSERT INTO novel_statistics ( + novel_id, + average_rating, + rating_sum, + rating_count, + popularity + ) + SELECT + n.novel_id, + CAST( + ROUND(COALESCE(AVG(NULLIF(un.user_novel_rating, 0)), 0), 3) + AS DECIMAL(4, 3) + ), + CAST( + ROUND(COALESCE(SUM(NULLIF(un.user_novel_rating, 0)), 0), 1) + AS DECIMAL(19, 1) + ), + COUNT(NULLIF(un.user_novel_rating, 0)), + COUNT(CASE + WHEN un.is_interest = TRUE OR un.status <> 'QUIT' THEN 1 + END) + FROM novel n + LEFT JOIN user_novel un ON un.novel_id = n.novel_id + GROUP BY n.novel_id + ON DUPLICATE KEY UPDATE + average_rating = VALUES(average_rating), + rating_sum = VALUES(rating_sum), + rating_count = VALUES(rating_count), + popularity = VALUES(popularity) + """, nativeQuery = true) + int correctAll(); +} diff --git a/src/main/java/org/websoso/WSSServer/novel/service/NovelStatisticsService.java b/src/main/java/org/websoso/WSSServer/novel/service/NovelStatisticsService.java new file mode 100644 index 00000000..117bf65e --- /dev/null +++ b/src/main/java/org/websoso/WSSServer/novel/service/NovelStatisticsService.java @@ -0,0 +1,42 @@ +package org.websoso.WSSServer.novel.service; + +import java.math.BigDecimal; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.websoso.WSSServer.novel.domain.NovelStatisticsContribution; +import org.websoso.WSSServer.novel.repository.NovelStatisticsRepository; + +@Service +@RequiredArgsConstructor +public class NovelStatisticsService { + + private final NovelStatisticsRepository novelStatisticsRepository; + + @Transactional + public void updateByDelta(Long novelId, NovelStatisticsContribution before, + NovelStatisticsContribution after) { + BigDecimal ratingSumDelta = after.ratingSum().subtract(before.ratingSum()); + long ratingCountDelta = after.ratingCount() - before.ratingCount(); + long popularityDelta = after.popularity() - before.popularity(); + + if (ratingSumDelta.signum() == 0 + && ratingCountDelta == 0 + && popularityDelta == 0) { + return; + } + + novelStatisticsRepository.insertIfAbsent(novelId); + novelStatisticsRepository.updateByDelta( + novelId, + ratingSumDelta, + ratingCountDelta, + popularityDelta + ); + } + + @Transactional + public int correctAll() { + return novelStatisticsRepository.correctAll(); + } +} diff --git a/src/test/java/org/websoso/WSSServer/application/LibraryInterestApplicationTest.java b/src/test/java/org/websoso/WSSServer/application/LibraryInterestApplicationTest.java new file mode 100644 index 00000000..bf3398b2 --- /dev/null +++ b/src/test/java/org/websoso/WSSServer/application/LibraryInterestApplicationTest.java @@ -0,0 +1,84 @@ +package org.websoso.WSSServer.application; + +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.BDDMockito.willAnswer; + +import java.math.BigDecimal; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.websoso.WSSServer.library.domain.UserNovel; +import org.websoso.WSSServer.library.service.LibraryService; +import org.websoso.WSSServer.novel.domain.Novel; +import org.websoso.WSSServer.novel.domain.NovelStatisticsContribution; +import org.websoso.WSSServer.novel.service.NovelServiceImpl; +import org.websoso.WSSServer.novel.service.NovelStatisticsService; +import org.websoso.WSSServer.user.domain.User; + +@ExtendWith(MockitoExtension.class) +class LibraryInterestApplicationTest { + + private static final long NOVEL_ID = 1L; + + @InjectMocks + private LibraryInterestApplication application; + + @Mock + private NovelServiceImpl novelService; + + @Mock + private LibraryService libraryService; + + @Mock + private NovelStatisticsService novelStatisticsService; + + @Mock + private User user; + + @Mock + private Novel novel; + + @DisplayName("관심 등록 전후의 UserNovel 통계 기여분을 작품 통계 서비스에 전달한다") + @Test + void registersInterestAndUpdatesStatistics() { + UserNovel library = UserNovel.create(null, 0.0f, null, null, user, novel); + given(novelService.getNovelOrException(NOVEL_ID)).willReturn(novel); + given(libraryService.getOrCreateLibraryForInterest(user, novel)).willReturn(library); + willAnswer(invocation -> { + library.markAsInterested(); + return null; + }).given(libraryService).registerInterest(library); + + application.registerAsInterest(user, NOVEL_ID); + + then(novelStatisticsService).should().updateByDelta( + NOVEL_ID, + NovelStatisticsContribution.EMPTY, + new NovelStatisticsContribution(BigDecimal.ZERO, 0L, 1L) + ); + } + + @DisplayName("관심 해제 전후의 UserNovel 통계 기여분을 작품 통계 서비스에 전달한다") + @Test + void unregistersInterestAndUpdatesStatistics() { + UserNovel library = UserNovel.create(null, 0.0f, null, null, user, novel); + library.markAsInterested(); + given(libraryService.getLibraryForUpdateOrNull(user, NOVEL_ID)).willReturn(library); + willAnswer(invocation -> { + library.unmarkAsInterested(); + return null; + }).given(libraryService).unregisterInterest(library); + + application.unregisterAsInterest(user, NOVEL_ID); + + then(novelStatisticsService).should().updateByDelta( + NOVEL_ID, + new NovelStatisticsContribution(BigDecimal.ZERO, 0L, 1L), + NovelStatisticsContribution.EMPTY + ); + } +} diff --git a/src/test/java/org/websoso/WSSServer/library/service/LibraryServiceTest.java b/src/test/java/org/websoso/WSSServer/library/service/LibraryServiceTest.java new file mode 100644 index 00000000..0d70cf5b --- /dev/null +++ b/src/test/java/org/websoso/WSSServer/library/service/LibraryServiceTest.java @@ -0,0 +1,108 @@ +package org.websoso.WSSServer.library.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; + +import java.util.List; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.websoso.WSSServer.domain.common.ReadStatus; +import org.websoso.WSSServer.library.domain.UserNovel; +import org.websoso.WSSServer.library.repository.UserNovelRepository; +import org.websoso.WSSServer.library.repository.projection.NovelInterestCount; +import org.websoso.WSSServer.novel.domain.Novel; +import org.websoso.WSSServer.user.domain.User; + +@ExtendWith(MockitoExtension.class) +class LibraryServiceTest { + + private static final long NOVEL_ID = 1L; + + @InjectMocks + private LibraryService libraryService; + + @Mock + private UserNovelRepository userNovelRepository; + + @Mock + private User user; + + @Mock + private Novel novel; + + @DisplayName("평가 정보를 변경하면 UserNovel만 수정한다") + @Test + void updatesEvaluation() { + UserNovel library = UserNovel.create(ReadStatus.QUIT, 2.0f, null, null, user, novel); + + libraryService.updateEvaluation(library, 4.0f, ReadStatus.WATCHING, null, null); + + assertThat(library.getUserNovelRating()).isEqualTo(4.0f); + assertThat(library.getStatus()).isEqualTo(ReadStatus.WATCHING); + } + + @DisplayName("관심 등록용 UserNovel이 없으면 생성한 뒤 잠금 조회한다") + @Test + void getsOrCreatesLibraryForInterest() { + given(user.getUserId()).willReturn(10L); + given(novel.getNovelId()).willReturn(NOVEL_ID); + UserNovel library = UserNovel.create(null, 0.0f, null, null, user, novel); + given(userNovelRepository.findByNovelIdAndUserForUpdate(NOVEL_ID, user)) + .willReturn(Optional.of(library)); + + UserNovel result = libraryService.getOrCreateLibraryForInterest(user, novel); + + assertThat(result).isSameAs(library); + then(userNovelRepository).should().insertLibraryIfAbsent( + 10L, + NOVEL_ID, + UserNovel.DEFAULT_RATING, + UserNovel.DEFAULT_STATUS + ); + } + + @DisplayName("관심 등록 시 UserNovel의 관심 상태만 변경한다") + @Test + void registersInterest() { + UserNovel library = UserNovel.create(null, 0.0f, null, null, user, novel); + + libraryService.registerInterest(library); + + assertThat(library.getIsInterest()).isTrue(); + } + + @DisplayName("UserNovel 삭제를 저장소에 위임한다") + @Test + void deletesLibrary() { + UserNovel library = UserNovel.create(ReadStatus.WATCHED, 4.5f, null, null, user, novel); + + libraryService.delete(library); + + then(userNovelRepository).should().delete(library); + } + + @DisplayName("작품별 관심 수를 한 번의 집계 조회 결과로 반환한다") + @Test + void getsInterestCountsByNovelIds() { + List novelIds = List.of(1L, 2L); + given(userNovelRepository.findInterestCountsByNovelIds(novelIds)) + .willReturn(List.of( + new NovelInterestCount(1L, 3L), + new NovelInterestCount(2L, 5L) + )); + + Map result = libraryService.getInterestCountsByNovelIds(novelIds); + + assertThat(result).containsExactlyInAnyOrderEntriesOf(Map.of( + 1L, 3L, + 2L, 5L + )); + } +} diff --git a/src/test/java/org/websoso/WSSServer/novel/job/NovelStatisticsCorrectionJobTest.java b/src/test/java/org/websoso/WSSServer/novel/job/NovelStatisticsCorrectionJobTest.java new file mode 100644 index 00000000..dd752e9a --- /dev/null +++ b/src/test/java/org/websoso/WSSServer/novel/job/NovelStatisticsCorrectionJobTest.java @@ -0,0 +1,29 @@ +package org.websoso.WSSServer.novel.job; + +import static org.mockito.BDDMockito.then; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.websoso.WSSServer.novel.service.NovelStatisticsService; + +@ExtendWith(MockitoExtension.class) +class NovelStatisticsCorrectionJobTest { + + @InjectMocks + private NovelStatisticsCorrectionJob job; + + @Mock + private NovelStatisticsService novelStatisticsService; + + @DisplayName("스케줄 실행 시 전체 작품 통계를 보정한다") + @Test + void correctsNovelStatistics() { + job.correct(); + + then(novelStatisticsService).should().correctAll(); + } +} diff --git a/src/test/java/org/websoso/WSSServer/novel/service/NovelStatisticsServiceTest.java b/src/test/java/org/websoso/WSSServer/novel/service/NovelStatisticsServiceTest.java new file mode 100644 index 00000000..e4567662 --- /dev/null +++ b/src/test/java/org/websoso/WSSServer/novel/service/NovelStatisticsServiceTest.java @@ -0,0 +1,67 @@ +package org.websoso.WSSServer.novel.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.never; + +import java.math.BigDecimal; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.websoso.WSSServer.novel.domain.NovelStatisticsContribution; +import org.websoso.WSSServer.novel.repository.NovelStatisticsRepository; + +@ExtendWith(MockitoExtension.class) +class NovelStatisticsServiceTest { + + @InjectMocks + private NovelStatisticsService novelStatisticsService; + + @Mock + private NovelStatisticsRepository novelStatisticsRepository; + + @DisplayName("변경 전후의 차이만 작품 통계에 반영한다") + @Test + void updatesStatisticsByDelta() { + NovelStatisticsContribution before = + new NovelStatisticsContribution(new BigDecimal("2.0"), 1L, 0L); + NovelStatisticsContribution after = + new NovelStatisticsContribution(new BigDecimal("4.0"), 1L, 1L); + + novelStatisticsService.updateByDelta(1L, before, after); + + then(novelStatisticsRepository).should().insertIfAbsent(1L); + then(novelStatisticsRepository).should() + .updateByDelta(1L, new BigDecimal("2.0"), 0L, 1L); + } + + @DisplayName("통계 기여분에 변화가 없으면 UPDATE를 실행하지 않는다") + @Test + void skipsUpdateWhenDeltaIsZero() { + NovelStatisticsContribution contribution = + new NovelStatisticsContribution(new BigDecimal("4.0"), 1L, 1L); + + novelStatisticsService.updateByDelta(1L, contribution, contribution); + + then(novelStatisticsRepository).should(never()).insertIfAbsent(anyLong()); + then(novelStatisticsRepository).should(never()) + .updateByDelta(anyLong(), any(BigDecimal.class), anyLong(), anyLong()); + } + + @DisplayName("전체 작품 통계를 원본 서재 데이터 기준으로 보정한다") + @Test + void correctsAllStatistics() { + given(novelStatisticsRepository.correctAll()).willReturn(10); + + int affectedRows = novelStatisticsService.correctAll(); + + assertThat(affectedRows).isEqualTo(10); + then(novelStatisticsRepository).should().correctAll(); + } +}