diff --git a/src/main/java/project/flipnote/bookmark/listener/GroupLeftCleanupBookmarkListener.java b/src/main/java/project/flipnote/bookmark/listener/GroupLeftCleanupBookmarkListener.java new file mode 100644 index 00000000..0f3cb44d --- /dev/null +++ b/src/main/java/project/flipnote/bookmark/listener/GroupLeftCleanupBookmarkListener.java @@ -0,0 +1,38 @@ +package project.flipnote.bookmark.listener; + +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.bookmark.service.BookmarkService; +import project.flipnote.common.model.event.GroupLeftEvent; + +@Slf4j +@RequiredArgsConstructor +@Component +public class GroupLeftCleanupBookmarkListener { + + private final BookmarkService bookmarkService; + + @Async + @Retryable( + maxAttempts = 3, + backoff = @Backoff(delay = 2000, multiplier = 2) + ) + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleGroupLeftEvent(GroupLeftEvent event) { + // TODO: 해당 이벤트 그룹 탈퇴시 퍼블리싱되게 + bookmarkService.removePrivateCardSetBookmarks(event.groupId(), event.userId()); + } + + @Recover + public void recover(Exception ex, GroupLeftEvent event) { + log.error("그룹 탈퇴 후처리 - 비공개 카드셋 북마크 제거 실패: groupId={}, userId={}", event.groupId(), event.userId(), ex); + } +} diff --git a/src/main/java/project/flipnote/bookmark/repository/BookmarkRepository.java b/src/main/java/project/flipnote/bookmark/repository/BookmarkRepository.java index 2a9289f9..f51e5f99 100644 --- a/src/main/java/project/flipnote/bookmark/repository/BookmarkRepository.java +++ b/src/main/java/project/flipnote/bookmark/repository/BookmarkRepository.java @@ -1,6 +1,7 @@ package project.flipnote.bookmark.repository; import java.util.Optional; +import java.util.Set; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -15,4 +16,6 @@ public interface BookmarkRepository extends JpaRepository { Optional findByTargetTypeAndUserIdAndTargetId(BookmarkTargetType targetType, Long userId, Long targetId); Page findAllByTargetTypeAndUserId(BookmarkTargetType targetType, Long userId, Pageable pageable); + + int deleteByTargetTypeAndUserIdAndTargetIdIn(BookmarkTargetType targetType, Long userId, Set targetIds); } diff --git a/src/main/java/project/flipnote/bookmark/service/BookmarkPolicyService.java b/src/main/java/project/flipnote/bookmark/service/BookmarkPolicyService.java index 10750ae3..30c8f4c7 100644 --- a/src/main/java/project/flipnote/bookmark/service/BookmarkPolicyService.java +++ b/src/main/java/project/flipnote/bookmark/service/BookmarkPolicyService.java @@ -16,8 +16,8 @@ public class BookmarkPolicyService { private final BookmarkRepository bookmarkRepository; private final BookmarkTargetFetchService bookmarkTargetFetchService; - public void validateTargetExists(BookmarkTargetType targetType, Long targetId) { - if (!bookmarkTargetFetchService.existsByTypeAndId(targetType, targetId)) { + public void validateTargetViewable(BookmarkTargetType targetType, Long targetId, Long userId) { + if (!bookmarkTargetFetchService.isTargetViewable(targetType, targetId, userId)) { throw new BizException(BookmarkErrorCode.BOOKMARK_TARGET_NOT_FOUND); } } diff --git a/src/main/java/project/flipnote/bookmark/service/BookmarkService.java b/src/main/java/project/flipnote/bookmark/service/BookmarkService.java index 7350ebaf..e38a8ee1 100644 --- a/src/main/java/project/flipnote/bookmark/service/BookmarkService.java +++ b/src/main/java/project/flipnote/bookmark/service/BookmarkService.java @@ -18,6 +18,7 @@ import project.flipnote.bookmark.model.BookmarkSearchRequest; import project.flipnote.bookmark.model.BookmarkTargetResponse; import project.flipnote.bookmark.repository.BookmarkRepository; +import project.flipnote.cardset.service.CardSetService; import project.flipnote.common.exception.BizException; import project.flipnote.common.model.response.IdResponse; import project.flipnote.common.model.response.PagingResponse; @@ -30,6 +31,7 @@ public class BookmarkService { private final BookmarkPolicyService bookmarkPolicyService; private final BookmarkRepository bookmarkRepository; private final BookmarkTargetFetchService bookmarkTargetFetchService; + private final CardSetService cardSetService; /** * 즐겨찾기 추가 @@ -43,7 +45,7 @@ public class BookmarkService { @Transactional public IdResponse addBookmark(Long userId, BookmarkTargetType targetType, Long targetId) { bookmarkPolicyService.validateBookmarkNotExists(targetType, userId, targetId); - bookmarkPolicyService.validateTargetExists(targetType, targetId); + bookmarkPolicyService.validateTargetViewable(targetType, targetId, userId); Bookmark bookmark = Bookmark.builder() .targetType(targetType) @@ -100,7 +102,7 @@ public PagingResponse> getBookmarks( Set targetIds = likedAtMap.keySet(); Map targetMap - = bookmarkTargetFetchService.fetchByTypeAndIds(targetType, targetIds); + = bookmarkTargetFetchService.fetchByTypeAndIds(targetType, targetIds, userId); Page> content = bookmarkPage.map(bookmark -> new BookmarkResponse<>( @@ -111,4 +113,21 @@ public PagingResponse> getBookmarks( return PagingResponse.from(content); } + + /** + * 해당 그룹의 비공개 카드셋 즐겨찾기 제거 + * + * @param groupId 즐겨찾기를 제거할 카드셋이 속한 그룹 ID + * @param userId 즐겨찾기를 제거할 사용자 ID + * @author 윤정환 + */ + @Transactional + public void removePrivateCardSetBookmarks(Long groupId, Long userId) { + Set privateCardSetIds = cardSetService.findPrivateCardSetIds(groupId); + if (privateCardSetIds == null || privateCardSetIds.isEmpty()) { + return; + } + + bookmarkRepository.deleteByTargetTypeAndUserIdAndTargetIdIn(BookmarkTargetType.CARD_SET, userId, privateCardSetIds); + } } diff --git a/src/main/java/project/flipnote/bookmark/service/BookmarkTargetFetchService.java b/src/main/java/project/flipnote/bookmark/service/BookmarkTargetFetchService.java index f67c84cb..f10d4fc2 100644 --- a/src/main/java/project/flipnote/bookmark/service/BookmarkTargetFetchService.java +++ b/src/main/java/project/flipnote/bookmark/service/BookmarkTargetFetchService.java @@ -30,19 +30,20 @@ public void init() { .collect(Collectors.toMap(BookmarkTargetFetcher::getTargetType, Function.identity())); } - public boolean existsByTypeAndId(BookmarkTargetType targetType, Long targetId) { + public boolean isTargetViewable(BookmarkTargetType targetType, Long targetId, Long userId) { BookmarkTargetFetcher targetFetcher = getFetcher(targetType); - return targetFetcher.existsById(targetId); + return targetFetcher.isTargetViewable(targetId, userId); } public Map fetchByTypeAndIds( BookmarkTargetType targetType, - Set targetIds + Set targetIds, + Long userId ) { BookmarkTargetFetcher targetFetcher = getFetcher(targetType); - return targetFetcher.fetchByIds(targetIds); + return targetFetcher.fetchByIds(targetIds, userId); } private BookmarkTargetFetcher getFetcher(BookmarkTargetType targetType) { diff --git a/src/main/java/project/flipnote/bookmark/service/fetcher/BookmarkCardSetFetcher.java b/src/main/java/project/flipnote/bookmark/service/fetcher/BookmarkCardSetFetcher.java index 77f30121..1b562ae2 100644 --- a/src/main/java/project/flipnote/bookmark/service/fetcher/BookmarkCardSetFetcher.java +++ b/src/main/java/project/flipnote/bookmark/service/fetcher/BookmarkCardSetFetcher.java @@ -24,13 +24,13 @@ public BookmarkTargetType getTargetType() { } @Override - public boolean existsById(Long targetId) { - return cardSetService.existsById(targetId); + public boolean isTargetViewable(Long targetId, Long userId) { + return cardSetService.isCardSetViewable(targetId, userId); } @Override - public Map fetchByIds(Set ids) { - return cardSetService.getCardSetsByIds(ids).stream() + public Map fetchByIds(Set targetIds, Long userId) { + return cardSetService.findViewableCardSetsByIds(targetIds, userId).stream() .map(CardSetBookmarkResponse::from) .collect(Collectors.toMap(CardSetBookmarkResponse::getId, Function.identity())); } diff --git a/src/main/java/project/flipnote/bookmark/service/fetcher/BookmarkTargetFetcher.java b/src/main/java/project/flipnote/bookmark/service/fetcher/BookmarkTargetFetcher.java index 0be48db7..308e3896 100644 --- a/src/main/java/project/flipnote/bookmark/service/fetcher/BookmarkTargetFetcher.java +++ b/src/main/java/project/flipnote/bookmark/service/fetcher/BookmarkTargetFetcher.java @@ -9,7 +9,7 @@ public interface BookmarkTargetFetcher { BookmarkTargetType getTargetType(); - boolean existsById(Long targetId); + boolean isTargetViewable(Long targetId, Long userId); - Map fetchByIds(Set ids); + Map fetchByIds(Set targetIds, Long userId); } diff --git a/src/main/java/project/flipnote/cardset/repository/CardSetRepository.java b/src/main/java/project/flipnote/cardset/repository/CardSetRepository.java index 42a30727..91f10e06 100644 --- a/src/main/java/project/flipnote/cardset/repository/CardSetRepository.java +++ b/src/main/java/project/flipnote/cardset/repository/CardSetRepository.java @@ -1,6 +1,7 @@ package project.flipnote.cardset.repository; import java.util.Optional; +import java.util.Set; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -29,4 +30,10 @@ Page findByNameContainingAndCategory( Optional findByIdAndGroup_Id(Long id, Long groupId); + @Query(""" + SELECT c.id FROM CardSet c + WHERE c.group.id = :groupId + AND c.publicVisible = false + """) + Set findPrivateIdsByGroupId(@Param("groupId") Long groupId); } diff --git a/src/main/java/project/flipnote/cardset/service/CardSetPolicyService.java b/src/main/java/project/flipnote/cardset/service/CardSetPolicyService.java index 406d4cf2..58a5d18d 100644 --- a/src/main/java/project/flipnote/cardset/service/CardSetPolicyService.java +++ b/src/main/java/project/flipnote/cardset/service/CardSetPolicyService.java @@ -49,12 +49,30 @@ public void validateCardSetEditable(Long userId, Long cardSetId) { * * @param cardSet 조회 대상 카드셋 엔티티 * @param userId 조회 권한을 검증할 회원의 ID - * @param groupId 카드셋이 속한 그룹의 ID * @author 윤정환 */ - public void validateCardSetViewable(CardSet cardSet, Long userId, Long groupId) { - if (!cardSet.getPublicVisible() && !groupService.existsMember(groupId, userId)) { + public void validateCardSetViewable(CardSet cardSet, Long userId) { + if (!isCardSetViewable(cardSet, userId)) { throw new BizException(CardSetErrorCode.CARD_SET_PRIVATE); } } + + /** + * 특정 회원이 해당 카드셋을 조회할 수 있는 권한이 있는지 확인 + * + * @param cardSet 조회 대상 카드셋 엔티티 + * @param userId 조회 권한을 검증할 회원의 ID + * @return 카드셋 조회 가능 여부 + * @author 윤정환 + */ + public boolean isCardSetViewable(CardSet cardSet, Long userId) { + if (cardSet == null || userId == null) { + return false; + } + if (cardSet.getGroup() == null || cardSet.getGroup().getId() == null) { + return false; + } + + return cardSet.getPublicVisible() || groupService.existsMember(cardSet.getGroup().getId(), userId); + } } diff --git a/src/main/java/project/flipnote/cardset/service/CardSetService.java b/src/main/java/project/flipnote/cardset/service/CardSetService.java index 41b8e02a..cb9dc43c 100644 --- a/src/main/java/project/flipnote/cardset/service/CardSetService.java +++ b/src/main/java/project/flipnote/cardset/service/CardSetService.java @@ -143,7 +143,7 @@ public PagingResponse getCardSets(CardSetSearchRequest r public CardSetDetailResponse getCardSet(Long userId, Long groupId, Long cardSetId) { CardSet cardSet = cardSetPolicyService.findByIdAndGroupIdOrThrow(groupId, cardSetId); - cardSetPolicyService.validateCardSetViewable(cardSet, userId, groupId); + cardSetPolicyService.validateCardSetViewable(cardSet, userId); return CardSetDetailResponse.from(cardSet); } @@ -219,4 +219,46 @@ public List getCardSetsByIds(Set targetIds) { .map(CardSetSummaryResponse::from) .toList(); } + + /** + * 사용자가 특정 카드셋에 접근할 수 있는지 여부를 확인 + * + * @param cardSetId 확인할 카드셋의 ID + * @param userId 접근 권한을 확인할 사용자의 ID + * @return 접근 가능 여부 + * @author 윤정환 + */ + public boolean isCardSetViewable(Long cardSetId, Long userId) { + return cardSetRepository.findById(cardSetId) + .map(cardSet -> cardSetPolicyService.isCardSetViewable(cardSet, userId)) + .orElse(false); + } + + /** + * 카드셋 ID 목록에 해당하는 카드셋 목록 조회 + * + * @param targetIds 조회할 카드셋 ID 목록 + * @param userId 카드셋 목록을 조회하는 회원 ID + * @return 조회된 카드셋 목록 + * @author 윤정환 + */ + @Transactional + public List findViewableCardSetsByIds(Set targetIds, Long userId) { + // TODO: MSA로 전환시 전용 DTO로 변경 필요 + return cardSetRepository.findAllById(targetIds).stream() + .filter(cardSet -> cardSetPolicyService.isCardSetViewable(cardSet, userId)) + .map(CardSetSummaryResponse::from) + .toList(); + } + + /** + * 해당 그룹의 비공개인 카드셋의 ID들을 조회 + * + * @param groupId 조회할 그룹의 ID + * @return 그룹에 속한 비공개 카드셋 ID의 집합 + * @author 윤정환 + */ + public Set findPrivateCardSetIds(Long groupId) { + return cardSetRepository.findPrivateIdsByGroupId(groupId); + } } diff --git a/src/main/java/project/flipnote/common/config/RetryConfig.java b/src/main/java/project/flipnote/common/config/RetryConfig.java new file mode 100644 index 00000000..662e12ca --- /dev/null +++ b/src/main/java/project/flipnote/common/config/RetryConfig.java @@ -0,0 +1,9 @@ +package project.flipnote.common.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.retry.annotation.EnableRetry; + +@EnableRetry +@Configuration +public class RetryConfig { +} diff --git a/src/main/java/project/flipnote/common/model/event/GroupLeftEvent.java b/src/main/java/project/flipnote/common/model/event/GroupLeftEvent.java new file mode 100644 index 00000000..86c57a95 --- /dev/null +++ b/src/main/java/project/flipnote/common/model/event/GroupLeftEvent.java @@ -0,0 +1,7 @@ +package project.flipnote.common.model.event; + +public record GroupLeftEvent( + Long groupId, + Long userId +) { +}