diff --git a/src/main/java/project/flipnote/bookmark/entity/BookmarkTargetType.java b/src/main/java/project/flipnote/bookmark/entity/BookmarkTargetType.java index 153e0983..6cdb2f94 100644 --- a/src/main/java/project/flipnote/bookmark/entity/BookmarkTargetType.java +++ b/src/main/java/project/flipnote/bookmark/entity/BookmarkTargetType.java @@ -1,5 +1,21 @@ package project.flipnote.bookmark.entity; +import lombok.extern.slf4j.Slf4j; +import project.flipnote.common.model.event.BookmarkEventTargetType; + +@Slf4j public enum BookmarkTargetType { - CARD_SET + CARD_SET; + + public BookmarkEventTargetType toEventType() { + try { + return BookmarkEventTargetType.valueOf(this.name()); + } catch (IllegalArgumentException e) { + log.error("Failed to map BookmarkTargetType '{}' to BookmarkEventTargetType", this.name(), e); + throw new IllegalStateException( + "Invalid mapping from BookmarkTargetType to BookmarkEventTargetType: " + this.name(), + e + ); + } + } } diff --git a/src/main/java/project/flipnote/bookmark/repository/BookmarkRepository.java b/src/main/java/project/flipnote/bookmark/repository/BookmarkRepository.java index f51e5f99..265bb967 100644 --- a/src/main/java/project/flipnote/bookmark/repository/BookmarkRepository.java +++ b/src/main/java/project/flipnote/bookmark/repository/BookmarkRepository.java @@ -1,5 +1,6 @@ package project.flipnote.bookmark.repository; +import java.util.List; import java.util.Optional; import java.util.Set; @@ -17,5 +18,7 @@ public interface BookmarkRepository extends JpaRepository { Page findAllByTargetTypeAndUserId(BookmarkTargetType targetType, Long userId, Pageable pageable); - int deleteByTargetTypeAndUserIdAndTargetIdIn(BookmarkTargetType targetType, Long userId, Set targetIds); + List findAllByTargetTypeAndUserIdAndTargetIdIn( + BookmarkTargetType targetType, Long userId, Set targetIds + ); } diff --git a/src/main/java/project/flipnote/bookmark/service/BookmarkService.java b/src/main/java/project/flipnote/bookmark/service/BookmarkService.java index e38a8ee1..a6e9853c 100644 --- a/src/main/java/project/flipnote/bookmark/service/BookmarkService.java +++ b/src/main/java/project/flipnote/bookmark/service/BookmarkService.java @@ -1,10 +1,12 @@ package project.flipnote.bookmark.service; import java.time.LocalDateTime; +import java.util.List; import java.util.Map; import java.util.Set; import java.util.stream.Collectors; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.data.domain.Page; import org.springframework.stereotype.Service; @@ -20,6 +22,9 @@ import project.flipnote.bookmark.repository.BookmarkRepository; import project.flipnote.cardset.service.CardSetService; import project.flipnote.common.exception.BizException; +import project.flipnote.common.model.event.BookmarkAddedEvent; +import project.flipnote.common.model.event.BookmarkRemovedEvent; +import project.flipnote.common.model.event.BulkBookmarkRemovedEvent; import project.flipnote.common.model.response.IdResponse; import project.flipnote.common.model.response.PagingResponse; @@ -32,6 +37,7 @@ public class BookmarkService { private final BookmarkRepository bookmarkRepository; private final BookmarkTargetFetchService bookmarkTargetFetchService; private final CardSetService cardSetService; + private final ApplicationEventPublisher eventPublisher; /** * 즐겨찾기 추가 @@ -59,6 +65,8 @@ public IdResponse addBookmark(Long userId, BookmarkTargetType targetType, Long t throw new BizException(BookmarkErrorCode.BOOKMARK_ALREADY_EXISTS); } + eventPublisher.publishEvent(new BookmarkAddedEvent(targetType.toEventType(), targetId, userId)); + return IdResponse.from(bookmark.getId()); } @@ -78,6 +86,8 @@ public IdResponse deleteBookmark(Long userId, BookmarkTargetType targetType, Lon bookmarkRepository.delete(bookmark); + eventPublisher.publishEvent(new BookmarkRemovedEvent(targetType.toEventType(), targetId, userId)); + return IdResponse.from(bookmark.getId()); } @@ -128,6 +138,18 @@ public void removePrivateCardSetBookmarks(Long groupId, Long userId) { return; } - bookmarkRepository.deleteByTargetTypeAndUserIdAndTargetIdIn(BookmarkTargetType.CARD_SET, userId, privateCardSetIds); + BookmarkTargetType targetType = BookmarkTargetType.CARD_SET; + List bookmarks + = bookmarkRepository.findAllByTargetTypeAndUserIdAndTargetIdIn(targetType, userId, privateCardSetIds); + if (bookmarks.isEmpty()) { + return; + } + + bookmarkRepository.deleteAll(bookmarks); + + List targetIds = bookmarks.stream() + .map(Bookmark::getTargetId) + .toList(); + eventPublisher.publishEvent(new BulkBookmarkRemovedEvent(targetType.toEventType(), targetIds, userId)); } } diff --git a/src/main/java/project/flipnote/cardset/entity/CardSetMetadata.java b/src/main/java/project/flipnote/cardset/entity/CardSetMetadata.java index 25114f67..d3eb2923 100644 --- a/src/main/java/project/flipnote/cardset/entity/CardSetMetadata.java +++ b/src/main/java/project/flipnote/cardset/entity/CardSetMetadata.java @@ -21,8 +21,13 @@ public class CardSetMetadata { @Column(nullable = false) private int likeCount; + @Column(nullable = false) + private int bookmarkCount; + @Builder public CardSetMetadata(Long id) { this.id = id; + this.likeCount = 0; + this.bookmarkCount = 0; } } diff --git a/src/main/java/project/flipnote/cardset/listener/BulkCardSetBookmarkRemovedEventHandler.java b/src/main/java/project/flipnote/cardset/listener/BulkCardSetBookmarkRemovedEventHandler.java new file mode 100644 index 00000000..02902608 --- /dev/null +++ b/src/main/java/project/flipnote/cardset/listener/BulkCardSetBookmarkRemovedEventHandler.java @@ -0,0 +1,47 @@ +package project.flipnote.cardset.listener; + +import org.springframework.dao.DataAccessException; +import org.springframework.retry.annotation.Backoff; +import org.springframework.retry.annotation.Recover; +import org.springframework.retry.annotation.Retryable; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import project.flipnote.cardset.service.CardSetService; +import project.flipnote.common.model.event.BookmarkEventTargetType; +import project.flipnote.common.model.event.BulkBookmarkRemovedEvent; + +@Slf4j +@RequiredArgsConstructor +@Component +public class BulkCardSetBookmarkRemovedEventHandler { + + private final CardSetService cardSetService; + + @Async + @Retryable( + maxAttempts = 3, + retryFor = DataAccessException.class, + backoff = @Backoff(delay = 2000, multiplier = 2) + ) + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleBookmarkRemovedEvent(BulkBookmarkRemovedEvent event) { + if (event.targetType() != BookmarkEventTargetType.CARD_SET) { + return; + } + + cardSetService.decrementBookmarkCount(event.targetIds()); + } + + @Recover + public void recover(Exception ex, BulkBookmarkRemovedEvent event) { + log.error( + "카드셋 즐겨찾기 벌크 삭제 처리 중 예외 발생 : targetType={}, userId={}, targetIds={}", + event.targetType(), event.userId(), event.targetIds(), ex + ); + } +} diff --git a/src/main/java/project/flipnote/cardset/listener/CardSetBookmarkAddedEventHandler.java b/src/main/java/project/flipnote/cardset/listener/CardSetBookmarkAddedEventHandler.java new file mode 100644 index 00000000..3e6fbcea --- /dev/null +++ b/src/main/java/project/flipnote/cardset/listener/CardSetBookmarkAddedEventHandler.java @@ -0,0 +1,47 @@ +package project.flipnote.cardset.listener; + +import org.springframework.dao.DataAccessException; +import org.springframework.retry.annotation.Backoff; +import org.springframework.retry.annotation.Recover; +import org.springframework.retry.annotation.Retryable; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import project.flipnote.cardset.service.CardSetService; +import project.flipnote.common.model.event.BookmarkAddedEvent; +import project.flipnote.common.model.event.BookmarkEventTargetType; + +@Slf4j +@RequiredArgsConstructor +@Component +public class CardSetBookmarkAddedEventHandler { + + private final CardSetService cardSetService; + + @Async + @Retryable( + maxAttempts = 3, + retryFor = DataAccessException.class, + backoff = @Backoff(delay = 2000, multiplier = 2) + ) + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleBookmarkAddedEvent(BookmarkAddedEvent event) { + if (event.targetType() != BookmarkEventTargetType.CARD_SET) { + return; + } + + cardSetService.incrementBookmarkCount(event.targetId()); + } + + @Recover + public void recover(Exception ex, BookmarkAddedEvent event) { + log.error( + "카드셋 즐겨찾기 추가 처리 중 예외 발생 : targetType={}, targetId={}, userId={}", + event.targetType(), event.targetId(), event.userId(), ex + ); + } +} diff --git a/src/main/java/project/flipnote/cardset/listener/CardSetBookmarkRemovedEventHandler.java b/src/main/java/project/flipnote/cardset/listener/CardSetBookmarkRemovedEventHandler.java new file mode 100644 index 00000000..cfbfe11b --- /dev/null +++ b/src/main/java/project/flipnote/cardset/listener/CardSetBookmarkRemovedEventHandler.java @@ -0,0 +1,47 @@ +package project.flipnote.cardset.listener; + +import org.springframework.dao.DataAccessException; +import org.springframework.retry.annotation.Backoff; +import org.springframework.retry.annotation.Recover; +import org.springframework.retry.annotation.Retryable; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import project.flipnote.cardset.service.CardSetService; +import project.flipnote.common.model.event.BookmarkEventTargetType; +import project.flipnote.common.model.event.BookmarkRemovedEvent; + +@Slf4j +@RequiredArgsConstructor +@Component +public class CardSetBookmarkRemovedEventHandler { + + private final CardSetService cardSetService; + + @Async + @Retryable( + maxAttempts = 3, + retryFor = DataAccessException.class, + backoff = @Backoff(delay = 2000, multiplier = 2) + ) + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleBookmarkRemovedEvent(BookmarkRemovedEvent event) { + if (event.targetType() != BookmarkEventTargetType.CARD_SET) { + return; + } + + cardSetService.decrementBookmarkCount(event.targetId()); + } + + @Recover + public void recover(Exception ex, BookmarkRemovedEvent event) { + log.error( + "카드셋 즐겨찾기 삭제 처리 중 예외 발생 : targetType={}, targetId={}, userId={}", + event.targetType(), event.targetId(), event.userId(), ex + ); + } +} diff --git a/src/main/java/project/flipnote/cardset/model/CardSetSortField.java b/src/main/java/project/flipnote/cardset/model/CardSetSortField.java index 0282178c..f842dd66 100644 --- a/src/main/java/project/flipnote/cardset/model/CardSetSortField.java +++ b/src/main/java/project/flipnote/cardset/model/CardSetSortField.java @@ -5,7 +5,7 @@ import java.util.stream.Collectors; public enum CardSetSortField { - ID, LIKE; + ID, LIKE, BOOKMARK; public static Set getFieldNames() { return Arrays.stream(values()) diff --git a/src/main/java/project/flipnote/cardset/repository/CardSetMetadataRepository.java b/src/main/java/project/flipnote/cardset/repository/CardSetMetadataRepository.java index f39ec1d6..d6cb0a7b 100644 --- a/src/main/java/project/flipnote/cardset/repository/CardSetMetadataRepository.java +++ b/src/main/java/project/flipnote/cardset/repository/CardSetMetadataRepository.java @@ -1,5 +1,7 @@ package project.flipnote.cardset.repository; +import java.util.List; + import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; @@ -20,4 +22,24 @@ public interface CardSetMetadataRepository extends JpaRepository 0 THEN m.bookmarkCount - 1 ELSE 0 END + WHERE m.id = :cardSetId + """) + int decrementBookmarkCount(@Param("cardSetId") Long cardSetId); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + UPDATE CardSetMetadata m + SET m.bookmarkCount = CASE WHEN m.bookmarkCount > 0 THEN m.bookmarkCount - 1 ELSE 0 END + WHERE m.id IN :cardSetIds + """) + int decrementBookmarkCount(@Param("cardSetIds") List cardSetIds); } diff --git a/src/main/java/project/flipnote/cardset/repository/CardSetRepositoryCustomImpl.java b/src/main/java/project/flipnote/cardset/repository/CardSetRepositoryCustomImpl.java index 5f950ea2..998edb75 100644 --- a/src/main/java/project/flipnote/cardset/repository/CardSetRepositoryCustomImpl.java +++ b/src/main/java/project/flipnote/cardset/repository/CardSetRepositoryCustomImpl.java @@ -58,6 +58,9 @@ public Page searchByNameContainingAndCategory( if (sortField == CardSetSortField.LIKE) { orders.add(toOrderSpecifier(cardSetMetadata.likeCount, order)); useMetadata = true; + } else if (sortField == CardSetSortField.BOOKMARK) { + orders.add(toOrderSpecifier(cardSetMetadata.bookmarkCount, order)); + useMetadata = true; } else { orders.add(toOrderSpecifier(cardSet.id, order)); hasIdSort = true; @@ -74,7 +77,7 @@ public Page searchByNameContainingAndCategory( .where(buildCardSetSearchFilterConditions(name, category)); if (useMetadata) { - selectQuery.join(cardSetMetadata).on(cardSet.id.eq(cardSetMetadata.id)); + selectQuery.leftJoin(cardSetMetadata).on(cardSet.id.eq(cardSetMetadata.id)); } List content = selectQuery diff --git a/src/main/java/project/flipnote/cardset/service/CardSetService.java b/src/main/java/project/flipnote/cardset/service/CardSetService.java index 5cfee2a6..2e61c06b 100644 --- a/src/main/java/project/flipnote/cardset/service/CardSetService.java +++ b/src/main/java/project/flipnote/cardset/service/CardSetService.java @@ -122,7 +122,7 @@ public CreateCardSetResponse createCardSet(Long groupId, AuthPrinciple authPrinc public PagingResponse getCardSets(CardSetSearchRequest req) { // TODO: Projection 튜닝 필요 Page cardSetPage = cardSetRepository.searchByNameContainingAndCategory( - req.getKeyword(), Category.from(req.getCategory()), req.getPageRequest() + req.getKeyword(), Category.from(req.getCategory()), req.getPageRequest() ); Page res = cardSetPage.map(CardSetSummaryResponse::from); @@ -223,7 +223,7 @@ public List getCardSetsByIds(Set targetIds) { * 사용자가 특정 카드셋에 접근할 수 있는지 여부를 확인 * * @param cardSetId 확인할 카드셋의 ID - * @param userId 접근 권한을 확인할 사용자의 ID + * @param userId 접근 권한을 확인할 사용자의 ID * @return 접근 가능 여부 * @author 윤정환 */ @@ -237,7 +237,7 @@ public boolean isCardSetViewable(Long cardSetId, Long userId) { * 카드셋 ID 목록에 해당하는 카드셋 목록 조회 * * @param targetIds 조회할 카드셋 ID 목록 - * @param userId 카드셋 목록을 조회하는 회원 ID + * @param userId 카드셋 목록을 조회하는 회원 ID * @return 조회된 카드셋 목록 * @author 윤정환 */ @@ -260,4 +260,37 @@ public List findViewableCardSetsByIds(Set targetId public Set findPrivateCardSetIds(Long groupId) { return cardSetRepository.findPrivateIdsByGroupId(groupId); } + + /** + * 카드셋 즐겨찾기 수를 1 증가 + * + * @param cardSetId 즐겨찾기 수를 증가시킬 카드셋 ID + * @author 윤정환 + */ + @Transactional + public void incrementBookmarkCount(Long cardSetId) { + cardSetMetadataRepository.incrementBookmarkCount(cardSetId); + } + + /** + * 카드셋 즐겨찾기 수를 1 감소 + * + * @param cardSetId 즐겨찾기 수를 감소시킬 카드셋 ID + * @author 윤정환 + */ + @Transactional + public void decrementBookmarkCount(Long cardSetId) { + cardSetMetadataRepository.decrementBookmarkCount(cardSetId); + } + + /** + * 여러 카드셋 즐겨찾기 수를 1 감소 + * + * @param cardSetIds 즐겨찾기 수를 감소시킬 카드셋 ID 목록 + * @author 윤정환 + */ + @Transactional + public void decrementBookmarkCount(List cardSetIds) { + cardSetMetadataRepository.decrementBookmarkCount(cardSetIds); + } } diff --git a/src/main/java/project/flipnote/common/model/event/BookmarkAddedEvent.java b/src/main/java/project/flipnote/common/model/event/BookmarkAddedEvent.java new file mode 100644 index 00000000..b7e3a134 --- /dev/null +++ b/src/main/java/project/flipnote/common/model/event/BookmarkAddedEvent.java @@ -0,0 +1,8 @@ +package project.flipnote.common.model.event; + +public record BookmarkAddedEvent( + BookmarkEventTargetType targetType, + Long targetId, + Long userId +) { +} diff --git a/src/main/java/project/flipnote/common/model/event/BookmarkEventTargetType.java b/src/main/java/project/flipnote/common/model/event/BookmarkEventTargetType.java new file mode 100644 index 00000000..cd68fed2 --- /dev/null +++ b/src/main/java/project/flipnote/common/model/event/BookmarkEventTargetType.java @@ -0,0 +1,5 @@ +package project.flipnote.common.model.event; + +public enum BookmarkEventTargetType { + CARD_SET +} diff --git a/src/main/java/project/flipnote/common/model/event/BookmarkRemovedEvent.java b/src/main/java/project/flipnote/common/model/event/BookmarkRemovedEvent.java new file mode 100644 index 00000000..1751274b --- /dev/null +++ b/src/main/java/project/flipnote/common/model/event/BookmarkRemovedEvent.java @@ -0,0 +1,8 @@ +package project.flipnote.common.model.event; + +public record BookmarkRemovedEvent( + BookmarkEventTargetType targetType, + Long targetId, + Long userId +) { +} diff --git a/src/main/java/project/flipnote/common/model/event/BulkBookmarkRemovedEvent.java b/src/main/java/project/flipnote/common/model/event/BulkBookmarkRemovedEvent.java new file mode 100644 index 00000000..44a6dc69 --- /dev/null +++ b/src/main/java/project/flipnote/common/model/event/BulkBookmarkRemovedEvent.java @@ -0,0 +1,10 @@ +package project.flipnote.common.model.event; + +import java.util.List; + +public record BulkBookmarkRemovedEvent( + BookmarkEventTargetType targetType, + List targetIds, + Long userId +) { +}