diff --git a/build.gradle b/build.gradle index 40cc629a7..72be043bc 100644 --- a/build.gradle +++ b/build.gradle @@ -121,11 +121,21 @@ clean.doLast { } tasks.named('test') { + // CI 러너는 UTC 다. 일부 테스트가 jdbcTemplate 로 created_at 을 직접 써서, JVM 타임존이 + // hibernate.jdbc.time_zone 과 어긋나면 커서 경계가 깨진다. 서비스 기준인 KST 로 고정한다. + systemProperty 'user.timezone', 'Asia/Seoul' useJUnitPlatform { if (System.getenv('CI') == 'true') { excludeTags 'concurrency' } } + // 실패 원인이 요약되면 CI 로그만으로 진단할 수 없다 + testLogging { + events 'failed' + exceptionFormat = 'full' + showStackTraces = true + showCauses = true + } } apply from: "$rootDir/jacoco.gradle" \ No newline at end of file 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 63c7ce2c2..748da15e5 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 @@ -81,9 +81,10 @@ public BaseResponse showRecruitingRoomsWithBook( @Parameter(description = "책의 ISBN 번호 (13자리 숫자)", example = "9781234567890") @PathVariable("isbn") @Pattern(regexp = "\\d{13}") final String isbn, @Parameter(description = "커서 (첫번째 요청시 : null, 다음 요청시 : 이전 요청에서 반환받은 nextCursor 값)") - @RequestParam(required = false) final String cursor + @RequestParam(required = false) final String cursor, + @Parameter(hidden = true) @UserId final Long userId ) { - return BaseResponse.ok(bookRecruitingRoomsUseCase.getRecruitingRoomsWithBook(isbn, cursor)); + return BaseResponse.ok(bookRecruitingRoomsUseCase.getRecruitingRoomsWithBook(isbn, cursor, userId)); } @Operation( diff --git a/src/main/java/konkuk/thip/book/application/port/in/BookRecruitingRoomsUseCase.java b/src/main/java/konkuk/thip/book/application/port/in/BookRecruitingRoomsUseCase.java index 8c32d6cec..66326d668 100644 --- a/src/main/java/konkuk/thip/book/application/port/in/BookRecruitingRoomsUseCase.java +++ b/src/main/java/konkuk/thip/book/application/port/in/BookRecruitingRoomsUseCase.java @@ -4,5 +4,5 @@ public interface BookRecruitingRoomsUseCase { - BookRecruitingRoomsResponse getRecruitingRoomsWithBook(String isbn, String cursor); + BookRecruitingRoomsResponse getRecruitingRoomsWithBook(String isbn, String cursor, Long userId); } diff --git a/src/main/java/konkuk/thip/book/application/service/BookRecruitingRoomsService.java b/src/main/java/konkuk/thip/book/application/service/BookRecruitingRoomsService.java index 21c439a64..6b4e6b3b1 100644 --- a/src/main/java/konkuk/thip/book/application/service/BookRecruitingRoomsService.java +++ b/src/main/java/konkuk/thip/book/application/service/BookRecruitingRoomsService.java @@ -22,12 +22,12 @@ public class BookRecruitingRoomsService implements BookRecruitingRoomsUseCase { @Override @Transactional(readOnly = true) - public BookRecruitingRoomsResponse getRecruitingRoomsWithBook(String isbn, String cursorStr) { + public BookRecruitingRoomsResponse getRecruitingRoomsWithBook(String isbn, String cursorStr, Long userId) { Integer totalRoomCount = (cursorStr == null || cursorStr.isBlank()) ? // 첫 요청 여부 판단 roomQueryPort.countRecruitingRoomsByBookIsbn(isbn) : null; Cursor cursor = Cursor.from(cursorStr, DEFAULT_PAGE_SIZE); - CursorBasedList roomDtos = roomQueryPort.findRoomsByIsbnOrderByDeadline(isbn, cursor); + CursorBasedList roomDtos = roomQueryPort.findRoomsByIsbnOrderByDeadline(isbn, cursor, userId); return BookRecruitingRoomsResponse.of(bookQueryMapper.toRecruitingRoomDtoList(roomDtos.contents()), totalRoomCount, roomDtos.nextCursor(), roomDtos.isLast()); diff --git a/src/main/java/konkuk/thip/comment/adapter/out/persistence/CommentQueryPersistenceAdapter.java b/src/main/java/konkuk/thip/comment/adapter/out/persistence/CommentQueryPersistenceAdapter.java index 3c10850db..ca747e4bc 100644 --- a/src/main/java/konkuk/thip/comment/adapter/out/persistence/CommentQueryPersistenceAdapter.java +++ b/src/main/java/konkuk/thip/comment/adapter/out/persistence/CommentQueryPersistenceAdapter.java @@ -22,11 +22,11 @@ public class CommentQueryPersistenceAdapter implements CommentQueryPort { private final CommentMapper commentMapper; @Override - public CursorBasedList findLatestRootCommentsWithDeleted(Long postId, String postTypeStr, Cursor cursor) { + public CursorBasedList findLatestRootCommentsWithDeleted(Long postId, String postTypeStr, Cursor cursor, Long viewerId) { LocalDateTime lastCreatedAt = cursor.isFirstRequest() ? null : cursor.getLocalDateTime(0); int size = cursor.getPageSize(); - List commentQueryDtos = commentJpaRepository.findRootCommentsWithDeletedByCreatedAtDesc(postId, postTypeStr, lastCreatedAt, size); + List commentQueryDtos = commentJpaRepository.findRootCommentsWithDeletedByCreatedAtDesc(postId, postTypeStr, lastCreatedAt, size, viewerId); return CursorBasedList.of(commentQueryDtos, size, commentQueryDto -> { Cursor nextCursor = new Cursor(List.of(commentQueryDto.createdAt().toString())); @@ -35,13 +35,13 @@ public CursorBasedList findLatestRootCommentsWithDeleted(Long p } @Override - public List findAllActiveChildCommentsOldestFirst(Long rootCommentId) { - return commentJpaRepository.findAllActiveChildCommentsByCreatedAtAsc(rootCommentId); + public List findAllActiveChildCommentsOldestFirst(Long rootCommentId, Long viewerId) { + return commentJpaRepository.findAllActiveChildCommentsByCreatedAtAsc(rootCommentId, viewerId); } @Override - public Map> findAllActiveChildCommentsOldestFirst(Set rootCommentIds) { - return commentJpaRepository.findAllActiveChildCommentsByCreatedAtAsc(rootCommentIds); + public Map> findAllActiveChildCommentsOldestFirst(Set rootCommentIds, Long viewerId) { + return commentJpaRepository.findAllActiveChildCommentsByCreatedAtAsc(rootCommentIds, viewerId); } @Override diff --git a/src/main/java/konkuk/thip/comment/adapter/out/persistence/repository/CommentQueryRepository.java b/src/main/java/konkuk/thip/comment/adapter/out/persistence/repository/CommentQueryRepository.java index f10f7af76..4c7e43270 100644 --- a/src/main/java/konkuk/thip/comment/adapter/out/persistence/repository/CommentQueryRepository.java +++ b/src/main/java/konkuk/thip/comment/adapter/out/persistence/repository/CommentQueryRepository.java @@ -9,11 +9,11 @@ public interface CommentQueryRepository { - List findRootCommentsWithDeletedByCreatedAtDesc(Long postId, String postTypeStr, LocalDateTime lastCreatedAt, int size); + List findRootCommentsWithDeletedByCreatedAtDesc(Long postId, String postTypeStr, LocalDateTime lastCreatedAt, int size, Long viewerId); - List findAllActiveChildCommentsByCreatedAtAsc(Long rootCommentId); + List findAllActiveChildCommentsByCreatedAtAsc(Long rootCommentId, Long viewerId); - Map> findAllActiveChildCommentsByCreatedAtAsc(Set rootCommentIds); + Map> findAllActiveChildCommentsByCreatedAtAsc(Set rootCommentIds, Long viewerId); CommentQueryDto findRootCommentId(Long commentId); diff --git a/src/main/java/konkuk/thip/comment/adapter/out/persistence/repository/CommentQueryRepositoryImpl.java b/src/main/java/konkuk/thip/comment/adapter/out/persistence/repository/CommentQueryRepositoryImpl.java index 193ef0795..70f7b001e 100644 --- a/src/main/java/konkuk/thip/comment/adapter/out/persistence/repository/CommentQueryRepositoryImpl.java +++ b/src/main/java/konkuk/thip/comment/adapter/out/persistence/repository/CommentQueryRepositoryImpl.java @@ -16,6 +16,7 @@ import java.util.stream.Collectors; import static konkuk.thip.common.entity.StatusType.ACTIVE; +import static konkuk.thip.user.adapter.out.persistence.expression.BlockFilterExpressions.notBlockedWith; @Repository @RequiredArgsConstructor @@ -34,7 +35,7 @@ public class CommentQueryRepositoryImpl implements CommentQueryRepository { // 최상위 댓글 조회 (삭제된 댓글 포함, 최신순, 페이징) @Override - public List findRootCommentsWithDeletedByCreatedAtDesc(Long postId, String postTypeStr, LocalDateTime lastCreatedAt, int size) { + public List findRootCommentsWithDeletedByCreatedAtDesc(Long postId, String postTypeStr, LocalDateTime lastCreatedAt, int size, Long viewerId) { // 최상위 댓글(size+1) 프로젝션 생성 QCommentQueryDto proj = new QCommentQueryDto( comment.commentId, @@ -57,6 +58,12 @@ public List findRootCommentsWithDeletedByCreatedAtDesc(Long pos : Expressions.TRUE ); + // 차단 관계인 작성자의 루트 댓글은 하위 답글까지 통째로 숨긴다 + BooleanExpression notBlocked = notBlockedWith(commentCreator.userId, viewerId); + if (notBlocked != null) { + whereClause = whereClause.and(notBlocked); + } + // 조회 및 반환 return queryFactory .select(proj) @@ -69,7 +76,7 @@ public List findRootCommentsWithDeletedByCreatedAtDesc(Long pos } @Override - public List findAllActiveChildCommentsByCreatedAtAsc(Long rootCommentId) { + public List findAllActiveChildCommentsByCreatedAtAsc(Long rootCommentId, Long viewerId) { List allDescendants = new ArrayList<>(); // 결과 누적용 리스트 // 1) 부모 ID 집합에 루트 댓글 ID 추가 @@ -101,7 +108,8 @@ public List findAllActiveChildCommentsByCreatedAtAsc(Long rootC .where( comment.parent.commentId.in(parentIds), // parentIds 하위의 모든 자식 댓글 조회 comment.status.eq(ACTIVE), // 자식 댓글은 ACTIVE인 것만 조회 - commentCreator.status.eq(ACTIVE) // 자식 댓글 작성자 ACTIVE + commentCreator.status.eq(ACTIVE), // 자식 댓글 작성자 ACTIVE + notBlockedWith(commentCreator.userId, viewerId) // 차단 관계인 작성자의 답글 숨김 ) .fetch(); @@ -120,7 +128,7 @@ public List findAllActiveChildCommentsByCreatedAtAsc(Long rootC } @Override - public Map> findAllActiveChildCommentsByCreatedAtAsc(Set rootCommentIds) { + public Map> findAllActiveChildCommentsByCreatedAtAsc(Set rootCommentIds, Long viewerId) { // 1) 루트 ID별로 최상위 매핑 초기화 Map idToRoot = new HashMap<>(); for (Long rootId : rootCommentIds) { @@ -161,7 +169,8 @@ public Map> findAllActiveChildCommentsByCreatedAtAsc .where( comment.parent.commentId.in(parentIds), // parentIds 하위의 모든 자식 댓글 조회 comment.status.eq(ACTIVE), // 자식 댓글은 ACTIVE인 것만 조회 - commentCreator.status.eq(ACTIVE) // 자식 댓글 작성자 ACTIVE + commentCreator.status.eq(ACTIVE), // 자식 댓글 작성자 ACTIVE + notBlockedWith(commentCreator.userId, viewerId) // 차단 관계인 작성자의 답글 숨김 ) .fetch(); diff --git a/src/main/java/konkuk/thip/comment/application/port/out/CommentQueryPort.java b/src/main/java/konkuk/thip/comment/application/port/out/CommentQueryPort.java index 388d303d6..002911711 100644 --- a/src/main/java/konkuk/thip/comment/application/port/out/CommentQueryPort.java +++ b/src/main/java/konkuk/thip/comment/application/port/out/CommentQueryPort.java @@ -10,11 +10,11 @@ public interface CommentQueryPort { - CursorBasedList findLatestRootCommentsWithDeleted(Long postId, String postTypeStr, Cursor cursor); + CursorBasedList findLatestRootCommentsWithDeleted(Long postId, String postTypeStr, Cursor cursor, Long viewerId); - List findAllActiveChildCommentsOldestFirst(Long rootCommentId); + List findAllActiveChildCommentsOldestFirst(Long rootCommentId, Long viewerId); - Map> findAllActiveChildCommentsOldestFirst(Set rootCommentIds); + Map> findAllActiveChildCommentsOldestFirst(Set rootCommentIds, Long viewerId); CommentQueryDto findRootCommentById(Long rootCommentId); diff --git a/src/main/java/konkuk/thip/comment/application/service/CommentCreateService.java b/src/main/java/konkuk/thip/comment/application/service/CommentCreateService.java index 5fa211bdc..fe8649229 100644 --- a/src/main/java/konkuk/thip/comment/application/service/CommentCreateService.java +++ b/src/main/java/konkuk/thip/comment/application/service/CommentCreateService.java @@ -10,6 +10,8 @@ import konkuk.thip.comment.application.port.out.dto.CommentQueryDto; import konkuk.thip.comment.application.service.validator.CommentAuthorizationValidator; import konkuk.thip.comment.domain.Comment; +import konkuk.thip.common.exception.BusinessException; +import konkuk.thip.common.exception.code.ErrorCode; import konkuk.thip.common.exception.InvalidStateException; import konkuk.thip.notification.application.port.in.FeedNotificationOrchestrator; import konkuk.thip.notification.application.port.in.RoomNotificationOrchestrator; @@ -17,6 +19,7 @@ import konkuk.thip.post.domain.CountUpdatable; import konkuk.thip.post.application.service.handler.PostHandler; import konkuk.thip.post.domain.PostType; +import konkuk.thip.user.application.port.out.UserBlockQueryPort; import konkuk.thip.user.application.port.out.UserCommandPort; import konkuk.thip.user.domain.User; import lombok.RequiredArgsConstructor; @@ -35,6 +38,7 @@ public class CommentCreateService implements CommentCreateUseCase { private final CommentLikeQueryPort commentLikeQueryPort; private final CommentQueryMapper commentQueryMapper; private final UserCommandPort userCommandPort; + private final UserBlockQueryPort userBlockQueryPort; private final PostHandler postHandler; private final CommentAuthorizationValidator commentAuthorizationValidator; @@ -55,8 +59,11 @@ public CommentCreateResponse createComment(CommentCreateCommand command) { // 2-1. 게시글 타입에 따른 댓글 생성 권한 검증 commentAuthorizationValidator.validateUserCanAccessPostForComment(type, post, command.userId()); - // 2-2. 댓글 생성 푸쉬 알림 전송 (게시글 작성자에게) + // 2-2. 차단 관계인 작성자의 게시글에는 댓글을 달 수 없다 PostQueryDto postQueryDto = postHandler.getPostQueryDto(type, post.getId()); + validateNotBlocked(command.userId(), postQueryDto.creatorId()); + + // 2-3. 댓글 생성 푸쉬 알림 전송 (게시글 작성자에게) User actorUser = userCommandPort.findById(command.userId()); sendNotificationsToPostWriter(postQueryDto, actorUser); @@ -90,6 +97,15 @@ public CommentCreateResponse createComment(CommentCreateCommand command) { } } + private void validateNotBlocked(Long userId, Long targetUserId) { + if (targetUserId == null || userId.equals(targetUserId)) { + return; + } + if (userBlockQueryPort.existsBlockBetween(userId, targetUserId)) { + throw new BusinessException(ErrorCode.USER_BLOCKED_CANNOT_INTERACT); + } + } + private void sendNotificationsToPostWriter(PostQueryDto postQueryDto, User actorUser) { if (postQueryDto.creatorId().equals(actorUser.getId())) return; // 자신이 작성한 게시글 제외 diff --git a/src/main/java/konkuk/thip/comment/application/service/CommentLikeService.java b/src/main/java/konkuk/thip/comment/application/service/CommentLikeService.java index 475132a6d..6720d2c8c 100644 --- a/src/main/java/konkuk/thip/comment/application/service/CommentLikeService.java +++ b/src/main/java/konkuk/thip/comment/application/service/CommentLikeService.java @@ -8,12 +8,15 @@ import konkuk.thip.comment.application.port.out.CommentLikeQueryPort; import konkuk.thip.comment.application.service.validator.CommentAuthorizationValidator; import konkuk.thip.comment.domain.Comment; +import konkuk.thip.common.exception.BusinessException; +import konkuk.thip.common.exception.code.ErrorCode; import konkuk.thip.notification.application.port.in.FeedNotificationOrchestrator; import konkuk.thip.notification.application.port.in.RoomNotificationOrchestrator; import konkuk.thip.post.application.port.out.dto.PostQueryDto; import konkuk.thip.post.application.service.handler.PostHandler; import konkuk.thip.post.domain.CountUpdatable; import konkuk.thip.post.domain.PostType; +import konkuk.thip.user.application.port.out.UserBlockQueryPort; import konkuk.thip.user.application.port.out.UserCommandPort; import konkuk.thip.user.domain.User; import lombok.RequiredArgsConstructor; @@ -28,6 +31,7 @@ public class CommentLikeService implements CommentLikeUseCase { private final CommentLikeQueryPort commentLikeQueryPort; private final CommentLikeCommandPort commentLikeCommandPort; private final UserCommandPort userCommandPort; + private final UserBlockQueryPort userBlockQueryPort; private final PostHandler postHandler; private final CommentAuthorizationValidator commentAuthorizationValidator; @@ -50,6 +54,7 @@ public CommentIsLikeResult changeLikeStatusComment(CommentIsLikeCommand command) // 3. 좋아요 상태변경 if (command.isLike()) { + validateNotBlocked(command.userId(), comment.getCreatorId()); // 차단 관계인 작성자의 댓글에는 좋아요할 수 없다 comment.validateCanLike(alreadyLiked); // 좋아요 가능 여부 검증 commentLikeCommandPort.save(command.userId(), command.commentId()); @@ -67,6 +72,15 @@ public CommentIsLikeResult changeLikeStatusComment(CommentIsLikeCommand command) return CommentIsLikeResult.of(comment.getId(), command.isLike()); } + private void validateNotBlocked(Long userId, Long commentCreatorId) { + if (userId.equals(commentCreatorId)) { + return; + } + if (userBlockQueryPort.existsBlockBetween(userId, commentCreatorId)) { + throw new BusinessException(ErrorCode.USER_BLOCKED_CANNOT_INTERACT); + } + } + private void sendNotifications(CommentIsLikeCommand command, Comment comment) { if (command.userId().equals(comment.getCreatorId())) return; // 자신의 댓글에 좋아요 누르는 경우 제외 diff --git a/src/main/java/konkuk/thip/comment/application/service/CommentShowAllService.java b/src/main/java/konkuk/thip/comment/application/service/CommentShowAllService.java index 69dbc40ef..bde620b3a 100644 --- a/src/main/java/konkuk/thip/comment/application/service/CommentShowAllService.java +++ b/src/main/java/konkuk/thip/comment/application/service/CommentShowAllService.java @@ -33,7 +33,7 @@ public CommentForSinglePostResponse showAllCommentsOfPost(CommentShowAllQuery qu Cursor cursor = Cursor.from(query.cursorStr(), PAGE_SIZE); // 1. size 크기만큼의 루트 댓글 최신순 조회 -> 삭제된 루트 댓글 포함해서 전부 조회 - CursorBasedList commentQueryDtoCursorBasedList = commentQueryPort.findLatestRootCommentsWithDeleted(query.postId(), query.postType().getType(), cursor); + CursorBasedList commentQueryDtoCursorBasedList = commentQueryPort.findLatestRootCommentsWithDeleted(query.postId(), query.postType().getType(), cursor, query.userId()); List rootsInOrder = commentQueryDtoCursorBasedList.contents(); // 2. 조회한 루트 댓글들의 전체 자식 댓귿들을(깊이 무관) 작성 시간순으로 조회 @@ -41,7 +41,7 @@ public CommentForSinglePostResponse showAllCommentsOfPost(CommentShowAllQuery qu .map(CommentQueryDto::commentId) .collect(Collectors.toUnmodifiableSet()); - Map> childrenMap = commentQueryPort.findAllActiveChildCommentsOldestFirst(rootCommentIds); + Map> childrenMap = commentQueryPort.findAllActiveChildCommentsOldestFirst(rootCommentIds, query.userId()); // 3. 반환할 모든 댓글(루트 + 자식 모두 포함) 중 유저가 좋아한 댓글 조회 Set allCommentIds = parseAllCommentIds(childrenMap); 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 5150524b8..ff0cbcd90 100644 --- a/src/main/java/konkuk/thip/common/exception/code/ErrorCode.java +++ b/src/main/java/konkuk/thip/common/exception/code/ErrorCode.java @@ -66,6 +66,16 @@ public enum ErrorCode implements ResponseCode { USER_CANNOT_FOLLOW_SELF(HttpStatus.BAD_REQUEST, 75002, "사용자는 자신을 팔로우할 수 없습니다."), FOLLOW_COUNT_CANNOT_BE_NEGATIVE(HttpStatus.BAD_REQUEST, 75003, "사용자의 팔로우 수가 0일때는 언팔로우는 불가능합니다."), + /** + * 77000 : block error + */ + BLOCK_NOT_FOUND(HttpStatus.NOT_FOUND, 77000, "존재하지 않는 차단 관계입니다."), + USER_ALREADY_BLOCKED(HttpStatus.BAD_REQUEST, 77001, "이미 차단한 사용자입니다."), + USER_ALREADY_UNBLOCKED(HttpStatus.BAD_REQUEST, 77002, "이미 차단 해제한 사용자입니다."), + USER_CANNOT_BLOCK_SELF(HttpStatus.BAD_REQUEST, 77003, "사용자는 자신을 차단할 수 없습니다."), + USER_BLOCKED_CANNOT_INTERACT(HttpStatus.BAD_REQUEST, 77004, "차단한 사용자와는 상호작용할 수 없습니다."), + ROOM_HOST_BLOCKED(HttpStatus.BAD_REQUEST, 77005, "차단한 사용자가 방장인 모임방에는 참여할 수 없습니다."), + /** * 80000 : book error */ diff --git a/src/main/java/konkuk/thip/common/swagger/SwaggerResponseDescription.java b/src/main/java/konkuk/thip/common/swagger/SwaggerResponseDescription.java index e976f2a72..0641d7ef3 100644 --- a/src/main/java/konkuk/thip/common/swagger/SwaggerResponseDescription.java +++ b/src/main/java/konkuk/thip/common/swagger/SwaggerResponseDescription.java @@ -48,12 +48,26 @@ public enum SwaggerResponseDescription { USER_ALREADY_FOLLOWED, USER_ALREADY_UNFOLLOWED, USER_CANNOT_FOLLOW_SELF, + USER_BLOCKED_CANNOT_INTERACT, FOLLOW_COUNT_CANNOT_BE_NEGATIVE ))), GET_USER_FOLLOW(new LinkedHashSet<>(Set.of( USER_NOT_FOUND ))), + // Block + CHANGE_BLOCK_STATE(new LinkedHashSet<>(Set.of( + USER_NOT_FOUND, + USER_ALREADY_BLOCKED, + USER_ALREADY_UNBLOCKED, + USER_CANNOT_BLOCK_SELF, + BLOCK_NOT_FOUND, + FOLLOW_COUNT_CANNOT_BE_NEGATIVE + ))), + GET_BLOCKED_USERS(new LinkedHashSet<>(Set.of( + USER_NOT_FOUND + ))), + // Room ROOM_CREATE(new LinkedHashSet<>(Set.of( USER_NOT_FOUND, @@ -62,6 +76,7 @@ public enum SwaggerResponseDescription { ))), ROOM_JOIN_CANCEL(new LinkedHashSet<>(Set.of( + ROOM_HOST_BLOCKED, USER_NOT_FOUND, ROOM_NOT_FOUND, ROOM_RECRUITMENT_PERIOD_EXPIRED, @@ -254,6 +269,7 @@ public enum SwaggerResponseDescription { FEED_ACCESS_FORBIDDEN ))), CHANGE_FEED_SAVED_STATE(new LinkedHashSet<>(Set.of( + USER_BLOCKED_CANNOT_INTERACT, USER_NOT_FOUND, FEED_NOT_FOUND, FEED_ALREADY_SAVED, @@ -283,6 +299,7 @@ public enum SwaggerResponseDescription { // Comment COMMENT_CREATE(new LinkedHashSet<>(Set.of( + USER_BLOCKED_CANNOT_INTERACT, POST_TYPE_NOT_MATCH, USER_NOT_FOUND, FEED_NOT_FOUND, @@ -295,6 +312,7 @@ public enum SwaggerResponseDescription { ROOM_NOT_IN_PROGRESS ))), CHANGE_COMMENT_LIKE_STATE(new LinkedHashSet<>(Set.of( + USER_BLOCKED_CANNOT_INTERACT, USER_NOT_FOUND, COMMENT_NOT_FOUND, FEED_NOT_FOUND, diff --git a/src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedQueryRepositoryImpl.java b/src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedQueryRepositoryImpl.java index fbd079a4a..a502000df 100644 --- a/src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedQueryRepositoryImpl.java +++ b/src/main/java/konkuk/thip/feed/adapter/out/persistence/repository/FeedQueryRepositoryImpl.java @@ -13,13 +13,13 @@ import konkuk.thip.feed.adapter.out.jpa.QSavedFeedJpaEntity; import konkuk.thip.feed.application.port.out.dto.FeedQueryDto; import konkuk.thip.feed.application.port.out.dto.QFeedQueryDto; -import konkuk.thip.post.application.port.out.dto.PostQueryDto; -import konkuk.thip.post.application.port.out.dto.QPostQueryDto; import konkuk.thip.user.adapter.out.jpa.QFollowingJpaEntity; import konkuk.thip.user.adapter.out.jpa.QUserJpaEntity; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; +import static konkuk.thip.user.adapter.out.persistence.expression.BlockFilterExpressions.notBlockedWith; + import java.time.LocalDateTime; import java.util.HashSet; import java.util.List; @@ -136,7 +136,8 @@ private List fetchFeedIdsAndPriorityByFollowingPriority(Long userId, Inte .where( // ACTIVE 인 feed & (내가 작성한 글 or 다른 유저가 작성한 공개글) & cursorCondition feed.userJpaEntity.userId.eq(userId).or(feed.isPublic.eq(true)), - cursorCondition + cursorCondition, + notBlockedWith(feed.userJpaEntity.userId, userId) ) .orderBy(priority.desc(), feed.createdAt.desc()) .limit(size + 1) @@ -153,7 +154,8 @@ private List fetchFeedIdsLatest(Long userId, LocalDateTime lastCreatedAt, .where( // ACTIVE 인 feed & (내가 작성한 글 or 다른 유저가 작성한 공개글) & cursorCondition feed.userJpaEntity.userId.eq(userId).or(feed.isPublic.eq(true)), - lastCreatedAt != null ? feed.createdAt.lt(lastCreatedAt) : Expressions.TRUE + lastCreatedAt != null ? feed.createdAt.lt(lastCreatedAt) : Expressions.TRUE, + notBlockedWith(feed.userJpaEntity.userId, userId) ) .orderBy(feed.createdAt.desc()) .limit(size + 1) @@ -343,11 +345,14 @@ private QFeedQueryDto toQueryDto() { ); } - // 필터링 조건: 책 ISBN & 공개 피드 + // 필터링 조건: 책 ISBN & 공개 피드 & 차단 관계가 아닌 작성자 private BooleanExpression feedByBooksFilter(String isbn, Long userId) { - return feed.bookJpaEntity.isbn.eq(isbn) + BooleanExpression filter = feed.bookJpaEntity.isbn.eq(isbn) // .and(feed.userJpaEntity.userId.ne(userId)) .and(feed.isPublic.eq(true)); + + BooleanExpression notBlocked = notBlockedWith(feed.userJpaEntity.userId, userId); + return notBlocked != null ? filter.and(notBlocked) : filter; } @Override @@ -374,6 +379,12 @@ public List findSavedFeedsByCreatedAt(Long userId, LocalDateTime l .or(savedFeed.feedJpaEntity.isPublic.eq(true)) ); + // 저장 관계(saved_feeds row)는 지우지 않고 목록에서만 숨긴다. 차단을 해제하면 다시 보인다. + BooleanExpression notBlocked = notBlockedWith(savedFeed.feedJpaEntity.userJpaEntity.userId, userId); + if (notBlocked != null) { + where = where.and(notBlocked); + } + if (lastSavedAt != null) { where = where.and(savedFeed.createdAt.lt(lastSavedAt)); } diff --git a/src/main/java/konkuk/thip/feed/application/service/FeedSavedService.java b/src/main/java/konkuk/thip/feed/application/service/FeedSavedService.java index 0169faa54..d53684902 100644 --- a/src/main/java/konkuk/thip/feed/application/service/FeedSavedService.java +++ b/src/main/java/konkuk/thip/feed/application/service/FeedSavedService.java @@ -7,6 +7,7 @@ import konkuk.thip.feed.application.port.out.FeedCommandPort; import konkuk.thip.feed.application.port.out.FeedQueryPort; import konkuk.thip.feed.domain.Feed; +import konkuk.thip.user.application.port.out.UserBlockQueryPort; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -19,6 +20,7 @@ public class FeedSavedService implements FeedSavedUseCase { private final FeedCommandPort feedCommandPort; private final FeedQueryPort feedQueryPort; + private final UserBlockQueryPort userBlockQueryPort; @Override @Transactional @@ -32,6 +34,11 @@ public FeedIsSavedResult changeSavedFeed(FeedIsSavedCommand command) { validateSaveFeedAction(command.isSaved(), alreadySaved); if (command.isSaved()) { + // 차단 관계인 작성자의 피드는 새로 저장할 수 없다. 저장 해제는 막지 않는다. + if (!command.userId().equals(feed.getCreatorId()) + && userBlockQueryPort.existsBlockBetween(command.userId(), feed.getCreatorId())) { + throw new BusinessException(USER_BLOCKED_CANNOT_INTERACT); + } feedCommandPort.saveSavedFeed(command.userId(), feed.getId()); } else { feedCommandPort.deleteSavedFeed(command.userId(), feed.getId()); diff --git a/src/main/java/konkuk/thip/feed/application/service/FeedShowAllOfUserService.java b/src/main/java/konkuk/thip/feed/application/service/FeedShowAllOfUserService.java index baa7314ec..64443914e 100644 --- a/src/main/java/konkuk/thip/feed/application/service/FeedShowAllOfUserService.java +++ b/src/main/java/konkuk/thip/feed/application/service/FeedShowAllOfUserService.java @@ -8,7 +8,9 @@ import konkuk.thip.feed.application.port.in.FeedShowAllOfUserUseCase; import konkuk.thip.feed.application.port.out.FeedQueryPort; import konkuk.thip.feed.application.port.out.dto.FeedQueryDto; +import konkuk.thip.common.exception.EntityNotFoundException; import konkuk.thip.post.application.port.out.PostLikeQueryPort; +import konkuk.thip.user.application.port.out.UserBlockQueryPort; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -17,6 +19,8 @@ import java.util.Set; import java.util.stream.Collectors; +import static konkuk.thip.common.exception.code.ErrorCode.USER_NOT_FOUND; + @Service @RequiredArgsConstructor public class FeedShowAllOfUserService implements FeedShowAllOfUserUseCase { @@ -24,6 +28,7 @@ public class FeedShowAllOfUserService implements FeedShowAllOfUserUseCase { private static final int PAGE_SIZE = 10; private final FeedQueryPort feedQueryPort; private final PostLikeQueryPort postLikeQueryPort; + private final UserBlockQueryPort userBlockQueryPort; private final FeedQueryMapper feedQueryMapper; @Transactional(readOnly = true) @@ -57,6 +62,11 @@ public FeedShowMineResponse showMyFeeds(Long userId, String cursor) { @Transactional(readOnly = true) @Override public FeedShowByUserResponse showPublicFeedsOfFeedOwner(Long userId, Long feedOwnerId, String cursor) { + // 0. 차단 관계면 프로필 자체에 진입할 수 없다 (존재 여부를 노출하지 않도록 404) + if (userBlockQueryPort.existsBlockBetween(userId, feedOwnerId)) { + throw new EntityNotFoundException(USER_NOT_FOUND); + } + // 1. 커서 생성 Cursor nextCursor = Cursor.from(cursor, PAGE_SIZE); diff --git a/src/main/java/konkuk/thip/feed/application/service/FeedShowSingleService.java b/src/main/java/konkuk/thip/feed/application/service/FeedShowSingleService.java index 7b20b0c3f..e313aae79 100644 --- a/src/main/java/konkuk/thip/feed/application/service/FeedShowSingleService.java +++ b/src/main/java/konkuk/thip/feed/application/service/FeedShowSingleService.java @@ -8,7 +8,9 @@ import konkuk.thip.feed.application.port.out.FeedCommandPort; import konkuk.thip.feed.application.port.out.FeedQueryPort; import konkuk.thip.feed.domain.Feed; +import konkuk.thip.common.exception.EntityNotFoundException; import konkuk.thip.post.application.port.out.PostLikeQueryPort; +import konkuk.thip.user.application.port.out.UserBlockQueryPort; import konkuk.thip.user.application.port.out.UserCommandPort; import konkuk.thip.user.domain.User; import lombok.RequiredArgsConstructor; @@ -17,6 +19,8 @@ import java.util.Set; +import static konkuk.thip.common.exception.code.ErrorCode.FEED_NOT_FOUND; + @Service @RequiredArgsConstructor public class FeedShowSingleService implements FeedShowSingleUseCase { @@ -26,6 +30,7 @@ public class FeedShowSingleService implements FeedShowSingleUseCase { private final BookCommandPort bookCommandPort; private final PostLikeQueryPort postLikeQueryPort; private final FeedQueryPort feedQueryPort; + private final UserBlockQueryPort userBlockQueryPort; private final FeedQueryMapper feedQueryMapper; @Override @@ -35,6 +40,11 @@ public FeedShowSingleResponse showSingleFeed(Long feedId, Long userId) { Feed feed = feedCommandPort.getByIdOrThrow(feedId); feed.validateViewPermission(userId); + // 딥링크로 진입할 수 있으므로 여기서 막는다. 존재 여부를 감추기 위해 404 로 응답한다. + if (userBlockQueryPort.existsBlockBetween(userId, feed.getCreatorId())) { + throw new EntityNotFoundException(FEED_NOT_FOUND); + } + // 2. 피드 작성자 도메인 조회 User feedCreator = userCommandPort.findById(feed.getCreatorId()); diff --git a/src/main/java/konkuk/thip/feed/application/service/FeedShowUserInfoService.java b/src/main/java/konkuk/thip/feed/application/service/FeedShowUserInfoService.java index 6edc7eff1..69838b7ec 100644 --- a/src/main/java/konkuk/thip/feed/application/service/FeedShowUserInfoService.java +++ b/src/main/java/konkuk/thip/feed/application/service/FeedShowUserInfoService.java @@ -4,7 +4,9 @@ import konkuk.thip.feed.application.mapper.FeedQueryMapper; import konkuk.thip.feed.application.port.in.FeedShowUserInfoUseCase; import konkuk.thip.feed.application.port.out.FeedQueryPort; +import konkuk.thip.common.exception.EntityNotFoundException; import konkuk.thip.user.application.port.out.FollowingQueryPort; +import konkuk.thip.user.application.port.out.UserBlockQueryPort; import konkuk.thip.user.application.port.out.UserCommandPort; import konkuk.thip.user.domain.User; import lombok.RequiredArgsConstructor; @@ -13,6 +15,8 @@ import java.util.List; +import static konkuk.thip.common.exception.code.ErrorCode.USER_NOT_FOUND; + @RequiredArgsConstructor @Service public class FeedShowUserInfoService implements FeedShowUserInfoUseCase { @@ -21,6 +25,7 @@ public class FeedShowUserInfoService implements FeedShowUserInfoUseCase { private final UserCommandPort userCommandPort; private final FollowingQueryPort followingQueryPort; private final FeedQueryPort feedQueryPort; + private final UserBlockQueryPort userBlockQueryPort; private final FeedQueryMapper feedQueryMapper; @Transactional(readOnly = true) @@ -41,6 +46,11 @@ public FeedShowUserInfoResponse showMyInfoInFeeds(Long userId) { @Transactional(readOnly = true) @Override public FeedShowUserInfoResponse showAnotherUserInfoInFeeds(Long userId, Long feedOwnerId) { + // 0. 차단 관계면 프로필 정보도 노출하지 않는다 + if (userBlockQueryPort.existsBlockBetween(userId, feedOwnerId)) { + throw new EntityNotFoundException(USER_NOT_FOUND); + } + // 1. feedOwner 찾기 User feedOwner = userCommandPort.findById(feedOwnerId); diff --git a/src/main/java/konkuk/thip/notification/application/service/FeedNotificationOrchestratorSyncImpl.java b/src/main/java/konkuk/thip/notification/application/service/FeedNotificationOrchestratorSyncImpl.java index 561340273..c947a9c3c 100644 --- a/src/main/java/konkuk/thip/notification/application/service/FeedNotificationOrchestratorSyncImpl.java +++ b/src/main/java/konkuk/thip/notification/application/service/FeedNotificationOrchestratorSyncImpl.java @@ -6,6 +6,7 @@ import konkuk.thip.notification.application.service.template.feed.*; import konkuk.thip.notification.domain.value.MessageRoute; import konkuk.thip.notification.domain.value.NotificationRedirectSpec; +import konkuk.thip.user.application.port.out.UserBlockQueryPort; import lombok.RequiredArgsConstructor; import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; @@ -25,11 +26,21 @@ public class FeedNotificationOrchestratorSyncImpl implements FeedNotificationOrc private final NotificationSyncExecutor notificationSyncExecutor; private final FeedEventCommandPort feedEventCommandPort; + private final UserBlockQueryPort userBlockQueryPort; + + // 차단 관계면 알림을 만들지 않는다. DB 저장과 FCM 발송이 같은 경로라 early return 으로 둘 다 막힌다. + private boolean suppressed(Long targetUserId, Long actorUserId) { + return actorUserId != null && userBlockQueryPort.existsBlockBetween(targetUserId, actorUserId); + } // ========================= Feed 영역 ========================= @Override @Transactional(propagation = Propagation.MANDATORY) public void notifyFollowed(Long targetUserId, Long actorUserId, String actorUsername) { + if (suppressed(targetUserId, actorUserId)) { + return; + } + var args = new FollowedTemplate.Args(actorUsername); NotificationRedirectSpec redirectSpec = new NotificationRedirectSpec( @@ -51,6 +62,10 @@ public void notifyFollowed(Long targetUserId, Long actorUserId, String actorUser @Override @Transactional(propagation = Propagation.MANDATORY) public void notifyFeedCommented(Long targetUserId, Long actorUserId, String actorUsername, Long feedId) { + if (suppressed(targetUserId, actorUserId)) { + return; + } + var args = new FeedCommentedTemplate.Args(actorUsername); NotificationRedirectSpec redirectSpec = new NotificationRedirectSpec( @@ -72,6 +87,10 @@ public void notifyFeedCommented(Long targetUserId, Long actorUserId, String acto @Override @Transactional(propagation = Propagation.MANDATORY) public void notifyFeedReplied(Long targetUserId, Long actorUserId, String actorUsername, Long feedId) { + if (suppressed(targetUserId, actorUserId)) { + return; + } + var args = new FeedRepliedTemplate.Args(actorUsername); NotificationRedirectSpec redirectSpec = new NotificationRedirectSpec( @@ -93,6 +112,10 @@ public void notifyFeedReplied(Long targetUserId, Long actorUserId, String actorU @Override @Transactional(propagation = Propagation.MANDATORY) public void notifyFolloweeNewFeed(Long targetUserId, Long actorUserId, String actorUsername, Long feedId) { + if (suppressed(targetUserId, actorUserId)) { + return; + } + var args = new FolloweeNewFeedTemplate.Args(actorUsername); NotificationRedirectSpec redirectSpec = new NotificationRedirectSpec( @@ -114,6 +137,10 @@ public void notifyFolloweeNewFeed(Long targetUserId, Long actorUserId, String ac @Override @Transactional(propagation = Propagation.MANDATORY) public void notifyFeedLiked(Long targetUserId, Long actorUserId, String actorUsername, Long feedId) { + if (suppressed(targetUserId, actorUserId)) { + return; + } + var args = new FeedLikedTemplate.Args(actorUsername); NotificationRedirectSpec redirectSpec = new NotificationRedirectSpec( @@ -135,6 +162,10 @@ public void notifyFeedLiked(Long targetUserId, Long actorUserId, String actorUse @Override @Transactional(propagation = Propagation.MANDATORY) public void notifyFeedCommentLiked(Long targetUserId, Long actorUserId, String actorUsername, Long feedId) { + if (suppressed(targetUserId, actorUserId)) { + return; + } + var args = new FeedCommentLikedTemplate.Args(actorUsername); NotificationRedirectSpec redirectSpec = new NotificationRedirectSpec( diff --git a/src/main/java/konkuk/thip/notification/application/service/RoomNotificationOrchestratorSyncImpl.java b/src/main/java/konkuk/thip/notification/application/service/RoomNotificationOrchestratorSyncImpl.java index 974e8231a..b22e3fb29 100644 --- a/src/main/java/konkuk/thip/notification/application/service/RoomNotificationOrchestratorSyncImpl.java +++ b/src/main/java/konkuk/thip/notification/application/service/RoomNotificationOrchestratorSyncImpl.java @@ -7,6 +7,7 @@ import konkuk.thip.notification.domain.value.MessageRoute; import konkuk.thip.notification.domain.value.NotificationRedirectSpec; import konkuk.thip.post.domain.PostType; +import konkuk.thip.user.application.port.out.UserBlockQueryPort; import lombok.RequiredArgsConstructor; import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; @@ -26,12 +27,22 @@ public class RoomNotificationOrchestratorSyncImpl implements RoomNotificationOrc private final NotificationSyncExecutor notificationSyncExecutor; private final RoomEventCommandPort roomEventCommandPort; + private final UserBlockQueryPort userBlockQueryPort; + + // 차단 관계면 알림을 만들지 않는다. DB 저장과 FCM 발송이 같은 경로라 early return 으로 둘 다 막힌다. + private boolean suppressed(Long targetUserId, Long actorUserId) { + return actorUserId != null && userBlockQueryPort.existsBlockBetween(targetUserId, actorUserId); + } // ========================= Room 영역 ========================= @Override @Transactional(propagation = Propagation.MANDATORY) public void notifyRoomPostCommented(Long targetUserId, Long actorUserId, String actorUsername, Long roomId, Integer page, Long postId, PostType postType) { + if (suppressed(targetUserId, actorUserId)) { + return; + } + var args = new RoomPostCommentedTemplate.Args(actorUsername); NotificationRedirectSpec redirectSpec = createRoomPostWithCommentsRedirectSpec(roomId, page, postId, postType); @@ -69,6 +80,10 @@ public void notifyRoomVoteStarted(Long targetUserId, Long roomId, String roomTit @Transactional(propagation = Propagation.MANDATORY) public void notifyRoomRecordCreated(Long targetUserId, Long actorUserId, String actorUsername, Long roomId, String roomTitle, Integer page, Long postId) { + if (suppressed(targetUserId, actorUserId)) { + return; + } + var args = new RoomRecordCreatedTemplate.Args(roomTitle, actorUsername); NotificationRedirectSpec redirectSpec = createRoomPostRedirectSpec(roomId, page, postId, PostType.RECORD); @@ -129,6 +144,10 @@ public void notifyRoomActivityStarted(Long targetUserId, Long roomId, String roo @Override @Transactional(propagation = Propagation.MANDATORY) public void notifyRoomJoinToHost(Long hostUserId, Long roomId, String roomTitle, Long actorUserId, String actorUsername) { + if (suppressed(hostUserId, actorUserId)) { + return; + } + var args = new RoomJoinToHostTemplate.Args(roomTitle, actorUsername); NotificationRedirectSpec redirectSpec = new NotificationRedirectSpec( @@ -151,6 +170,10 @@ public void notifyRoomJoinToHost(Long hostUserId, Long roomId, String roomTitle, @Transactional(propagation = Propagation.MANDATORY) public void notifyRoomCommentLiked(Long targetUserId, Long actorUserId, String actorUsername, Long roomId, Integer page, Long postId, PostType postType) { + if (suppressed(targetUserId, actorUserId)) { + return; + } + var args = new RoomCommentLikedTemplate.Args(actorUsername); NotificationRedirectSpec redirectSpec = createRoomPostWithCommentsRedirectSpec(roomId, page, postId, postType); @@ -170,6 +193,10 @@ public void notifyRoomCommentLiked(Long targetUserId, Long actorUserId, String a @Transactional(propagation = Propagation.MANDATORY) public void notifyRoomPostLiked(Long targetUserId, Long actorUserId, String actorUsername, Long roomId, Integer page, Long postId, PostType postType) { + if (suppressed(targetUserId, actorUserId)) { + return; + } + var args = new RoomPostLikedTemplate.Args(actorUsername); NotificationRedirectSpec redirectSpec = createRoomPostRedirectSpec(roomId, page, postId, postType); @@ -189,6 +216,10 @@ public void notifyRoomPostLiked(Long targetUserId, Long actorUserId, String acto @Transactional(propagation = Propagation.MANDATORY) public void notifyRoomPostCommentReplied(Long targetUserId, Long actorUserId, String actorUsername, Long roomId, Integer page, Long postId, PostType postType) { + if (suppressed(targetUserId, actorUserId)) { + return; + } + var args = new RoomPostCommentRepliedTemplate.Args(actorUsername); NotificationRedirectSpec redirectSpec = createRoomPostWithCommentsRedirectSpec(roomId, page, postId, postType); diff --git a/src/main/java/konkuk/thip/post/application/service/PostLikeService.java b/src/main/java/konkuk/thip/post/application/service/PostLikeService.java index 1d25d7157..b6ec165bc 100644 --- a/src/main/java/konkuk/thip/post/application/service/PostLikeService.java +++ b/src/main/java/konkuk/thip/post/application/service/PostLikeService.java @@ -16,6 +16,7 @@ import konkuk.thip.post.application.port.out.PostLikeQueryPort; import konkuk.thip.post.application.service.validator.PostLikeAuthorizationValidator; import konkuk.thip.post.domain.service.PostCountService; +import konkuk.thip.user.application.port.out.UserBlockQueryPort; import konkuk.thip.user.application.port.out.UserCommandPort; import konkuk.thip.user.domain.User; import lombok.RequiredArgsConstructor; @@ -33,6 +34,7 @@ public class PostLikeService implements PostLikeUseCase { private final PostLikeQueryPort postLikeQueryPort; private final PostLikeCommandPort postLikeCommandPort; private final UserCommandPort userCommandPort; + private final UserBlockQueryPort userBlockQueryPort; private final PostHandler postHandler; private final PostCountService postCountService; @@ -65,6 +67,7 @@ public PostIsLikeResult changeLikeStatusPost(PostIsLikeCommand command) { // 4. 좋아요 상태변경 if (command.isLike()) { + validateNotBlocked(command); // 차단 관계인 작성자의 게시글에는 좋아요할 수 없다 postLikeAuthorizationValidator.validateUserCanLike(alreadyLiked); // 좋아요 가능 여부 검증 postLikeCommandPort.save(command.userId(), command.postId(),command.postType()); @@ -93,6 +96,16 @@ public PostIsLikeResult recoverBusinessException(BusinessException e, PostIsLike throw e; } + private void validateNotBlocked(PostIsLikeCommand command) { + PostQueryDto postQueryDto = postHandler.getPostQueryDto(command.postType(), command.postId()); + if (command.userId().equals(postQueryDto.creatorId())) { + return; + } + if (userBlockQueryPort.existsBlockBetween(command.userId(), postQueryDto.creatorId())) { + throw new BusinessException(ErrorCode.USER_BLOCKED_CANNOT_INTERACT); + } + } + private void sendNotifications(PostIsLikeCommand command) { PostQueryDto postQueryDto = postHandler.getPostQueryDto(command.postType(), command.postId()); diff --git a/src/main/java/konkuk/thip/room/adapter/in/web/RoomQueryController.java b/src/main/java/konkuk/thip/room/adapter/in/web/RoomQueryController.java index 44dc0f9eb..dab407364 100644 --- a/src/main/java/konkuk/thip/room/adapter/in/web/RoomQueryController.java +++ b/src/main/java/konkuk/thip/room/adapter/in/web/RoomQueryController.java @@ -157,8 +157,9 @@ public BaseResponse getBookPage( @GetMapping("/rooms") public BaseResponse getDeadlineAndPopularAndRecentRoomList( @Parameter(description = "카테고리 이름 (default : 문학)", example = "과학/IT") - @RequestParam(value = "category", defaultValue = "문학") final String category + @RequestParam(value = "category", defaultValue = "문학") final String category, + @Parameter(hidden = true) @UserId final Long userId ) { - return BaseResponse.ok(roomGetDeadlinePopularRecentUseCase.getDeadlineAndPopularAndRecentRoomList(category)); + return BaseResponse.ok(roomGetDeadlinePopularRecentUseCase.getDeadlineAndPopularAndRecentRoomList(category, userId)); } } diff --git a/src/main/java/konkuk/thip/room/adapter/out/persistence/RoomQueryPersistenceAdapter.java b/src/main/java/konkuk/thip/room/adapter/out/persistence/RoomQueryPersistenceAdapter.java index b706a517b..92d9d16ae 100644 --- a/src/main/java/konkuk/thip/room/adapter/out/persistence/RoomQueryPersistenceAdapter.java +++ b/src/main/java/konkuk/thip/room/adapter/out/persistence/RoomQueryPersistenceAdapter.java @@ -31,42 +31,42 @@ public int countRecruitingRoomsByBookIsbn(String isbn) { } @Override - public CursorBasedList searchRecruitingRoomsByDeadline(String keyword, Cursor cursor) { + public CursorBasedList searchRecruitingRoomsByDeadline(String keyword, Cursor cursor, Long viewerId) { return findRoomsByDeadlineCursor(cursor, ((lastLocalDate, lastId, pageSize) -> - roomJpaRepository.findRecruitingRoomsOrderByStartDateAsc(keyword, lastLocalDate, lastId, pageSize))); + roomJpaRepository.findRecruitingRoomsOrderByStartDateAsc(keyword, lastLocalDate, lastId, pageSize, viewerId))); } @Override - public CursorBasedList searchRecruitingRoomsWithCategoryByDeadline(String keyword, Category category, Cursor cursor) { + public CursorBasedList searchRecruitingRoomsWithCategoryByDeadline(String keyword, Category category, Cursor cursor, Long viewerId) { return findRoomsByDeadlineCursor(cursor, (lastLocalDate, lastId, pageSize) -> roomJpaRepository.findRecruitingRoomsWithCategoryOrderByStartDateAsc( - keyword, category, lastLocalDate, lastId, pageSize + keyword, category, lastLocalDate, lastId, pageSize, viewerId ) ); } @Override - public CursorBasedList searchRecruitingRoomsByMemberCount(String keyword, Cursor cursor) { + public CursorBasedList searchRecruitingRoomsByMemberCount(String keyword, Cursor cursor, Long viewerId) { return findRoomsByMemberCountCursor(cursor, (lastMemberCount, lastId, pageSize) -> roomJpaRepository.findRecruitingRoomsOrderByMemberCountDesc( - keyword, lastMemberCount, lastId, pageSize + keyword, lastMemberCount, lastId, pageSize, viewerId ) ); } @Override - public CursorBasedList searchRecruitingRoomsWithCategoryByMemberCount(String keyword, Category category, Cursor cursor) { + public CursorBasedList searchRecruitingRoomsWithCategoryByMemberCount(String keyword, Category category, Cursor cursor, Long viewerId) { return findRoomsByMemberCountCursor(cursor, (lastMemberCount, lastId, pageSize) -> roomJpaRepository.findRecruitingRoomsWithCategoryOrderByMemberCountDesc( - keyword, category, lastMemberCount, lastId, pageSize + keyword, category, lastMemberCount, lastId, pageSize, viewerId ) ); } @Override - public List findOtherRecruitingRoomsByCategoryOrderByStartDateAsc(Room currentRoom, int count) { + public List findOtherRecruitingRoomsByCategoryOrderByStartDateAsc(Room currentRoom, int count, Long viewerId) { return roomJpaRepository.findOtherRecruitingRoomsByCategoryOrderByStartDateAsc( - currentRoom.getId(), currentRoom.getCategory(), count); + currentRoom.getId(), currentRoom.getCategory(), count, viewerId); } @Override @@ -132,9 +132,9 @@ public CursorBasedList findExpiredRoomsUserParticipated(Long userI } @Override - public CursorBasedList findRoomsByIsbnOrderByDeadline(String isbn, Cursor cursor) { + public CursorBasedList findRoomsByIsbnOrderByDeadline(String isbn, Cursor cursor, Long viewerId) { return findRoomsByDeadlineCursor(cursor, (lastLocalDate, lastId, pageSize) -> - roomJpaRepository.findRoomsByIsbnOrderByStartDateAsc(isbn, lastLocalDate, lastId, pageSize)); + roomJpaRepository.findRoomsByIsbnOrderByStartDateAsc(isbn, lastLocalDate, lastId, pageSize, viewerId)); } private CursorBasedList findRoomsByDeadlineCursor(Cursor cursor, LocalDateCursorRoomQueryFunction queryFunction) { @@ -170,18 +170,18 @@ private CursorBasedList findRoomsByMemberCountCursor(Cursor cursor } @Override - public List findRoomsByCategoryOrderByDeadline(Category category, int limit) { - return roomJpaRepository.findRoomsByCategoryOrderByStartDateAsc(category, limit); + public List findRoomsByCategoryOrderByDeadline(Category category, int limit, Long viewerId) { + return roomJpaRepository.findRoomsByCategoryOrderByStartDateAsc(category, limit, viewerId); } @Override - public List findRoomsByCategoryOrderByPopular(Category category, int limit) { - return roomJpaRepository.findRoomsByCategoryOrderByMemberCount(category, limit); + public List findRoomsByCategoryOrderByPopular(Category category, int limit, Long viewerId) { + return roomJpaRepository.findRoomsByCategoryOrderByMemberCount(category, limit, viewerId); } @Override - public List findRoomsByCategoryOrderByRecent(Category category, LocalDateTime createdAfter, int limit) { - return roomJpaRepository.findRoomsByCategoryOrderByCreatedAtDesc(category, createdAfter, limit); + public List findRoomsByCategoryOrderByRecent(Category category, LocalDateTime createdAfter, int limit, Long viewerId) { + return roomJpaRepository.findRoomsByCategoryOrderByCreatedAtDesc(category, createdAfter, limit, viewerId); } @Override diff --git a/src/main/java/konkuk/thip/room/adapter/out/persistence/repository/RoomQueryRepository.java b/src/main/java/konkuk/thip/room/adapter/out/persistence/repository/RoomQueryRepository.java index cd93198e8..96db54b1a 100644 --- a/src/main/java/konkuk/thip/room/adapter/out/persistence/repository/RoomQueryRepository.java +++ b/src/main/java/konkuk/thip/room/adapter/out/persistence/repository/RoomQueryRepository.java @@ -15,12 +15,12 @@ public interface RoomQueryRepository { /** * 방 검색 */ - List findRecruitingRoomsOrderByStartDateAsc(String keyword, LocalDate lastStartDate, Long roomId, int pageSize); - List findRecruitingRoomsWithCategoryOrderByStartDateAsc(String keyword, Category category, LocalDate lastStartDate, Long roomId, int pageSize); - List findRecruitingRoomsOrderByMemberCountDesc(String keyword, Integer lastMemberCount, Long roomId, int pageSize); - List findRecruitingRoomsWithCategoryOrderByMemberCountDesc(String keyword, Category category, Integer lastMemberCount, Long roomId, int pageSize); + List findRecruitingRoomsOrderByStartDateAsc(String keyword, LocalDate lastStartDate, Long roomId, int pageSize, Long viewerId); + List findRecruitingRoomsWithCategoryOrderByStartDateAsc(String keyword, Category category, LocalDate lastStartDate, Long roomId, int pageSize, Long viewerId); + List findRecruitingRoomsOrderByMemberCountDesc(String keyword, Integer lastMemberCount, Long roomId, int pageSize, Long viewerId); + List findRecruitingRoomsWithCategoryOrderByMemberCountDesc(String keyword, Category category, Integer lastMemberCount, Long roomId, int pageSize, Long viewerId); - List findOtherRecruitingRoomsByCategoryOrderByStartDateAsc(Long roomId, Category category, int count); + List findOtherRecruitingRoomsByCategoryOrderByStartDateAsc(Long roomId, Category category, int count, Long viewerId); List findHomeJoinedRoomsByUserPercentage(Long userId, Double userPercentageCursor, LocalDate startDateCursor, Long roomIdCursor, int pageSize); @@ -32,11 +32,11 @@ public interface RoomQueryRepository { List findExpiredRoomsUserParticipated(Long userId, LocalDate dateCursor, Long roomIdCursor, int pageSize); - List findRoomsByCategoryOrderByStartDateAsc(Category category, int limit); + List findRoomsByCategoryOrderByStartDateAsc(Category category, int limit, Long viewerId); - List findRoomsByCategoryOrderByMemberCount(Category category, int limit); + List findRoomsByCategoryOrderByMemberCount(Category category, int limit, Long viewerId); - List findRoomsByCategoryOrderByCreatedAtDesc(Category category, LocalDateTime createdAfter, int limit); + List findRoomsByCategoryOrderByCreatedAtDesc(Category category, LocalDateTime createdAfter, int limit, Long viewerId); - List findRoomsByIsbnOrderByStartDateAsc(String isbn, LocalDate dateCursor, Long roomIdCursor, int pageSize); + List findRoomsByIsbnOrderByStartDateAsc(String isbn, LocalDate dateCursor, Long roomIdCursor, int pageSize, Long viewerId); } diff --git a/src/main/java/konkuk/thip/room/adapter/out/persistence/repository/RoomQueryRepositoryImpl.java b/src/main/java/konkuk/thip/room/adapter/out/persistence/repository/RoomQueryRepositoryImpl.java index 3ae7fe5d3..5ebf4c0a6 100644 --- a/src/main/java/konkuk/thip/room/adapter/out/persistence/repository/RoomQueryRepositoryImpl.java +++ b/src/main/java/konkuk/thip/room/adapter/out/persistence/repository/RoomQueryRepositoryImpl.java @@ -19,10 +19,13 @@ import konkuk.thip.room.application.port.out.dto.RoomParticipantQueryDto; import konkuk.thip.room.application.port.out.dto.RoomQueryDto; import konkuk.thip.room.domain.value.Category; +import konkuk.thip.room.domain.value.RoomParticipantRole; import konkuk.thip.room.domain.value.RoomStatus; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; +import static konkuk.thip.user.adapter.out.persistence.expression.BlockFilterExpressions.notBlockedWith; + import java.time.LocalDate; import java.time.LocalDateTime; import java.util.List; @@ -39,10 +42,11 @@ public class RoomQueryRepositoryImpl implements RoomQueryRepository { private final QBookJpaEntity book = QBookJpaEntity.bookJpaEntity; private final QRoomParticipantJpaEntity participant = QRoomParticipantJpaEntity.roomParticipantJpaEntity; - /** 모집중 + ACTIVE 공통 where */ - private BooleanBuilder recruitingActiveWhere() { + /** 모집중 + ACTIVE 공통 where (방장이 차단 관계인 방은 제외) */ + private BooleanBuilder recruitingActiveWhere(Long viewerId) { BooleanBuilder where = new BooleanBuilder(); where.and(room.roomStatus.eq(RECRUITING)); + where.and(roomHostNotBlockedWith(viewerId)); return where; } @@ -96,10 +100,10 @@ private QRoomQueryDto projectionForRecruitingRoomSearch() { * 모집중인 방 검색 관련 메서드 */ @Override - public List findRecruitingRoomsOrderByStartDateAsc(String keyword, LocalDate lastStartDate, Long roomId, int pageSize) { + public List findRecruitingRoomsOrderByStartDateAsc(String keyword, LocalDate lastStartDate, Long roomId, int pageSize, Long viewerId) { DateExpression cursorExpr = room.startDate; // 커서 비교는 startDate - BooleanBuilder where = recruitingActiveWhere(); + BooleanBuilder where = recruitingActiveWhere(viewerId); applyKeyword(where, keyword); applyCursorStartDateAsc(where, cursorExpr, lastStartDate, roomId); @@ -114,10 +118,10 @@ public List findRecruitingRoomsOrderByStartDateAsc(String keyword, } @Override - public List findRecruitingRoomsWithCategoryOrderByStartDateAsc(String keyword, Category category, LocalDate lastStartDate, Long roomId, int pageSize) { + public List findRecruitingRoomsWithCategoryOrderByStartDateAsc(String keyword, Category category, LocalDate lastStartDate, Long roomId, int pageSize, Long viewerId) { DateExpression cursorExpr = room.startDate; - BooleanBuilder where = recruitingActiveWhere(); + BooleanBuilder where = recruitingActiveWhere(viewerId); applyCategory(where, category); applyKeyword(where, keyword); applyCursorStartDateAsc(where, cursorExpr, lastStartDate, roomId); @@ -133,8 +137,8 @@ public List findRecruitingRoomsWithCategoryOrderByStartDateAsc(Str } @Override - public List findRecruitingRoomsOrderByMemberCountDesc(String keyword, Integer lastMemberCount, Long roomId, int pageSize) { - BooleanBuilder where = recruitingActiveWhere(); + public List findRecruitingRoomsOrderByMemberCountDesc(String keyword, Integer lastMemberCount, Long roomId, int pageSize, Long viewerId) { + BooleanBuilder where = recruitingActiveWhere(viewerId); applyKeyword(where, keyword); applyCursorMemberCountDesc(where, lastMemberCount, roomId); @@ -149,8 +153,8 @@ public List findRecruitingRoomsOrderByMemberCountDesc(String keywo } @Override - public List findRecruitingRoomsWithCategoryOrderByMemberCountDesc(String keyword, Category category, Integer lastMemberCount, Long roomId, int pageSize) { - BooleanBuilder where = recruitingActiveWhere(); + public List findRecruitingRoomsWithCategoryOrderByMemberCountDesc(String keyword, Category category, Integer lastMemberCount, Long roomId, int pageSize, Long viewerId) { + BooleanBuilder where = recruitingActiveWhere(viewerId); applyCategory(where, category); applyKeyword(where, keyword); applyCursorMemberCountDesc(where, lastMemberCount, roomId); @@ -167,7 +171,7 @@ public List findRecruitingRoomsWithCategoryOrderByMemberCountDesc( // ----------------------------------------------------------------------------------------------------------------------- @Override - public List findOtherRecruitingRoomsByCategoryOrderByStartDateAsc(Long roomId, Category category, int count) { + public List findOtherRecruitingRoomsByCategoryOrderByStartDateAsc(Long roomId, Category category, int count, Long viewerId) { List tuples = queryFactory .select(room.roomId, room.title, room.memberCount, room.recruitCount, room.startDate, book.imageUrl) .from(room) @@ -177,6 +181,7 @@ public List findOtherRecruitingR .and(room.roomStatus.eq(RECRUITING)) // 모집 중인 방 .and(room.roomId.ne(roomId))// 현재 방 제외 .and(room.isPublic.isTrue()) // 공개방 만 + , roomHostNotBlockedWith(viewerId) ) .orderBy(room.startDate.asc()) .limit(count) @@ -323,7 +328,7 @@ public List findExpiredRoomsUserParticipated( } @Override - public List findRoomsByCategoryOrderByStartDateAsc(Category category, int limit) { + public List findRoomsByCategoryOrderByStartDateAsc(Category category, int limit, Long viewerId) { return queryFactory .select(new QRoomQueryDto( room.roomId, @@ -336,14 +341,14 @@ public List findRoomsByCategoryOrderByStartDateAsc(Category catego )) .from(room) .join(room.bookJpaEntity, book) - .where(findDeadlinePopularRecentRoomCondition(category)) + .where(findDeadlinePopularRecentRoomCondition(category, viewerId)) .orderBy(room.startDate.asc(), room.memberCount.desc(), room.roomId.asc()) .limit(limit) .fetch(); } @Override - public List findRoomsByCategoryOrderByMemberCount(Category category, int limit) { + public List findRoomsByCategoryOrderByMemberCount(Category category, int limit, Long viewerId) { return queryFactory .select(new QRoomQueryDto( room.roomId, @@ -356,14 +361,14 @@ public List findRoomsByCategoryOrderByMemberCount(Category categor )) .from(room) .join(room.bookJpaEntity, book) - .where(findDeadlinePopularRecentRoomCondition(category)) + .where(findDeadlinePopularRecentRoomCondition(category, viewerId)) .orderBy(room.memberCount.desc(), room.startDate.asc(), room.roomId.asc()) .limit(limit) .fetch(); } @Override - public List findRoomsByCategoryOrderByCreatedAtDesc(Category category, LocalDateTime createdAfter, int limit) { + public List findRoomsByCategoryOrderByCreatedAtDesc(Category category, LocalDateTime createdAfter, int limit, Long viewerId) { return queryFactory .select(new QRoomQueryDto( room.roomId, @@ -377,7 +382,7 @@ public List findRoomsByCategoryOrderByCreatedAtDesc(Category categ .from(room) .join(room.bookJpaEntity, book) .where( - findDeadlinePopularRecentRoomCondition(category) + findDeadlinePopularRecentRoomCondition(category, viewerId) .and(room.createdAt.goe(createdAfter)) ) .orderBy(room.createdAt.desc(), room.roomId.desc()) @@ -386,11 +391,16 @@ public List findRoomsByCategoryOrderByCreatedAtDesc(Category categ } @Override - public List findRoomsByIsbnOrderByStartDateAsc(String isbn, LocalDate dateCursor, Long roomIdCursor, int pageSize) { + public List findRoomsByIsbnOrderByStartDateAsc(String isbn, LocalDate dateCursor, Long roomIdCursor, int pageSize, Long viewerId) { DateExpression cursorExpr = room.startDate; // 커서 비교는 startDate(= 모집 마감일 - 1일) BooleanExpression baseCondition = room.bookJpaEntity.isbn.eq(isbn) .and(room.roomStatus.eq(RECRUITING)); // 모집중인 방 + BooleanExpression hostNotBlocked = roomHostNotBlockedWith(viewerId); + if (hostNotBlocked != null) { + baseCondition = baseCondition.and(hostNotBlocked); + } + if (dateCursor != null && roomIdCursor != null) { // 첫 페이지가 아닌 경우 baseCondition = baseCondition.and(cursorExpr.gt(dateCursor) @@ -416,10 +426,30 @@ public List findRoomsByIsbnOrderByStartDateAsc(String isbn, LocalD .fetch(); } - private BooleanExpression findDeadlinePopularRecentRoomCondition(Category category) { - return room.category.eq(category) + private BooleanExpression findDeadlinePopularRecentRoomCondition(Category category, Long viewerId) { + BooleanExpression condition = room.category.eq(category) .and(room.roomStatus.eq(RECRUITING)) // 모집중인 방 .and(room.isPublic.isTrue()); // 공개 방만 조회 + + BooleanExpression hostNotBlocked = roomHostNotBlockedWith(viewerId); + return hostNotBlocked != null ? condition.and(hostNotBlocked) : condition; + } + + // rooms 에 작성자 컬럼이 없어 방장을 participants 의 role 로 찾아야 한다. 이미 참여 중인 방은 숨기지 않는다. + private BooleanExpression roomHostNotBlockedWith(Long viewerId) { + if (viewerId == null) { + return null; + } + QRoomParticipantJpaEntity host = new QRoomParticipantJpaEntity("blockFilterHost"); + return JPAExpressions + .selectOne() + .from(host) + .where( + host.roomJpaEntity.roomId.eq(room.roomId), + host.roomParticipantRole.eq(RoomParticipantRole.HOST), + notBlockedWith(host.userJpaEntity.userId, viewerId).not() + ) + .notExists(); } /** diff --git a/src/main/java/konkuk/thip/room/application/port/in/RoomGetDeadlinePopularRecentUseCase.java b/src/main/java/konkuk/thip/room/application/port/in/RoomGetDeadlinePopularRecentUseCase.java index c5a22c283..03fcf1777 100644 --- a/src/main/java/konkuk/thip/room/application/port/in/RoomGetDeadlinePopularRecentUseCase.java +++ b/src/main/java/konkuk/thip/room/application/port/in/RoomGetDeadlinePopularRecentUseCase.java @@ -4,5 +4,5 @@ public interface RoomGetDeadlinePopularRecentUseCase { - RoomGetDeadlinePopularRecentResponse getDeadlineAndPopularAndRecentRoomList(String category); + RoomGetDeadlinePopularRecentResponse getDeadlineAndPopularAndRecentRoomList(String category, Long userId); } diff --git a/src/main/java/konkuk/thip/room/application/port/out/RoomQueryPort.java b/src/main/java/konkuk/thip/room/application/port/out/RoomQueryPort.java index d0961035f..1efa5a24f 100644 --- a/src/main/java/konkuk/thip/room/application/port/out/RoomQueryPort.java +++ b/src/main/java/konkuk/thip/room/application/port/out/RoomQueryPort.java @@ -18,12 +18,12 @@ public interface RoomQueryPort { /** * 방 검색 */ - CursorBasedList searchRecruitingRoomsByDeadline(String keyword, Cursor cursor); - CursorBasedList searchRecruitingRoomsWithCategoryByDeadline(String keyword, Category category, Cursor cursor); - CursorBasedList searchRecruitingRoomsByMemberCount(String keyword, Cursor cursor); - CursorBasedList searchRecruitingRoomsWithCategoryByMemberCount(String keyword, Category category, Cursor cursor); + CursorBasedList searchRecruitingRoomsByDeadline(String keyword, Cursor cursor, Long viewerId); + CursorBasedList searchRecruitingRoomsWithCategoryByDeadline(String keyword, Category category, Cursor cursor, Long viewerId); + CursorBasedList searchRecruitingRoomsByMemberCount(String keyword, Cursor cursor, Long viewerId); + CursorBasedList searchRecruitingRoomsWithCategoryByMemberCount(String keyword, Category category, Cursor cursor, Long viewerId); - List findOtherRecruitingRoomsByCategoryOrderByStartDateAsc(Room currentRoom, int count); + List findOtherRecruitingRoomsByCategoryOrderByStartDateAsc(Room currentRoom, int count, Long viewerId); CursorBasedList searchHomeJoinedRooms(Long userId, Cursor cursor); @@ -35,13 +35,13 @@ public interface RoomQueryPort { CursorBasedList findExpiredRoomsUserParticipated(Long userId, Cursor cursor); - CursorBasedList findRoomsByIsbnOrderByDeadline(String isbn, Cursor cursor); + CursorBasedList findRoomsByIsbnOrderByDeadline(String isbn, Cursor cursor, Long viewerId); - List findRoomsByCategoryOrderByDeadline(Category category, int limit); + List findRoomsByCategoryOrderByDeadline(Category category, int limit, Long viewerId); - List findRoomsByCategoryOrderByPopular(Category category, int limit); + List findRoomsByCategoryOrderByPopular(Category category, int limit, Long viewerId); - List findRoomsByCategoryOrderByRecent(Category category, LocalDateTime createdAfter, int limit); + List findRoomsByCategoryOrderByRecent(Category category, LocalDateTime createdAfter, int limit, Long viewerId); /** * 임시 메서드 * TODO 리펙토링 대상 diff --git a/src/main/java/konkuk/thip/room/application/service/RoomGetDeadlinePopularRecentService.java b/src/main/java/konkuk/thip/room/application/service/RoomGetDeadlinePopularRecentService.java index 2b7be4ae2..29a05a65c 100644 --- a/src/main/java/konkuk/thip/room/application/service/RoomGetDeadlinePopularRecentService.java +++ b/src/main/java/konkuk/thip/room/application/service/RoomGetDeadlinePopularRecentService.java @@ -23,17 +23,17 @@ public class RoomGetDeadlinePopularRecentService implements RoomGetDeadlinePopul @Override @Transactional(readOnly = true) - public RoomGetDeadlinePopularRecentResponse getDeadlineAndPopularAndRecentRoomList(String categoryStr) { + public RoomGetDeadlinePopularRecentResponse getDeadlineAndPopularAndRecentRoomList(String categoryStr, Long userId) { Category category = Category.from(categoryStr); LocalDateTime now = LocalDateTime.now(); LocalDateTime recentCutoff = now.minusHours(RECENT_HOURS); var deadlineRoomList = roomQueryMapper.toDeadlinePopularRecentRoomDtoList( - roomQueryPort.findRoomsByCategoryOrderByDeadline(category, DEFAULT_LIMIT)); + roomQueryPort.findRoomsByCategoryOrderByDeadline(category, DEFAULT_LIMIT, userId)); var popularRoomList = roomQueryMapper.toDeadlinePopularRecentRoomDtoList( - roomQueryPort.findRoomsByCategoryOrderByPopular(category, DEFAULT_LIMIT)); + roomQueryPort.findRoomsByCategoryOrderByPopular(category, DEFAULT_LIMIT, userId)); var recentRoomList = roomQueryMapper.toDeadlinePopularRecentRoomDtoList( - roomQueryPort.findRoomsByCategoryOrderByRecent(category, recentCutoff, DEFAULT_LIMIT)); + roomQueryPort.findRoomsByCategoryOrderByRecent(category, recentCutoff, DEFAULT_LIMIT, userId)); return RoomGetDeadlinePopularRecentResponse.of(deadlineRoomList, popularRoomList,recentRoomList); } diff --git a/src/main/java/konkuk/thip/room/application/service/RoomGetMemberListService.java b/src/main/java/konkuk/thip/room/application/service/RoomGetMemberListService.java index 19f9173e8..b4f1a6e68 100644 --- a/src/main/java/konkuk/thip/room/application/service/RoomGetMemberListService.java +++ b/src/main/java/konkuk/thip/room/application/service/RoomGetMemberListService.java @@ -7,6 +7,7 @@ import konkuk.thip.room.application.service.validator.RoomParticipantValidator; import konkuk.thip.room.domain.Room; import konkuk.thip.room.domain.RoomParticipant; +import konkuk.thip.user.application.port.out.UserBlockQueryPort; import konkuk.thip.user.application.port.out.UserCommandPort; import konkuk.thip.user.domain.User; import lombok.RequiredArgsConstructor; @@ -15,6 +16,7 @@ import java.util.List; import java.util.Map; +import java.util.Set; @Service @RequiredArgsConstructor @@ -25,6 +27,7 @@ public class RoomGetMemberListService implements RoomGetMemberListUseCase { private final UserCommandPort userCommandPort; private final RoomParticipantValidator roomParticipantValidator; + private final UserBlockQueryPort userBlockQueryPort; @Override @Transactional(readOnly = true) @@ -35,8 +38,11 @@ public RoomGetMemberListResponse getRoomMemberList(Long userId, Long roomId) { // 1-1. 방 검증 및 방 조회 Room room = roomCommandPort.getByIdOrThrow(roomId); - // 2. 방 참여자(UserRoom) 전체 조회 - List roomParticipants = roomParticipantCommandPort.findAllByRoomId(room.getId()); + // 2. 방 참여자 전체 조회 (차단 관계인 참여자는 제외. memberCount 는 차감하지 않는다) + Set blockedUserIds = userBlockQueryPort.findBlockedUserIdsBothWays(userId); + List roomParticipants = roomParticipantCommandPort.findAllByRoomId(room.getId()).stream() + .filter(roomParticipant -> !blockedUserIds.contains(roomParticipant.getUserId())) + .toList(); // 3. 참여자 userId 목록 추출 List userIds = roomParticipants.stream() diff --git a/src/main/java/konkuk/thip/room/application/service/RoomJoinService.java b/src/main/java/konkuk/thip/room/application/service/RoomJoinService.java index 7364b0963..558b378f7 100644 --- a/src/main/java/konkuk/thip/room/application/service/RoomJoinService.java +++ b/src/main/java/konkuk/thip/room/application/service/RoomJoinService.java @@ -13,6 +13,7 @@ import konkuk.thip.room.domain.Room; import konkuk.thip.room.application.port.in.dto.RoomJoinType; import konkuk.thip.room.domain.RoomParticipant; +import konkuk.thip.user.application.port.out.UserBlockQueryPort; import konkuk.thip.user.application.port.out.UserCommandPort; import konkuk.thip.user.domain.User; import lombok.RequiredArgsConstructor; @@ -34,6 +35,7 @@ public class RoomJoinService implements RoomJoinUseCase { private final RoomCommandPort roomCommandPort; private final RoomParticipantCommandPort roomParticipantCommandPort; private final UserCommandPort userCommandPort; + private final UserBlockQueryPort userBlockQueryPort; private final RoomNotificationOrchestrator roomNotificationOrchestrator; @@ -63,6 +65,11 @@ public RoomJoinResult changeJoinState(RoomJoinCommand roomJoinCommand) { Optional roomParticipantOptional = roomParticipantCommandPort.findByUserIdAndRoomIdOptional(roomJoinCommand.userId(), roomJoinCommand.roomId()); + // 참여하려는 방의 방장이 차단 관계면 참여를 막는다. 나가기(CANCEL)에는 적용하지 않는다. + if (type == RoomJoinType.JOIN) { + validateHostNotBlocked(roomJoinCommand.userId(), room.getId()); + } + // 방 참여 상태 변경 요청에 따라 분기 처리 switch (type) { case JOIN -> handleJoin(roomJoinCommand, roomParticipantOptional, room); @@ -95,6 +102,13 @@ public RoomJoinResult recoverBusinessException(BusinessException e, RoomJoinComm throw e; } + private void validateHostNotBlocked(Long userId, Long roomId) { + RoomParticipant host = roomParticipantCommandPort.findHostByRoomId(roomId); + if (host != null && userBlockQueryPort.existsBlockBetween(userId, host.getUserId())) { + throw new BusinessException(ErrorCode.ROOM_HOST_BLOCKED); + } + } + private void sendNotifications(RoomJoinCommand roomJoinCommand, Room room) { RoomParticipant targetUser = roomParticipantCommandPort.findHostByRoomId(room.getId()); User actorUser = userCommandPort.findById(roomJoinCommand.userId()); diff --git a/src/main/java/konkuk/thip/room/application/service/RoomSearchService.java b/src/main/java/konkuk/thip/room/application/service/RoomSearchService.java index 268251e62..1d6af9920 100644 --- a/src/main/java/konkuk/thip/room/application/service/RoomSearchService.java +++ b/src/main/java/konkuk/thip/room/application/service/RoomSearchService.java @@ -46,7 +46,7 @@ public RoomSearchResponse searchRecruitingRooms(RoomSearchQuery query) { Cursor cursor = Cursor.from(query.cursorStr(), DEFAULT_PAGE_SIZE); // 4) 실행 (정렬 기준별 단일 switch) - CursorBasedList result = executeSearchMode(mode, sortParam, effectiveKeyword, category, cursor); + CursorBasedList result = executeSearchMode(mode, sortParam, effectiveKeyword, category, cursor, query.userId()); // 5) 최근 검색어 저장 recentSearchCreateManager.saveRecentSearchByUser( @@ -66,11 +66,11 @@ private CursorBasedList executeSearchMode( RoomSearchSortParam sort, String keyword, Category category, - Cursor cursor + Cursor cursor, Long viewerId ) { return switch (sort) { - case DEADLINE -> executeByDeadline(mode, keyword, category, cursor); - case MEMBER_COUNT -> executeByMemberCount(mode, keyword, category, cursor); + case DEADLINE -> executeByDeadline(mode, keyword, category, cursor, viewerId); + case MEMBER_COUNT -> executeByMemberCount(mode, keyword, category, cursor, viewerId); default -> throw new BusinessException( API_INVALID_PARAM, new IllegalArgumentException("지원하지 않는 정렬 기준입니다: " + sort) @@ -79,20 +79,20 @@ private CursorBasedList executeSearchMode( } private CursorBasedList executeByDeadline( - RoomSearchMode mode, String keyword, Category category, Cursor cursor + RoomSearchMode mode, String keyword, Category category, Cursor cursor, Long viewerId ) { return switch (mode) { - case GLOBAL_BY_KEYWORD_OR_ALL -> roomQueryPort.searchRecruitingRoomsByDeadline(keyword, cursor); - case CATEGORY_ALL, CATEGORY_BY_KEYWORD -> roomQueryPort.searchRecruitingRoomsWithCategoryByDeadline(keyword, category, cursor); + case GLOBAL_BY_KEYWORD_OR_ALL -> roomQueryPort.searchRecruitingRoomsByDeadline(keyword, cursor, viewerId); + case CATEGORY_ALL, CATEGORY_BY_KEYWORD -> roomQueryPort.searchRecruitingRoomsWithCategoryByDeadline(keyword, category, cursor, viewerId); }; } private CursorBasedList executeByMemberCount( - RoomSearchMode mode, String keyword, Category category, Cursor cursor + RoomSearchMode mode, String keyword, Category category, Cursor cursor, Long viewerId ) { return switch (mode) { - case GLOBAL_BY_KEYWORD_OR_ALL -> roomQueryPort.searchRecruitingRoomsByMemberCount(keyword, cursor); - case CATEGORY_ALL, CATEGORY_BY_KEYWORD -> roomQueryPort.searchRecruitingRoomsWithCategoryByMemberCount(keyword, category, cursor); + case GLOBAL_BY_KEYWORD_OR_ALL -> roomQueryPort.searchRecruitingRoomsByMemberCount(keyword, cursor, viewerId); + case CATEGORY_ALL, CATEGORY_BY_KEYWORD -> roomQueryPort.searchRecruitingRoomsWithCategoryByMemberCount(keyword, category, cursor, viewerId); }; } diff --git a/src/main/java/konkuk/thip/room/application/service/RoomShowPlayingOrExpiredDetailViewService.java b/src/main/java/konkuk/thip/room/application/service/RoomShowPlayingOrExpiredDetailViewService.java index c0351ef55..792e7a6dc 100644 --- a/src/main/java/konkuk/thip/room/application/service/RoomShowPlayingOrExpiredDetailViewService.java +++ b/src/main/java/konkuk/thip/room/application/service/RoomShowPlayingOrExpiredDetailViewService.java @@ -47,7 +47,7 @@ public RoomPlayingOrExpiredDetailViewResponse getPlayingOrExpiredRoomDetailView( RoomParticipant roomParticipant = roomParticipantCommandPort.getByUserIdAndRoomIdOrThrow(userId, roomId); // 3. 투표 참여율이 가장 높은 투표 조회 - List topParticipationVotes = voteQueryPort.findTopParticipationVotesByRoom(room, TOP_PARTICIPATION_VOTES_COUNT); + List topParticipationVotes = voteQueryPort.findTopParticipationVotesByRoom(room, TOP_PARTICIPATION_VOTES_COUNT, userId); // 4. response 구성 return buildResponse(room, book, roomParticipant, topParticipationVotes); diff --git a/src/main/java/konkuk/thip/room/application/service/RoomShowRecruitingDetailViewService.java b/src/main/java/konkuk/thip/room/application/service/RoomShowRecruitingDetailViewService.java index 2897ef2af..63ca6be3c 100644 --- a/src/main/java/konkuk/thip/room/application/service/RoomShowRecruitingDetailViewService.java +++ b/src/main/java/konkuk/thip/room/application/service/RoomShowRecruitingDetailViewService.java @@ -7,7 +7,10 @@ import konkuk.thip.room.application.port.in.RoomShowRecruitingDetailViewUseCase; import konkuk.thip.room.application.port.out.RoomCommandPort; import konkuk.thip.room.application.port.out.RoomQueryPort; +import konkuk.thip.common.exception.EntityNotFoundException; +import konkuk.thip.common.exception.code.ErrorCode; import konkuk.thip.room.domain.Room; +import konkuk.thip.user.application.port.out.UserBlockQueryPort; import konkuk.thip.room.application.port.out.RoomParticipantCommandPort; import konkuk.thip.room.domain.RoomParticipant; import konkuk.thip.room.domain.RoomParticipants; @@ -27,6 +30,7 @@ public class RoomShowRecruitingDetailViewService implements RoomShowRecruitingDe private final RoomQueryPort roomQueryPort; private final BookCommandPort bookCommandPort; private final RoomParticipantCommandPort roomParticipantCommandPort; + private final UserBlockQueryPort userBlockQueryPort; @Override @Transactional(readOnly = true) @@ -41,13 +45,29 @@ public RoomRecruitingDetailViewResponse getRecruitingRoomDetailView(Long userId, List findByRoomId = roomParticipantCommandPort.findAllByRoomId(roomId); RoomParticipants roomParticipants = RoomParticipants.from(findByRoomId); + // 2-1. 미참여 상태에서 방장이 차단 관계라면 진입할 수 없다 (이미 참여 중이면 통과) + validateHostNotBlockedForNonMember(userId, roomId, findByRoomId); + // 3. 다른 모임방 추천 - List recommendRooms = roomQueryPort.findOtherRecruitingRoomsByCategoryOrderByStartDateAsc(room, RECOMMEND_ROOM_COUNT); + List recommendRooms = roomQueryPort.findOtherRecruitingRoomsByCategoryOrderByStartDateAsc(room, RECOMMEND_ROOM_COUNT, userId); // 4. response 구성 return buildResponse(userId, room, book, roomParticipants, recommendRooms); } + private void validateHostNotBlockedForNonMember(Long userId, Long roomId, List participants) { + boolean alreadyJoined = participants.stream() + .anyMatch(participant -> participant.getUserId().equals(userId)); + if (alreadyJoined) { + return; + } + + RoomParticipant host = roomParticipantCommandPort.findHostByRoomId(roomId); + if (host != null && userBlockQueryPort.existsBlockBetween(userId, host.getUserId())) { + throw new EntityNotFoundException(ErrorCode.ROOM_NOT_FOUND); + } + } + private RoomRecruitingDetailViewResponse buildResponse( Long userId, Room room, diff --git a/src/main/java/konkuk/thip/roompost/adapter/out/persistence/AttendanceCheckQueryPersistenceAdapter.java b/src/main/java/konkuk/thip/roompost/adapter/out/persistence/AttendanceCheckQueryPersistenceAdapter.java index 1b1c07e60..7a61bd983 100644 --- a/src/main/java/konkuk/thip/roompost/adapter/out/persistence/AttendanceCheckQueryPersistenceAdapter.java +++ b/src/main/java/konkuk/thip/roompost/adapter/out/persistence/AttendanceCheckQueryPersistenceAdapter.java @@ -12,8 +12,6 @@ import java.time.LocalDateTime; import java.util.List; -import static konkuk.thip.common.entity.StatusType.ACTIVE; - @Repository @RequiredArgsConstructor public class AttendanceCheckQueryPersistenceAdapter implements AttendanceCheckQueryPort { @@ -30,11 +28,11 @@ public int countAttendanceChecksOnTodayByUser(Long userId, Long roomId) { } @Override - public CursorBasedList findAttendanceChecksByCreatedAtDesc(Long roomId, Cursor cursor) { + public CursorBasedList findAttendanceChecksByCreatedAtDesc(Long roomId, Cursor cursor, Long viewerId) { LocalDateTime lastCreateAt = cursor.isFirstRequest() ? null : cursor.getLocalDateTime(0); int size = cursor.getPageSize(); - List attendanceCheckQueryDtos = attendanceCheckJpaRepository.findAttendanceChecksByCreatedAtDesc(roomId, lastCreateAt, size); + List attendanceCheckQueryDtos = attendanceCheckJpaRepository.findAttendanceChecksByCreatedAtDesc(roomId, lastCreateAt, size, viewerId); return CursorBasedList.of(attendanceCheckQueryDtos, size, attendanceCheckQueryDto -> { Cursor nextCursor = new Cursor(List.of(attendanceCheckQueryDto.createdAt().toString())); diff --git a/src/main/java/konkuk/thip/roompost/adapter/out/persistence/VoteQueryPersistenceAdapter.java b/src/main/java/konkuk/thip/roompost/adapter/out/persistence/VoteQueryPersistenceAdapter.java index 97ab89043..1140f9f03 100644 --- a/src/main/java/konkuk/thip/roompost/adapter/out/persistence/VoteQueryPersistenceAdapter.java +++ b/src/main/java/konkuk/thip/roompost/adapter/out/persistence/VoteQueryPersistenceAdapter.java @@ -23,8 +23,8 @@ public class VoteQueryPersistenceAdapter implements VoteQueryPort { private final VoteJpaRepository voteJpaRepository; @Override - public List findTopParticipationVotesByRoom(Room room, int count) { - return voteJpaRepository.findTopParticipationVotesByRoom(room.getId(), count); + public List findTopParticipationVotesByRoom(Room room, int count, Long viewerId) { + return voteJpaRepository.findTopParticipationVotesByRoom(room.getId(), count, viewerId); } @Override diff --git a/src/main/java/konkuk/thip/roompost/adapter/out/persistence/repository/attendancecheck/AttendanceCheckQueryRepository.java b/src/main/java/konkuk/thip/roompost/adapter/out/persistence/repository/attendancecheck/AttendanceCheckQueryRepository.java index a0c250171..c4188a8b8 100644 --- a/src/main/java/konkuk/thip/roompost/adapter/out/persistence/repository/attendancecheck/AttendanceCheckQueryRepository.java +++ b/src/main/java/konkuk/thip/roompost/adapter/out/persistence/repository/attendancecheck/AttendanceCheckQueryRepository.java @@ -7,5 +7,5 @@ public interface AttendanceCheckQueryRepository { - List findAttendanceChecksByCreatedAtDesc(Long roomId, LocalDateTime lastCreatedAt, int size); + List findAttendanceChecksByCreatedAtDesc(Long roomId, LocalDateTime lastCreatedAt, int size, Long viewerId); } diff --git a/src/main/java/konkuk/thip/roompost/adapter/out/persistence/repository/attendancecheck/AttendanceCheckQueryRepositoryImpl.java b/src/main/java/konkuk/thip/roompost/adapter/out/persistence/repository/attendancecheck/AttendanceCheckQueryRepositoryImpl.java index d32a5dbc2..9b778feec 100644 --- a/src/main/java/konkuk/thip/roompost/adapter/out/persistence/repository/attendancecheck/AttendanceCheckQueryRepositoryImpl.java +++ b/src/main/java/konkuk/thip/roompost/adapter/out/persistence/repository/attendancecheck/AttendanceCheckQueryRepositoryImpl.java @@ -9,6 +9,8 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; +import static konkuk.thip.user.adapter.out.persistence.expression.BlockFilterExpressions.notBlockedWith; + import java.time.LocalDateTime; import java.util.List; @@ -19,12 +21,13 @@ public class AttendanceCheckQueryRepositoryImpl implements AttendanceCheckQueryR private final JPAQueryFactory jpaQueryFactory; @Override - public List findAttendanceChecksByCreatedAtDesc(Long roomId, LocalDateTime lastCreatedAt, int size) { + public List findAttendanceChecksByCreatedAtDesc(Long roomId, LocalDateTime lastCreatedAt, int size, Long viewerId) { QAttendanceCheckJpaEntity attendanceCheck = QAttendanceCheckJpaEntity.attendanceCheckJpaEntity; QUserJpaEntity user = QUserJpaEntity.userJpaEntity; BooleanExpression roomPredicate = attendanceCheck.roomJpaEntity.roomId.eq(roomId); BooleanExpression cursorPredicate = (lastCreatedAt == null) ? null : attendanceCheck.createdAt.lt(lastCreatedAt); + BooleanExpression notBlockedPredicate = notBlockedWith(attendanceCheck.userJpaEntity.userId, viewerId); return jpaQueryFactory .select(new QAttendanceCheckQueryDto( @@ -37,7 +40,7 @@ public List findAttendanceChecksByCreatedAtDesc(Long ro )) .from(attendanceCheck) .join(attendanceCheck.userJpaEntity, user) - .where(roomPredicate, cursorPredicate) + .where(roomPredicate, cursorPredicate, notBlockedPredicate) .orderBy( attendanceCheck.createdAt.desc() ) diff --git a/src/main/java/konkuk/thip/roompost/adapter/out/persistence/repository/record/RecordQueryRepositoryImpl.java b/src/main/java/konkuk/thip/roompost/adapter/out/persistence/repository/record/RecordQueryRepositoryImpl.java index 2376fc136..326a2ab5c 100644 --- a/src/main/java/konkuk/thip/roompost/adapter/out/persistence/repository/record/RecordQueryRepositoryImpl.java +++ b/src/main/java/konkuk/thip/roompost/adapter/out/persistence/repository/record/RecordQueryRepositoryImpl.java @@ -22,6 +22,7 @@ import static com.querydsl.jpa.JPAExpressions.treat; import static konkuk.thip.post.domain.PostType.RECORD; import static konkuk.thip.post.domain.PostType.VOTE; +import static konkuk.thip.user.adapter.out.persistence.expression.BlockFilterExpressions.notBlockedWith; @Repository @RequiredArgsConstructor @@ -70,7 +71,7 @@ private BooleanBuilder buildMyRecordCondition(Long roomId, Long userId) { @Override public List findGroupRecordsOrderBySortType(Long roomId, Long userId, Cursor cursor, Integer pageStart, Integer pageEnd, Boolean isOverview, RoomPostSortType roomPostSortType) { - BooleanBuilder where = buildRecordVoteCondition(roomId, pageStart, pageEnd, isOverview); + BooleanBuilder where = buildRecordVoteCondition(roomId, pageStart, pageEnd, isOverview, userId); if (!cursor.isFirstRequest()) { where.and(buildCursorPredicateForSortType(roomPostSortType, cursor)); @@ -86,7 +87,7 @@ public List findGroupRecordsOrderBySortType(Long roomId, Long .fetch(); } - private BooleanBuilder buildRecordVoteCondition(Long roomId, Integer pageStart, Integer pageEnd, Boolean isOverview) { + private BooleanBuilder buildRecordVoteCondition(Long roomId, Integer pageStart, Integer pageEnd, Boolean isOverview, Long viewerId) { BooleanBuilder where = new BooleanBuilder(); // VOTE @@ -113,7 +114,8 @@ private BooleanBuilder buildRecordVoteCondition(Long roomId, Integer pageStart, .and(treat(post, QRecordJpaEntity.class).page.between(pageStart, pageEnd)); } - where.and(voteCondition.or(recordCondition)); + where.and(voteCondition.or(recordCondition)) + .and(notBlockedWith(post.userJpaEntity.userId, viewerId)); return where; } diff --git a/src/main/java/konkuk/thip/roompost/adapter/out/persistence/repository/vote/VoteQueryRepository.java b/src/main/java/konkuk/thip/roompost/adapter/out/persistence/repository/vote/VoteQueryRepository.java index d7033890b..545210f96 100644 --- a/src/main/java/konkuk/thip/roompost/adapter/out/persistence/repository/vote/VoteQueryRepository.java +++ b/src/main/java/konkuk/thip/roompost/adapter/out/persistence/repository/vote/VoteQueryRepository.java @@ -11,7 +11,7 @@ public interface VoteQueryRepository { List findVotesByRoom(Long roomId, String type, Integer pageStart, Integer pageEnd, Long userId); - List findTopParticipationVotesByRoom(Long roomId, int count); + List findTopParticipationVotesByRoom(Long roomId, int count, Long viewerId); List mapVoteItemsByVoteIds(Set voteIds, Long userId); diff --git a/src/main/java/konkuk/thip/roompost/adapter/out/persistence/repository/vote/VoteQueryRepositoryImpl.java b/src/main/java/konkuk/thip/roompost/adapter/out/persistence/repository/vote/VoteQueryRepositoryImpl.java index 4071ba234..7c426e860 100644 --- a/src/main/java/konkuk/thip/roompost/adapter/out/persistence/repository/vote/VoteQueryRepositoryImpl.java +++ b/src/main/java/konkuk/thip/roompost/adapter/out/persistence/repository/vote/VoteQueryRepositoryImpl.java @@ -14,6 +14,8 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; +import static konkuk.thip.user.adapter.out.persistence.expression.BlockFilterExpressions.notBlockedWith; + import java.util.List; import java.util.Set; @@ -37,7 +39,8 @@ public List findVotesByRoom(Long roomId, String type, Integer pag .where( vote.roomJpaEntity.roomId.eq(roomId), filterByType(type, vote, userId), - (startEndNull(pageStart, pageEnd) ? vote.isOverview.isTrue() : vote.page.between(pageStart, pageEnd)) + (startEndNull(pageStart, pageEnd) ? vote.isOverview.isTrue() : vote.page.between(pageStart, pageEnd)), + notBlockedWith(vote.userJpaEntity.userId, userId) ) .fetch(); } @@ -54,13 +57,14 @@ private BooleanExpression filterByType(String type, QVoteJpaEntity post, Long us } @Override - public List findTopParticipationVotesByRoom(Long roomId, int count) { + public List findTopParticipationVotesByRoom(Long roomId, int count, Long viewerId) { // 1. Fetch top votes by total participation count List topVotes = jpaQueryFactory .select(vote) .from(vote) .join(voteItem).on(voteItem.voteJpaEntity.eq(vote)) // vote item이 없는 경우 포함 X - .where(vote.roomJpaEntity.roomId.eq(roomId)) + .where(vote.roomJpaEntity.roomId.eq(roomId), + notBlockedWith(vote.userJpaEntity.userId, viewerId)) .groupBy(vote) .orderBy(voteItem.count.sum().desc()) // 해당 투표에 참여한 총 참여자 수 기준 내림차순 정렬 .limit(count) diff --git a/src/main/java/konkuk/thip/roompost/application/port/out/AttendanceCheckQueryPort.java b/src/main/java/konkuk/thip/roompost/application/port/out/AttendanceCheckQueryPort.java index 529977e3c..8c61ff189 100644 --- a/src/main/java/konkuk/thip/roompost/application/port/out/AttendanceCheckQueryPort.java +++ b/src/main/java/konkuk/thip/roompost/application/port/out/AttendanceCheckQueryPort.java @@ -8,5 +8,5 @@ public interface AttendanceCheckQueryPort { int countAttendanceChecksOnTodayByUser(Long userId, Long roomId); - CursorBasedList findAttendanceChecksByCreatedAtDesc(Long roomId, Cursor cursor); + CursorBasedList findAttendanceChecksByCreatedAtDesc(Long roomId, Cursor cursor, Long viewerId); } diff --git a/src/main/java/konkuk/thip/roompost/application/port/out/VoteQueryPort.java b/src/main/java/konkuk/thip/roompost/application/port/out/VoteQueryPort.java index ee68f34ec..de50d904c 100644 --- a/src/main/java/konkuk/thip/roompost/application/port/out/VoteQueryPort.java +++ b/src/main/java/konkuk/thip/roompost/application/port/out/VoteQueryPort.java @@ -10,7 +10,7 @@ public interface VoteQueryPort { - List findTopParticipationVotesByRoom(Room room, int count); + List findTopParticipationVotesByRoom(Room room, int count, Long viewerId); Map> findVoteItemsByVoteIds(Set voteIds, Long userId); diff --git a/src/main/java/konkuk/thip/roompost/application/service/AttendanceCheckShowService.java b/src/main/java/konkuk/thip/roompost/application/service/AttendanceCheckShowService.java index acd7fb1bf..682effd26 100644 --- a/src/main/java/konkuk/thip/roompost/application/service/AttendanceCheckShowService.java +++ b/src/main/java/konkuk/thip/roompost/application/service/AttendanceCheckShowService.java @@ -31,7 +31,7 @@ public AttendanceCheckShowResponse showDailyGreeting(Long userId, Long roomId, S Cursor cursor = Cursor.from(cursorStr, PAGE_SIZE); // 3. 오늘의 한마디 조회 - CursorBasedList dtos = attendanceCheckQueryPort.findAttendanceChecksByCreatedAtDesc(roomId, cursor); + CursorBasedList dtos = attendanceCheckQueryPort.findAttendanceChecksByCreatedAtDesc(roomId, cursor, userId); // 4. response 로 매핑 후 반환 return new AttendanceCheckShowResponse( diff --git a/src/main/java/konkuk/thip/user/adapter/in/web/UserCommandController.java b/src/main/java/konkuk/thip/user/adapter/in/web/UserCommandController.java index e5f9044b4..d26a7c75a 100644 --- a/src/main/java/konkuk/thip/user/adapter/in/web/UserCommandController.java +++ b/src/main/java/konkuk/thip/user/adapter/in/web/UserCommandController.java @@ -9,11 +9,14 @@ import konkuk.thip.common.security.annotation.Oauth2Id; import konkuk.thip.common.security.annotation.UserId; import konkuk.thip.common.swagger.annotation.ExceptionDescription; +import konkuk.thip.user.adapter.in.web.request.UserBlockRequest; import konkuk.thip.user.adapter.in.web.request.UserFollowRequest; import konkuk.thip.user.adapter.in.web.request.UserSignupRequest; import konkuk.thip.user.adapter.in.web.request.UserUpdateRequest; +import konkuk.thip.user.adapter.in.web.response.UserBlockResponse; import konkuk.thip.user.adapter.in.web.response.UserFollowResponse; import konkuk.thip.user.adapter.in.web.response.UserSignupResponse; +import konkuk.thip.user.application.port.in.UserBlockUseCase; import konkuk.thip.user.application.port.in.UserDeleteUseCase; import konkuk.thip.user.application.port.in.UserFollowUsecase; import konkuk.thip.user.application.port.in.UserSignupUseCase; @@ -30,6 +33,7 @@ public class UserCommandController { private final UserSignupUseCase userSignupUseCase; private final UserFollowUsecase userFollowUsecase; + private final UserBlockUseCase userBlockUseCase; private final UserUpdateUseCase userUpdateUseCase; private final UserDeleteUseCase userDeleteUseCase; @@ -65,6 +69,23 @@ public BaseResponse followUser( ))); } + @Operation( + summary = "사용자 차단 상태 변경", + description = "특정 사용자를 차단하거나 차단 해제합니다. true 이면 차단, false 이면 차단 해제입니다. " + + "차단하면 서로의 콘텐츠가 목록에서 보이지 않게 되며, 양쪽 팔로우 관계가 자동으로 해제됩니다. " + + "차단을 해제해도 팔로우 관계는 복구되지 않습니다." + ) + @ExceptionDescription(CHANGE_BLOCK_STATE) + @PostMapping("/users/block/{targetUserId}") + public BaseResponse blockUser( + @Parameter(hidden = true) @UserId final Long userId, + @Parameter(description = "차단/차단 해제할 사용자 ID") @PathVariable final Long targetUserId, + @RequestBody @Valid final UserBlockRequest userBlockRequest) { + return BaseResponse.ok(UserBlockResponse.of(userBlockUseCase.changeBlockState( + userBlockRequest.toCommand(userId, targetUserId) + ))); + } + @Operation( summary = "사용자 정보 수정", description = "사용자가 자신의 정보를 수정합니다. 닉네임과 칭호(Alias)를 수정할 수 있습니다." diff --git a/src/main/java/konkuk/thip/user/adapter/in/web/UserQueryController.java b/src/main/java/konkuk/thip/user/adapter/in/web/UserQueryController.java index 4444d99d6..539bf9629 100644 --- a/src/main/java/konkuk/thip/user/adapter/in/web/UserQueryController.java +++ b/src/main/java/konkuk/thip/user/adapter/in/web/UserQueryController.java @@ -38,6 +38,7 @@ public class UserQueryController { private final UserViewAliasChoiceUseCase userViewAliasChoiceUseCase; private final UserGetFollowUsecase userGetFollowUsecase; + private final UserGetBlockedUsersUseCase userGetBlockedUsersUseCase; private final UserIsFollowingUsecase userIsFollowingUsecase; private final UserVerifyNicknameUseCase userVerifyNicknameUseCase; private final UserSearchUsecase userSearchUsecase; @@ -95,6 +96,20 @@ public BaseResponse showMyFollowing( return BaseResponse.ok(userGetFollowUsecase.getMyFollowing(userId, cursor, size)); } + @Operation( + summary = "내가 차단한 사용자 목록 조회", + description = "내가 차단한 사용자 목록을 조회합니다. 차단 해제를 위해 대상을 식별해야 하므로 이 목록에는 차단 필터를 적용하지 않습니다." + ) + @ExceptionDescription(GET_BLOCKED_USERS) + @GetMapping("/users/blocks") + public BaseResponse showBlockedUsers( + @Parameter(hidden = true) @UserId final Long userId, + @Parameter(description = "커서") @RequestParam(required = false) final String cursor, + @Parameter(description = "단일 요청 페이지 크기 (1~10)") + @RequestParam(defaultValue = "10") @Max(value = 10) @Min(value = 1) final int size) { + return BaseResponse.ok(userGetBlockedUsersUseCase.getBlockedUsers(userId, cursor, size)); + } + @Deprecated @Operation( summary = "팔로잉 여부 조회", diff --git a/src/main/java/konkuk/thip/user/adapter/in/web/request/UserBlockRequest.java b/src/main/java/konkuk/thip/user/adapter/in/web/request/UserBlockRequest.java new file mode 100644 index 000000000..aeaa5a36b --- /dev/null +++ b/src/main/java/konkuk/thip/user/adapter/in/web/request/UserBlockRequest.java @@ -0,0 +1,16 @@ +package konkuk.thip.user.adapter.in.web.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import konkuk.thip.user.application.port.in.dto.UserBlockCommand; + +@Schema(description = "사용자 차단 상태 변경 요청 DTO") +public record UserBlockRequest( + @Schema(description = "true -> 차단, false -> 차단 해제", example = "true") + @NotNull(message = "type은 필수 파라미터입니다.") + Boolean type +) { + public UserBlockCommand toCommand(Long userId, Long targetUserId) { + return new UserBlockCommand(userId, targetUserId, type); + } +} diff --git a/src/main/java/konkuk/thip/user/adapter/in/web/response/UserBlockResponse.java b/src/main/java/konkuk/thip/user/adapter/in/web/response/UserBlockResponse.java new file mode 100644 index 000000000..a6a490e3e --- /dev/null +++ b/src/main/java/konkuk/thip/user/adapter/in/web/response/UserBlockResponse.java @@ -0,0 +1,9 @@ +package konkuk.thip.user.adapter.in.web.response; + +public record UserBlockResponse( + boolean isBlocked +) { + public static UserBlockResponse of(boolean isBlocked) { + return new UserBlockResponse(isBlocked); + } +} diff --git a/src/main/java/konkuk/thip/user/adapter/in/web/response/UserBlockedListResponse.java b/src/main/java/konkuk/thip/user/adapter/in/web/response/UserBlockedListResponse.java new file mode 100644 index 000000000..7601fae48 --- /dev/null +++ b/src/main/java/konkuk/thip/user/adapter/in/web/response/UserBlockedListResponse.java @@ -0,0 +1,23 @@ +package konkuk.thip.user.adapter.in.web.response; + +import lombok.Builder; + +import java.util.List; + +@Builder +public record UserBlockedListResponse( + List blockedUsers, + Integer totalBlockedUserCount, + String nextCursor, + boolean isLast +) { + @Builder + public record BlockedUserDto( + Long userId, + String nickname, + String profileImageUrl, + String aliasName, + String aliasColor + ) { + } +} diff --git a/src/main/java/konkuk/thip/user/adapter/out/jpa/UserBlockJpaEntity.java b/src/main/java/konkuk/thip/user/adapter/out/jpa/UserBlockJpaEntity.java new file mode 100644 index 000000000..c9c7dd1de --- /dev/null +++ b/src/main/java/konkuk/thip/user/adapter/out/jpa/UserBlockJpaEntity.java @@ -0,0 +1,35 @@ +package konkuk.thip.user.adapter.out.jpa; + +import jakarta.persistence.*; +import konkuk.thip.common.entity.BaseJpaEntity; +import lombok.*; + +@Entity +@Table( + name = "user_blocks", + uniqueConstraints = { + @UniqueConstraint( + name = "uq_user_blocks_user_target", + columnNames = {"user_id", "blocked_user_id"} + ) + } +) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class UserBlockJpaEntity extends BaseJpaEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "block_id") + private Long blockId; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id", nullable = false) + private UserJpaEntity userJpaEntity; // 차단한 유저 + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "blocked_user_id", nullable = false) + private UserJpaEntity blockedUserJpaEntity; // 차단당한 유저 +} diff --git a/src/main/java/konkuk/thip/user/adapter/out/mapper/UserBlockMapper.java b/src/main/java/konkuk/thip/user/adapter/out/mapper/UserBlockMapper.java new file mode 100644 index 000000000..c837b4701 --- /dev/null +++ b/src/main/java/konkuk/thip/user/adapter/out/mapper/UserBlockMapper.java @@ -0,0 +1,28 @@ +package konkuk.thip.user.adapter.out.mapper; + +import konkuk.thip.user.adapter.out.jpa.UserBlockJpaEntity; +import konkuk.thip.user.adapter.out.jpa.UserJpaEntity; +import konkuk.thip.user.domain.UserBlock; +import org.springframework.stereotype.Component; + +@Component +public class UserBlockMapper { + + public UserBlockJpaEntity toJpaEntity(UserJpaEntity userJpaEntity, UserJpaEntity blockedUserJpaEntity) { + return UserBlockJpaEntity.builder() + .userJpaEntity(userJpaEntity) + .blockedUserJpaEntity(blockedUserJpaEntity) + .build(); + } + + public UserBlock toDomainEntity(UserBlockJpaEntity userBlockJpaEntity) { + return UserBlock.builder() + .id(userBlockJpaEntity.getBlockId()) + .userId(userBlockJpaEntity.getUserJpaEntity().getUserId()) + .blockedUserId(userBlockJpaEntity.getBlockedUserJpaEntity().getUserId()) + .createdAt(userBlockJpaEntity.getCreatedAt()) + .modifiedAt(userBlockJpaEntity.getModifiedAt()) + .status(userBlockJpaEntity.getStatus()) + .build(); + } +} diff --git a/src/main/java/konkuk/thip/user/adapter/out/persistence/FollowingQueryPersistenceAdapter.java b/src/main/java/konkuk/thip/user/adapter/out/persistence/FollowingQueryPersistenceAdapter.java index bff694526..6b5bf922a 100644 --- a/src/main/java/konkuk/thip/user/adapter/out/persistence/FollowingQueryPersistenceAdapter.java +++ b/src/main/java/konkuk/thip/user/adapter/out/persistence/FollowingQueryPersistenceAdapter.java @@ -21,24 +21,26 @@ public class FollowingQueryPersistenceAdapter implements FollowingQueryPort { private final FollowingJpaRepository followingJpaRepository; @Override - public CursorBasedList getFollowersByUserId(Long userId, String cursor, int size) { + public CursorBasedList getFollowersByUserId(Long userId, String cursor, int size, Long viewerId) { LocalDateTime cursorVal = cursor != null && !cursor.isBlank() ? DateUtil.parseDateTime(cursor) : null; List followerDtos = followingJpaRepository.findFollowerDtosByUserIdBeforeCreatedAt( userId, cursorVal, - size + size, + viewerId ); return CursorBasedList.of(followerDtos, size, followerDto -> followerDto.createdAt().toString()); } @Override - public CursorBasedList getFollowingByUserId(Long userId, String cursor, int size) { + public CursorBasedList getFollowingByUserId(Long userId, String cursor, int size, Long viewerId) { LocalDateTime cursorVal = cursor != null && !cursor.isBlank() ? DateUtil.parseDateTime(cursor) : null; List followingDtos = followingJpaRepository.findFollowingDtosByUserIdBeforeCreatedAt( userId, cursorVal, - size + size, + viewerId ); return CursorBasedList.of(followingDtos, size, followingDto -> followingDto.createdAt().toString()); diff --git a/src/main/java/konkuk/thip/user/adapter/out/persistence/UserBlockCommandPersistenceAdapter.java b/src/main/java/konkuk/thip/user/adapter/out/persistence/UserBlockCommandPersistenceAdapter.java new file mode 100644 index 000000000..33644e19d --- /dev/null +++ b/src/main/java/konkuk/thip/user/adapter/out/persistence/UserBlockCommandPersistenceAdapter.java @@ -0,0 +1,57 @@ +package konkuk.thip.user.adapter.out.persistence; + +import konkuk.thip.common.exception.EntityNotFoundException; +import konkuk.thip.user.adapter.out.jpa.UserBlockJpaEntity; +import konkuk.thip.user.adapter.out.jpa.UserJpaEntity; +import konkuk.thip.user.adapter.out.mapper.UserBlockMapper; +import konkuk.thip.user.adapter.out.persistence.repository.UserJpaRepository; +import konkuk.thip.user.adapter.out.persistence.repository.block.UserBlockJpaRepository; +import konkuk.thip.user.application.port.out.UserBlockCommandPort; +import konkuk.thip.user.domain.UserBlock; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +import static konkuk.thip.common.exception.code.ErrorCode.BLOCK_NOT_FOUND; +import static konkuk.thip.common.exception.code.ErrorCode.USER_NOT_FOUND; + +@Repository +@RequiredArgsConstructor +public class UserBlockCommandPersistenceAdapter implements UserBlockCommandPort { + + private final UserBlockJpaRepository userBlockJpaRepository; + private final UserJpaRepository userJpaRepository; + + private final UserBlockMapper userBlockMapper; + + @Override + public Optional findByUserIdAndTargetUserId(Long userId, Long targetUserId) { + return userBlockJpaRepository.findByUserAndBlockedUser(userId, targetUserId) + .map(userBlockMapper::toDomainEntity); + } + + @Override + public void save(UserBlock userBlock) { + UserJpaEntity userJpaEntity = userJpaRepository.findByUserId(userBlock.getUserId()) + .orElseThrow(() -> new EntityNotFoundException(USER_NOT_FOUND)); + UserJpaEntity blockedUserJpaEntity = userJpaRepository.findByUserId(userBlock.getBlockedUserId()) + .orElseThrow(() -> new EntityNotFoundException(USER_NOT_FOUND)); + + userBlockJpaRepository.save(userBlockMapper.toJpaEntity(userJpaEntity, blockedUserJpaEntity)); + } + + @Override + public void deleteBlock(UserBlock userBlock) { + UserBlockJpaEntity userBlockJpaEntity = userBlockJpaRepository + .findByUserAndBlockedUser(userBlock.getUserId(), userBlock.getBlockedUserId()) + .orElseThrow(() -> new EntityNotFoundException(BLOCK_NOT_FOUND)); + + userBlockJpaRepository.delete(userBlockJpaEntity); + } + + @Override + public void deleteAllByUserId(Long userId) { + userBlockJpaRepository.deleteAllByUserIdOrBlockedUserId(userId); + } +} diff --git a/src/main/java/konkuk/thip/user/adapter/out/persistence/UserBlockQueryPersistenceAdapter.java b/src/main/java/konkuk/thip/user/adapter/out/persistence/UserBlockQueryPersistenceAdapter.java new file mode 100644 index 000000000..cacd3d0f3 --- /dev/null +++ b/src/main/java/konkuk/thip/user/adapter/out/persistence/UserBlockQueryPersistenceAdapter.java @@ -0,0 +1,50 @@ +package konkuk.thip.user.adapter.out.persistence; + +import konkuk.thip.common.util.Cursor; +import konkuk.thip.common.util.CursorBasedList; +import konkuk.thip.user.adapter.out.persistence.repository.block.UserBlockJpaRepository; +import konkuk.thip.user.application.port.out.UserBlockQueryPort; +import konkuk.thip.user.application.port.out.dto.BlockedUserQueryDto; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Set; + +@Repository +@RequiredArgsConstructor +public class UserBlockQueryPersistenceAdapter implements UserBlockQueryPort { + + private final UserBlockJpaRepository userBlockJpaRepository; + + @Override + public CursorBasedList getBlockedUsersByUserId(Long userId, String cursorStr, int size) { + Cursor cursor = Cursor.from(cursorStr, size); + + LocalDateTime cursorCreatedAt = cursor.isFirstRequest() ? null : cursor.getLocalDateTime(0); + Long cursorBlockId = cursor.isFirstRequest() ? null : cursor.getLong(1); + + List blockedUserDtos = userBlockJpaRepository.findBlockedUserDtosByUserId( + userId, cursorCreatedAt, cursorBlockId, size + ); + + return CursorBasedList.of(blockedUserDtos, size, + dto -> new Cursor(List.of(dto.createdAt().toString(), dto.blockId().toString())).toEncodedString()); + } + + @Override + public int getBlockedUserCountByUserId(Long userId) { + return userBlockJpaRepository.countBlockedUsersByUserId(userId); + } + + @Override + public boolean existsBlockBetween(Long userId, Long targetUserId) { + return userBlockJpaRepository.existsBlockBetween(userId, targetUserId); + } + + @Override + public Set findBlockedUserIdsBothWays(Long userId) { + return userBlockJpaRepository.findBlockedUserIdsBothWays(userId); + } +} diff --git a/src/main/java/konkuk/thip/user/adapter/out/persistence/expression/BlockFilterExpressions.java b/src/main/java/konkuk/thip/user/adapter/out/persistence/expression/BlockFilterExpressions.java new file mode 100644 index 000000000..5e9b225e7 --- /dev/null +++ b/src/main/java/konkuk/thip/user/adapter/out/persistence/expression/BlockFilterExpressions.java @@ -0,0 +1,38 @@ +package konkuk.thip.user.adapter.out.persistence.expression; + +import com.querydsl.core.types.dsl.BooleanExpression; +import com.querydsl.core.types.dsl.NumberPath; +import com.querydsl.jpa.JPAExpressions; +import konkuk.thip.user.adapter.out.jpa.QUserBlockJpaEntity; + +// 차단한 사용자의 콘텐츠를 목록에서 숨기기 위한 QueryDSL 조건. 차단은 양방향으로 적용된다. +public final class BlockFilterExpressions { + + // 바깥 쿼리와의 별칭 충돌 방지 + private static final QUserBlockJpaEntity BLOCK = new QUserBlockJpaEntity("blockFilter"); + + private BlockFilterExpressions() { + } + + // viewerId 가 null 이면 조건을 걸지 않는다 (Querydsl 에서 null 조건은 무시됨) + public static BooleanExpression notBlockedWith(NumberPath authorUserIdPath, Long viewerId) { + if (viewerId == null) { + return null; + } + return blockedPairExists(authorUserIdPath, viewerId).not(); + } + + // 내가 상대를 차단했거나, 상대가 나를 차단한 경우 + private static BooleanExpression blockedPairExists(NumberPath otherUserIdPath, Long viewerId) { + return JPAExpressions + .selectOne() + .from(BLOCK) + .where( + BLOCK.userJpaEntity.userId.eq(viewerId) + .and(BLOCK.blockedUserJpaEntity.userId.eq(otherUserIdPath)) + .or(BLOCK.userJpaEntity.userId.eq(otherUserIdPath) + .and(BLOCK.blockedUserJpaEntity.userId.eq(viewerId))) + ) + .exists(); + } +} diff --git a/src/main/java/konkuk/thip/user/adapter/out/persistence/repository/UserQueryRepositoryImpl.java b/src/main/java/konkuk/thip/user/adapter/out/persistence/repository/UserQueryRepositoryImpl.java index f9f44c25a..d6068445b 100644 --- a/src/main/java/konkuk/thip/user/adapter/out/persistence/repository/UserQueryRepositoryImpl.java +++ b/src/main/java/konkuk/thip/user/adapter/out/persistence/repository/UserQueryRepositoryImpl.java @@ -18,6 +18,8 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; +import static konkuk.thip.user.adapter.out.persistence.expression.BlockFilterExpressions.notBlockedWith; + import java.time.LocalDateTime; import java.util.Comparator; import java.util.HashSet; @@ -69,7 +71,8 @@ public List findUsersByNicknameOrderByAccuracy(String keyword, Lon )) .from(user) .where(user.nickname.like(pattern) - .and(user.userId.ne(userId))) + .and(user.userId.ne(userId)), + notBlockedWith(user.userId, userId)) .orderBy(priority.desc(), user.nickname.asc()) .limit(size) .fetch(); @@ -83,6 +86,8 @@ public List findLikeByUserId(Long userId, LocalDateTime cursor BooleanBuilder where = new BooleanBuilder(); where.and(user.userId.eq(userId)); + // 내가 좋아요한 글이라도 작성자가 차단 관계면 숨긴다 + where.and(notBlockedWith(post.userJpaEntity.userId, userId)); if (cursorLocalDateTime != null) { where.and(postLike.createdAt.lt(cursorLocalDateTime)); @@ -115,6 +120,8 @@ public List findCommentByUserId(Long userId, LocalDateTime cur BooleanBuilder where = new BooleanBuilder(); where.and(user.userId.eq(userId)); + // 내가 댓글 단 글이라도 작성자가 차단 관계면 숨긴다 + where.and(notBlockedWith(post.userJpaEntity.userId, userId)); if (cursorLocalDateTime != null) { where.and(comment.createdAt.lt(cursorLocalDateTime)); diff --git a/src/main/java/konkuk/thip/user/adapter/out/persistence/repository/block/UserBlockJpaRepository.java b/src/main/java/konkuk/thip/user/adapter/out/persistence/repository/block/UserBlockJpaRepository.java new file mode 100644 index 000000000..fcc03ac26 --- /dev/null +++ b/src/main/java/konkuk/thip/user/adapter/out/persistence/repository/block/UserBlockJpaRepository.java @@ -0,0 +1,25 @@ +package konkuk.thip.user.adapter.out.persistence.repository.block; + +import konkuk.thip.user.adapter.out.jpa.UserBlockJpaEntity; +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; + +@Repository +public interface UserBlockJpaRepository extends JpaRepository, UserBlockQueryRepository { + + @Query("SELECT COUNT(b) > 0 FROM UserBlockJpaEntity b " + + "WHERE (b.userJpaEntity.userId = :userId AND b.blockedUserJpaEntity.userId = :targetUserId) " + + "OR (b.userJpaEntity.userId = :targetUserId AND b.blockedUserJpaEntity.userId = :userId)") + boolean existsBlockBetween(@Param("userId") Long userId, @Param("targetUserId") Long targetUserId); + + @Query("SELECT COUNT(b) FROM UserBlockJpaEntity b WHERE b.userJpaEntity.userId = :userId") + int countBlockedUsersByUserId(@Param("userId") Long userId); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query("DELETE FROM UserBlockJpaEntity b " + + "WHERE b.userJpaEntity.userId = :userId OR b.blockedUserJpaEntity.userId = :userId") + void deleteAllByUserIdOrBlockedUserId(@Param("userId") Long userId); +} diff --git a/src/main/java/konkuk/thip/user/adapter/out/persistence/repository/block/UserBlockQueryRepository.java b/src/main/java/konkuk/thip/user/adapter/out/persistence/repository/block/UserBlockQueryRepository.java new file mode 100644 index 000000000..8be608a73 --- /dev/null +++ b/src/main/java/konkuk/thip/user/adapter/out/persistence/repository/block/UserBlockQueryRepository.java @@ -0,0 +1,20 @@ +package konkuk.thip.user.adapter.out.persistence.repository.block; + +import konkuk.thip.user.adapter.out.jpa.UserBlockJpaEntity; +import konkuk.thip.user.application.port.out.dto.BlockedUserQueryDto; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +public interface UserBlockQueryRepository { + + Optional findByUserAndBlockedUser(Long userId, Long blockedUserId); + + // 해제하려면 대상을 식별해야 하므로 차단 필터를 적용하지 않는다 + List findBlockedUserDtosByUserId(Long userId, LocalDateTime cursorCreatedAt, Long cursorBlockId, int size); + + // QueryDSL 로 필터링할 수 없는 조회에서 사용한다 + Set findBlockedUserIdsBothWays(Long userId); +} diff --git a/src/main/java/konkuk/thip/user/adapter/out/persistence/repository/block/UserBlockQueryRepositoryImpl.java b/src/main/java/konkuk/thip/user/adapter/out/persistence/repository/block/UserBlockQueryRepositoryImpl.java new file mode 100644 index 000000000..015d8ea43 --- /dev/null +++ b/src/main/java/konkuk/thip/user/adapter/out/persistence/repository/block/UserBlockQueryRepositoryImpl.java @@ -0,0 +1,89 @@ +package konkuk.thip.user.adapter.out.persistence.repository.block; + +import com.querydsl.core.BooleanBuilder; +import com.querydsl.jpa.impl.JPAQueryFactory; +import konkuk.thip.user.adapter.out.jpa.QUserBlockJpaEntity; +import konkuk.thip.user.adapter.out.jpa.QUserJpaEntity; +import konkuk.thip.user.adapter.out.jpa.UserBlockJpaEntity; +import konkuk.thip.user.application.port.out.dto.BlockedUserQueryDto; +import konkuk.thip.user.application.port.out.dto.QBlockedUserQueryDto; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.HashSet; +import java.util.List; +import java.util.Optional; +import java.util.Set; + +@Repository +@RequiredArgsConstructor +public class UserBlockQueryRepositoryImpl implements UserBlockQueryRepository { + + private final JPAQueryFactory jpaQueryFactory; + + @Override + public Optional findByUserAndBlockedUser(Long userId, Long blockedUserId) { + QUserBlockJpaEntity block = QUserBlockJpaEntity.userBlockJpaEntity; + + UserBlockJpaEntity userBlockJpaEntity = jpaQueryFactory + .selectFrom(block) + .where(block.userJpaEntity.userId.eq(userId) + .and(block.blockedUserJpaEntity.userId.eq(blockedUserId))) + .fetchOne(); + + return Optional.ofNullable(userBlockJpaEntity); + } + + @Override + public List findBlockedUserDtosByUserId(Long userId, LocalDateTime cursorCreatedAt, Long cursorBlockId, int size) { + QUserBlockJpaEntity block = QUserBlockJpaEntity.userBlockJpaEntity; + QUserJpaEntity blockedUser = QUserJpaEntity.userJpaEntity; + + BooleanBuilder condition = new BooleanBuilder() + .and(block.userJpaEntity.userId.eq(userId)); + + // 같은 시각에 차단된 항목이 페이지 경계에서 누락되지 않도록 (createdAt, blockId) 복합 커서를 사용한다 + if (cursorCreatedAt != null && cursorBlockId != null) { + condition.and(block.createdAt.lt(cursorCreatedAt) + .or(block.createdAt.eq(cursorCreatedAt) + .and(block.blockId.lt(cursorBlockId)))); + } + + return jpaQueryFactory + .select(new QBlockedUserQueryDto( + blockedUser.userId, + blockedUser.nickname, + blockedUser.alias, + block.blockId, + block.createdAt + )) + .from(block) + .join(block.blockedUserJpaEntity, blockedUser) + .where(condition) + .orderBy(block.createdAt.desc(), block.blockId.desc()) + .limit(size + 1) + .fetch(); + } + + @Override + public Set findBlockedUserIdsBothWays(Long userId) { + QUserBlockJpaEntity block = QUserBlockJpaEntity.userBlockJpaEntity; + + List blockedByMe = jpaQueryFactory + .select(block.blockedUserJpaEntity.userId) + .from(block) + .where(block.userJpaEntity.userId.eq(userId)) + .fetch(); + + List blockedMe = jpaQueryFactory + .select(block.userJpaEntity.userId) + .from(block) + .where(block.blockedUserJpaEntity.userId.eq(userId)) + .fetch(); + + Set blockedUserIds = new HashSet<>(blockedByMe); + blockedUserIds.addAll(blockedMe); + return blockedUserIds; + } +} diff --git a/src/main/java/konkuk/thip/user/adapter/out/persistence/repository/following/FollowingQueryRepository.java b/src/main/java/konkuk/thip/user/adapter/out/persistence/repository/following/FollowingQueryRepository.java index a2d8cd7fa..7665bcdc9 100644 --- a/src/main/java/konkuk/thip/user/adapter/out/persistence/repository/following/FollowingQueryRepository.java +++ b/src/main/java/konkuk/thip/user/adapter/out/persistence/repository/following/FollowingQueryRepository.java @@ -12,8 +12,8 @@ public interface FollowingQueryRepository { Optional findByUserAndTargetUser(Long userId, Long targetUserId); - List findFollowerDtosByUserIdBeforeCreatedAt(Long userId, LocalDateTime cursor, int size); - List findFollowingDtosByUserIdBeforeCreatedAt(Long userId, LocalDateTime cursor, int size); + List findFollowerDtosByUserIdBeforeCreatedAt(Long userId, LocalDateTime cursor, int size, Long viewerId); + List findFollowingDtosByUserIdBeforeCreatedAt(Long userId, LocalDateTime cursor, int size, Long viewerId); List findLatestFollowers(Long userId, int size); diff --git a/src/main/java/konkuk/thip/user/adapter/out/persistence/repository/following/FollowingQueryRepositoryImpl.java b/src/main/java/konkuk/thip/user/adapter/out/persistence/repository/following/FollowingQueryRepositoryImpl.java index 1a2032800..1dc18337e 100644 --- a/src/main/java/konkuk/thip/user/adapter/out/persistence/repository/following/FollowingQueryRepositoryImpl.java +++ b/src/main/java/konkuk/thip/user/adapter/out/persistence/repository/following/FollowingQueryRepositoryImpl.java @@ -13,6 +13,8 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; +import static konkuk.thip.user.adapter.out.persistence.expression.BlockFilterExpressions.notBlockedWith; + import java.time.LocalDateTime; import java.util.List; import java.util.Optional; @@ -37,26 +39,29 @@ public Optional findByUserAndTargetUser(Long userId, Long ta } @Override - public List findFollowerDtosByUserIdBeforeCreatedAt(Long userId, LocalDateTime cursor, int size) { + public List findFollowerDtosByUserIdBeforeCreatedAt(Long userId, LocalDateTime cursor, int size, Long viewerId) { return findFollowDtos( userId, cursor, size, - true // isFollowerQuery + true, // isFollowerQuery + viewerId ); } @Override - public List findFollowingDtosByUserIdBeforeCreatedAt(Long userId, LocalDateTime cursor, int size) { + public List findFollowingDtosByUserIdBeforeCreatedAt(Long userId, LocalDateTime cursor, int size, Long viewerId) { return findFollowDtos( userId, cursor, size, - false // isFollowingQuery + false, // isFollowingQuery + viewerId ); } - private List findFollowDtos(Long userId, LocalDateTime cursor, int size, boolean isFollowerQuery) { + // userId 는 목록의 주인, viewerId 는 조회 주체. 제3자의 팔로우 목록을 볼 때 서로 다르다. + private List findFollowDtos(Long userId, LocalDateTime cursor, int size, boolean isFollowerQuery, Long viewerId) { QFollowingJpaEntity following = QFollowingJpaEntity.followingJpaEntity; QUserJpaEntity user = QUserJpaEntity.userJpaEntity; @@ -71,6 +76,8 @@ private List findFollowDtos(Long userId, LocalDateTime cursor, int QUserJpaEntity targetUser = isFollowerQuery ? following.userJpaEntity : following.followingUserJpaEntity; + condition.and(notBlockedWith(targetUser.userId, viewerId)); + return jpaQueryFactory .select(new QUserQueryDto( targetUser.userId, diff --git a/src/main/java/konkuk/thip/user/application/mapper/BlockQueryMapper.java b/src/main/java/konkuk/thip/user/application/mapper/BlockQueryMapper.java new file mode 100644 index 000000000..deed11038 --- /dev/null +++ b/src/main/java/konkuk/thip/user/application/mapper/BlockQueryMapper.java @@ -0,0 +1,15 @@ +package konkuk.thip.user.application.mapper; + +import konkuk.thip.user.adapter.in.web.response.UserBlockedListResponse; +import konkuk.thip.user.application.port.out.dto.BlockedUserQueryDto; +import org.mapstruct.Mapper; + +import java.util.List; + +@Mapper(componentModel = "spring") +public interface BlockQueryMapper { + + UserBlockedListResponse.BlockedUserDto toBlockedUserDto(BlockedUserQueryDto dto); + + List toBlockedUserDtoList(List dtos); +} diff --git a/src/main/java/konkuk/thip/user/application/port/in/UserBlockUseCase.java b/src/main/java/konkuk/thip/user/application/port/in/UserBlockUseCase.java new file mode 100644 index 000000000..144ed47bf --- /dev/null +++ b/src/main/java/konkuk/thip/user/application/port/in/UserBlockUseCase.java @@ -0,0 +1,8 @@ +package konkuk.thip.user.application.port.in; + +import konkuk.thip.user.application.port.in.dto.UserBlockCommand; + +public interface UserBlockUseCase { + + Boolean changeBlockState(UserBlockCommand blockCommand); +} diff --git a/src/main/java/konkuk/thip/user/application/port/in/UserGetBlockedUsersUseCase.java b/src/main/java/konkuk/thip/user/application/port/in/UserGetBlockedUsersUseCase.java new file mode 100644 index 000000000..a9b10a96f --- /dev/null +++ b/src/main/java/konkuk/thip/user/application/port/in/UserGetBlockedUsersUseCase.java @@ -0,0 +1,8 @@ +package konkuk.thip.user.application.port.in; + +import konkuk.thip.user.adapter.in.web.response.UserBlockedListResponse; + +public interface UserGetBlockedUsersUseCase { + + UserBlockedListResponse getBlockedUsers(Long userId, String cursor, int size); +} diff --git a/src/main/java/konkuk/thip/user/application/port/in/dto/UserBlockCommand.java b/src/main/java/konkuk/thip/user/application/port/in/dto/UserBlockCommand.java new file mode 100644 index 000000000..417769e34 --- /dev/null +++ b/src/main/java/konkuk/thip/user/application/port/in/dto/UserBlockCommand.java @@ -0,0 +1,4 @@ +package konkuk.thip.user.application.port.in.dto; + +public record UserBlockCommand(Long userId, Long targetUserId, Boolean type) { +} diff --git a/src/main/java/konkuk/thip/user/application/port/out/FollowingQueryPort.java b/src/main/java/konkuk/thip/user/application/port/out/FollowingQueryPort.java index 3597d83c0..cc87c4a12 100644 --- a/src/main/java/konkuk/thip/user/application/port/out/FollowingQueryPort.java +++ b/src/main/java/konkuk/thip/user/application/port/out/FollowingQueryPort.java @@ -7,8 +7,8 @@ import java.util.List; public interface FollowingQueryPort { - CursorBasedList getFollowersByUserId(Long userId, String cursor, int size); - CursorBasedList getFollowingByUserId(Long userId, String cursor, int size); + CursorBasedList getFollowersByUserId(Long userId, String cursor, int size, Long viewerId); + CursorBasedList getFollowingByUserId(Long userId, String cursor, int size, Long viewerId); List getLatestFollowerImageUrls(Long userId, int size); diff --git a/src/main/java/konkuk/thip/user/application/port/out/UserBlockCommandPort.java b/src/main/java/konkuk/thip/user/application/port/out/UserBlockCommandPort.java new file mode 100644 index 000000000..9b7ebcd57 --- /dev/null +++ b/src/main/java/konkuk/thip/user/application/port/out/UserBlockCommandPort.java @@ -0,0 +1,23 @@ +package konkuk.thip.user.application.port.out; + +import konkuk.thip.common.exception.EntityNotFoundException; +import konkuk.thip.common.exception.code.ErrorCode; +import konkuk.thip.user.domain.UserBlock; + +import java.util.Optional; + +public interface UserBlockCommandPort { + + Optional findByUserIdAndTargetUserId(Long userId, Long targetUserId); + + default UserBlock getByUserIdAndTargetUserIdOrThrow(Long userId, Long targetUserId) { + return findByUserIdAndTargetUserId(userId, targetUserId) + .orElseThrow(() -> new EntityNotFoundException(ErrorCode.BLOCK_NOT_FOUND)); + } + + void save(UserBlock userBlock); + + void deleteBlock(UserBlock userBlock); + + void deleteAllByUserId(Long userId); +} diff --git a/src/main/java/konkuk/thip/user/application/port/out/UserBlockQueryPort.java b/src/main/java/konkuk/thip/user/application/port/out/UserBlockQueryPort.java new file mode 100644 index 000000000..4b0cc9347 --- /dev/null +++ b/src/main/java/konkuk/thip/user/application/port/out/UserBlockQueryPort.java @@ -0,0 +1,18 @@ +package konkuk.thip.user.application.port.out; + +import konkuk.thip.common.util.CursorBasedList; +import konkuk.thip.user.application.port.out.dto.BlockedUserQueryDto; + +import java.util.Set; + +public interface UserBlockQueryPort { + + CursorBasedList getBlockedUsersByUserId(Long userId, String cursor, int size); + + int getBlockedUserCountByUserId(Long userId); + + boolean existsBlockBetween(Long userId, Long targetUserId); + + // QueryDSL 로 필터링할 수 없는 조회에서 사용한다 + Set findBlockedUserIdsBothWays(Long userId); +} diff --git a/src/main/java/konkuk/thip/user/application/port/out/dto/BlockedUserQueryDto.java b/src/main/java/konkuk/thip/user/application/port/out/dto/BlockedUserQueryDto.java new file mode 100644 index 000000000..85455b989 --- /dev/null +++ b/src/main/java/konkuk/thip/user/application/port/out/dto/BlockedUserQueryDto.java @@ -0,0 +1,34 @@ +package konkuk.thip.user.application.port.out.dto; + +import com.querydsl.core.annotations.QueryProjection; +import konkuk.thip.user.domain.value.Alias; + +import java.time.LocalDateTime; + +public record BlockedUserQueryDto(Long userId, + String nickname, + String profileImageUrl, + String aliasName, + String aliasColor, + Long blockId, + LocalDateTime createdAt) { + + @QueryProjection + public BlockedUserQueryDto( + Long userId, + String nickname, + Alias userAlias, + Long blockId, + LocalDateTime createdAt + ) { + this( + userId, + nickname, + userAlias.getImageUrl(), + userAlias.getValue(), + userAlias.getColor(), + blockId, + createdAt + ); + } +} diff --git a/src/main/java/konkuk/thip/user/application/service/UserDeleteService.java b/src/main/java/konkuk/thip/user/application/service/UserDeleteService.java index 97fd361e8..078db4c9c 100644 --- a/src/main/java/konkuk/thip/user/application/service/UserDeleteService.java +++ b/src/main/java/konkuk/thip/user/application/service/UserDeleteService.java @@ -16,6 +16,7 @@ import konkuk.thip.user.application.port.UserTokenBlacklistCommandPort; import konkuk.thip.user.application.port.in.UserDeleteUseCase; import konkuk.thip.user.application.port.out.FollowingCommandPort; +import konkuk.thip.user.application.port.out.UserBlockCommandPort; import konkuk.thip.user.application.port.out.UserCommandPort; import konkuk.thip.user.domain.User; import lombok.RequiredArgsConstructor; @@ -31,6 +32,7 @@ public class UserDeleteService implements UserDeleteUseCase { private final UserCommandPort userCommandPort; private final UserJpaRepository userJpaRepository; private final FollowingCommandPort followingCommandPort; + private final UserBlockCommandPort userBlockCommandPort; private final AppleTokenClient appleTokenClient; private final FeedCommandPort feedCommandPort; private final BookCommandPort bookCommandPort; @@ -70,6 +72,8 @@ public void deleteUser(Long userId, String authToken) { // 3. 유저가 남긴 관련 정보들 삭제 // 팔로잉 관계 삭제 followingCommandPort.deleteAllByUserId(userId); + // 차단 관계 삭제 (내가 차단한 것 + 나를 차단한 것 모두) + userBlockCommandPort.deleteAllByUserId(userId); // 최근검색어 삭제 recentSearchCommandPort.deleteAllByUserId(userId); // 알림 삭제 // TODO 알림구현 적용되면 수정 diff --git a/src/main/java/konkuk/thip/user/application/service/block/UserBlockService.java b/src/main/java/konkuk/thip/user/application/service/block/UserBlockService.java new file mode 100644 index 000000000..d7efad551 --- /dev/null +++ b/src/main/java/konkuk/thip/user/application/service/block/UserBlockService.java @@ -0,0 +1,107 @@ +package konkuk.thip.user.application.service.block; + +import konkuk.thip.common.exception.BusinessException; +import konkuk.thip.common.exception.InvalidStateException; +import konkuk.thip.common.exception.code.ErrorCode; +import konkuk.thip.user.application.port.in.UserBlockUseCase; +import konkuk.thip.user.application.port.in.dto.UserBlockCommand; +import konkuk.thip.user.application.port.out.FollowingCommandPort; +import konkuk.thip.user.application.port.out.UserBlockCommandPort; +import konkuk.thip.user.application.port.out.UserCommandPort; +import konkuk.thip.user.domain.User; +import konkuk.thip.user.domain.UserBlock; +import lombok.RequiredArgsConstructor; +import org.springframework.retry.annotation.Backoff; +import org.springframework.retry.annotation.Recover; +import org.springframework.retry.annotation.Retryable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Map; + +import static konkuk.thip.common.exception.code.ErrorCode.USER_CANNOT_BLOCK_SELF; + +@Service +@RequiredArgsConstructor +public class UserBlockService implements UserBlockUseCase { + + private final UserBlockCommandPort userBlockCommandPort; + private final FollowingCommandPort followingCommandPort; + private final UserCommandPort userCommandPort; + + @Override + @Transactional + @Retryable( + notRecoverable = { + BusinessException.class, + InvalidStateException.class + }, + noRetryFor = { + BusinessException.class, + InvalidStateException.class + }, + maxAttempts = 3, + backoff = @Backoff(delay = 100, maxDelay = 500, multiplier = 2) + ) + public Boolean changeBlockState(UserBlockCommand blockCommand) { + Long userId = blockCommand.userId(); + Long targetUserId = blockCommand.targetUserId(); + Boolean type = blockCommand.type(); + + validateParams(userId, targetUserId); + + boolean isExistingBlock = userBlockCommandPort.findByUserIdAndTargetUserId(userId, targetUserId).isPresent(); + boolean isBlockRequest = UserBlock.validateBlockState(isExistingBlock, type); + + if (!isBlockRequest) { // 차단 해제 요청인 경우 : 팔로우 관계는 복구하지 않는다 + userBlockCommandPort.deleteBlock(UserBlock.withoutId(userId, targetUserId)); + return false; + } + + // 차단 요청인 경우 : 대상 유저가 실제로 존재하는지 확인하고, 양방향 팔로우를 해제한다 + Map lockedUsers = lockUsersInIdOrder(userId, targetUserId); + unfollowBothWays(userId, targetUserId, lockedUsers); + + userBlockCommandPort.save(UserBlock.withoutId(userId, targetUserId)); + return true; + } + + @Recover + public Boolean recoverChangeBlockState(Exception e, UserBlockCommand blockCommand) { + throw new BusinessException(ErrorCode.RESOURCE_LOCKED); + } + + // 데드락 방지 : 항상 userId 오름차순으로 락을 획득한다 + private Map lockUsersInIdOrder(Long userId, Long targetUserId) { + Long first = Math.min(userId, targetUserId); + Long second = Math.max(userId, targetUserId); + + User firstUser = userCommandPort.findByIdWithLock(first); + User secondUser = userCommandPort.findByIdWithLock(second); + + return Map.of(first, firstUser, second, secondUser); + } + + // 팔로우 관계는 락을 잡은 뒤 조회한다. 락 이전에 읽으면 이미 사라진 관계의 followerCount 를 감소시킬 수 있다. + private void unfollowBothWays(Long userId, Long targetUserId, Map lockedUsers) { + followingCommandPort.findByUserIdAndTargetUserId(userId, targetUserId) + .ifPresent(following -> { + User targetUser = lockedUsers.get(targetUserId); + targetUser.decreaseFollowerCount(); + followingCommandPort.deleteFollowing(following, targetUser); + }); + + followingCommandPort.findByUserIdAndTargetUserId(targetUserId, userId) + .ifPresent(following -> { + User user = lockedUsers.get(userId); + user.decreaseFollowerCount(); + followingCommandPort.deleteFollowing(following, user); + }); + } + + private void validateParams(Long userId, Long targetUserId) { + if (userId.equals(targetUserId)) { + throw new BusinessException(USER_CANNOT_BLOCK_SELF); + } + } +} diff --git a/src/main/java/konkuk/thip/user/application/service/block/UserGetBlockedUsersService.java b/src/main/java/konkuk/thip/user/application/service/block/UserGetBlockedUsersService.java new file mode 100644 index 000000000..3aa4f780a --- /dev/null +++ b/src/main/java/konkuk/thip/user/application/service/block/UserGetBlockedUsersService.java @@ -0,0 +1,45 @@ +package konkuk.thip.user.application.service.block; + +import konkuk.thip.common.util.CursorBasedList; +import konkuk.thip.user.adapter.in.web.response.UserBlockedListResponse; +import konkuk.thip.user.application.mapper.BlockQueryMapper; +import konkuk.thip.user.application.port.in.UserGetBlockedUsersUseCase; +import konkuk.thip.user.application.port.out.UserBlockQueryPort; +import konkuk.thip.user.application.port.out.UserCommandPort; +import konkuk.thip.user.application.port.out.dto.BlockedUserQueryDto; +import konkuk.thip.user.domain.User; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class UserGetBlockedUsersService implements UserGetBlockedUsersUseCase { + + private final UserBlockQueryPort userBlockQueryPort; + private final UserCommandPort userCommandPort; + + private final BlockQueryMapper blockQueryMapper; + + private static final int MAX_PAGE_SIZE = 10; + + @Override + @Transactional(readOnly = true) + public UserBlockedListResponse getBlockedUsers(Long userId, String cursor, int size) { + User user = userCommandPort.findById(userId); + + Integer totalBlockedUserCount = (cursor == null || cursor.isBlank()) ? + userBlockQueryPort.getBlockedUserCountByUserId(user.getId()) : null; + + CursorBasedList result = userBlockQueryPort.getBlockedUsersByUserId( + user.getId(), cursor, Math.min(size, MAX_PAGE_SIZE) + ); + + return UserBlockedListResponse.builder() + .blockedUsers(blockQueryMapper.toBlockedUserDtoList(result.contents())) + .totalBlockedUserCount(totalBlockedUserCount) + .nextCursor(result.nextCursor()) + .isLast(result.isLast()) + .build(); + } +} diff --git a/src/main/java/konkuk/thip/user/application/service/following/UserFollowService.java b/src/main/java/konkuk/thip/user/application/service/following/UserFollowService.java index c9a63b4f4..8d73b1643 100644 --- a/src/main/java/konkuk/thip/user/application/service/following/UserFollowService.java +++ b/src/main/java/konkuk/thip/user/application/service/following/UserFollowService.java @@ -7,6 +7,7 @@ import konkuk.thip.user.application.port.in.UserFollowUsecase; import konkuk.thip.user.application.port.in.dto.UserFollowCommand; import konkuk.thip.user.application.port.out.FollowingCommandPort; +import konkuk.thip.user.application.port.out.UserBlockQueryPort; import konkuk.thip.user.application.port.out.UserCommandPort; import konkuk.thip.user.domain.Following; import konkuk.thip.user.domain.User; @@ -19,6 +20,7 @@ import java.util.Optional; +import static konkuk.thip.common.exception.code.ErrorCode.USER_BLOCKED_CANNOT_INTERACT; import static konkuk.thip.common.exception.code.ErrorCode.USER_CANNOT_FOLLOW_SELF; @Service @@ -27,6 +29,7 @@ public class UserFollowService implements UserFollowUsecase { private final FollowingCommandPort followingCommandPort; private final UserCommandPort userCommandPort; + private final UserBlockQueryPort userBlockQueryPort; private final FeedNotificationOrchestrator feedNotificationOrchestrator; @@ -57,6 +60,7 @@ public Boolean changeFollowingState(UserFollowCommand followCommand) { boolean isFollowRequest = Following.validateFollowingState(optionalFollowing.isPresent(), type); if (isFollowRequest) { // 팔로우 요청인 경우 + validateNotBlocked(userId, targetUserId); targetUser.increaseFollowerCount(); followingCommandPort.save(Following.withoutId(userId, targetUserId), targetUser); @@ -85,4 +89,10 @@ private void validateParams(Long userId, Long targetUserId) { throw new BusinessException(USER_CANNOT_FOLLOW_SELF); } } + + private void validateNotBlocked(Long userId, Long targetUserId) { + if (userBlockQueryPort.existsBlockBetween(userId, targetUserId)) { + throw new BusinessException(USER_BLOCKED_CANNOT_INTERACT); + } + } } diff --git a/src/main/java/konkuk/thip/user/application/service/following/UserGetFollowService.java b/src/main/java/konkuk/thip/user/application/service/following/UserGetFollowService.java index 7c105a380..1dd4cf9fa 100644 --- a/src/main/java/konkuk/thip/user/application/service/following/UserGetFollowService.java +++ b/src/main/java/konkuk/thip/user/application/service/following/UserGetFollowService.java @@ -32,7 +32,7 @@ public UserFollowersResponse getUserFollowers(Long loginUserId, Long userId, Str user.getFollowerCount() : null; CursorBasedList result = followingQueryPort.getFollowersByUserId( - user.getId(), cursor, Math.min(size, MAX_PAGE_SIZE) + user.getId(), cursor, Math.min(size, MAX_PAGE_SIZE), loginUserId ); var followers = result.contents().stream() @@ -55,7 +55,7 @@ public UserFollowingResponse getMyFollowing(Long userId, String cursor, int size followingQueryPort.getFollowingCountByUser(user.getId()) : null; CursorBasedList result = followingQueryPort.getFollowingByUserId( - user.getId(), cursor, Math.min(size, MAX_PAGE_SIZE) + user.getId(), cursor, Math.min(size, MAX_PAGE_SIZE), userId ); var following = result.contents().stream() diff --git a/src/main/java/konkuk/thip/user/domain/UserBlock.java b/src/main/java/konkuk/thip/user/domain/UserBlock.java new file mode 100644 index 000000000..48d052225 --- /dev/null +++ b/src/main/java/konkuk/thip/user/domain/UserBlock.java @@ -0,0 +1,38 @@ +package konkuk.thip.user.domain; + +import konkuk.thip.common.entity.BaseDomainEntity; +import konkuk.thip.common.entity.StatusType; +import konkuk.thip.common.exception.InvalidStateException; +import lombok.Getter; +import lombok.experimental.SuperBuilder; + +import static konkuk.thip.common.exception.code.ErrorCode.USER_ALREADY_BLOCKED; +import static konkuk.thip.common.exception.code.ErrorCode.USER_ALREADY_UNBLOCKED; + +@Getter +@SuperBuilder +public class UserBlock extends BaseDomainEntity { + + private Long id; + + private Long userId; + + private Long blockedUserId; + + public static UserBlock withoutId(Long userId, Long blockedUserId) { + return UserBlock.builder() + .userId(userId) + .blockedUserId(blockedUserId) + .status(StatusType.ACTIVE) + .build(); + } + + public static boolean validateBlockState(boolean isExistingBlock, boolean isBlockRequest) { + if (isExistingBlock && isBlockRequest) { // 이미 차단한 상태에서 차단 요청을 하는 경우 + throw new InvalidStateException(USER_ALREADY_BLOCKED); + } else if (!isExistingBlock && !isBlockRequest) { // 차단 해제 요청인데 차단 관계가 존재하지 않는 경우 + throw new InvalidStateException(USER_ALREADY_UNBLOCKED); + } + return isBlockRequest; + } +} diff --git a/src/main/resources/db/migration/V260815__Create_user_blocks_table.sql b/src/main/resources/db/migration/V260815__Create_user_blocks_table.sql new file mode 100644 index 000000000..5000ef581 --- /dev/null +++ b/src/main/resources/db/migration/V260815__Create_user_blocks_table.sql @@ -0,0 +1,19 @@ +-- 사용자 차단 테이블. 차단 해제는 row 삭제(hard delete)로 처리한다. +CREATE TABLE user_blocks ( + block_id BIGINT AUTO_INCREMENT PRIMARY KEY, + user_id BIGINT NOT NULL, -- 차단한 사용자 + blocked_user_id BIGINT NOT NULL, -- 차단당한 사용자 + created_at DATETIME(6) NOT NULL, + modified_at DATETIME(6) NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE', + CONSTRAINT uq_user_blocks_user_target UNIQUE (user_id, blocked_user_id), + CONSTRAINT fk_user_blocks_user + FOREIGN KEY (user_id) REFERENCES users(user_id) + ON DELETE CASCADE, + CONSTRAINT fk_user_blocks_blocked_user + FOREIGN KEY (blocked_user_id) REFERENCES users(user_id) + ON DELETE CASCADE +); + +-- "나를 차단한 사용자" 역방향 조회용 +CREATE INDEX idx_user_blocks_reverse ON user_blocks (blocked_user_id, user_id); diff --git a/src/test/java/konkuk/thip/book/adapter/in/web/BookRecruitingRoomApiTest.java b/src/test/java/konkuk/thip/book/adapter/in/web/BookRecruitingRoomApiTest.java index 560eed86c..ea3df0230 100644 --- a/src/test/java/konkuk/thip/book/adapter/in/web/BookRecruitingRoomApiTest.java +++ b/src/test/java/konkuk/thip/book/adapter/in/web/BookRecruitingRoomApiTest.java @@ -107,6 +107,7 @@ void getRecruitingRoomsByIsbn_success() throws Exception { // when & then mockMvc.perform(get("/books/{isbn}/recruiting-rooms", book.getIsbn()) + .requestAttr("userId", user.getUserId()) .contentType(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) .andExpect(jsonPath("$.isSuccess").value(true)) @@ -121,6 +122,7 @@ void getRecruitingRoomsByIsbn_success() throws Exception { void getRecruitingRooms_no_matching_book() throws Exception { // when & then mockMvc.perform(get("/books/{isbn}/recruiting-rooms", "0987654321123") + .requestAttr("userId", 1L) .contentType(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) .andExpect(jsonPath("$.isSuccess").value(true)) @@ -149,6 +151,7 @@ void getRecruitingRoomsWithCursor_success() throws Exception { // when & then (1페이지 요청) MvcResult result = mockMvc.perform(get("/books/{isbn}/recruiting-rooms", isbn) + .requestAttr("userId", 1L) .param("cursor", (String) null)) .andExpect(status().isOk()) .andExpect(jsonPath("$.isSuccess").value(true)) @@ -164,6 +167,7 @@ void getRecruitingRoomsWithCursor_success() throws Exception { // when & then (2페이지 요청) mockMvc.perform(get("/books/{isbn}/recruiting-rooms", isbn) + .requestAttr("userId", 1L) .param("cursor", nextCursor)) .andExpect(status().isOk()) .andExpect(jsonPath("$.data.recruitingRoomList.length()").value(5)) // 나머지 5개 diff --git a/src/test/java/konkuk/thip/common/util/TestEntityFactory.java b/src/test/java/konkuk/thip/common/util/TestEntityFactory.java index a70d734f4..5a9757c88 100644 --- a/src/test/java/konkuk/thip/common/util/TestEntityFactory.java +++ b/src/test/java/konkuk/thip/common/util/TestEntityFactory.java @@ -25,6 +25,7 @@ import konkuk.thip.room.domain.value.RoomStatus; import konkuk.thip.roompost.adapter.out.jpa.*; import konkuk.thip.user.adapter.out.jpa.FollowingJpaEntity; +import konkuk.thip.user.adapter.out.jpa.UserBlockJpaEntity; import konkuk.thip.user.adapter.out.jpa.UserJpaEntity; import konkuk.thip.user.domain.value.UserRole; import konkuk.thip.user.domain.value.Alias; @@ -297,6 +298,13 @@ public static FollowingJpaEntity createFollowing(UserJpaEntity followerUser, Use .build(); } + public static UserBlockJpaEntity createUserBlock(UserJpaEntity user, UserJpaEntity blockedUser) { + return UserBlockJpaEntity.builder() + .userJpaEntity(user) + .blockedUserJpaEntity(blockedUser) + .build(); + } + /** * 공개/비공개 여부만을 설정하는 기본 피드 생성을 위한 팩토리 메서드 */ diff --git a/src/test/java/konkuk/thip/notification/application/service/FeedNotificationOrchestratorSyncImplUnitTest.java b/src/test/java/konkuk/thip/notification/application/service/FeedNotificationOrchestratorSyncImplUnitTest.java index 636df7797..e5f2dc61f 100644 --- a/src/test/java/konkuk/thip/notification/application/service/FeedNotificationOrchestratorSyncImplUnitTest.java +++ b/src/test/java/konkuk/thip/notification/application/service/FeedNotificationOrchestratorSyncImplUnitTest.java @@ -5,12 +5,15 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; +import konkuk.thip.user.application.port.out.UserBlockQueryPort; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @ExtendWith(MockitoExtension.class) @@ -19,6 +22,7 @@ class FeedNotificationOrchestratorSyncImplUnitTest { @Mock NotificationSyncExecutor notificationSyncExecutor; @Mock FeedEventCommandPort feedEventCommandPort; + @Mock UserBlockQueryPort userBlockQueryPort; @InjectMocks FeedNotificationOrchestratorSyncImpl sut; @@ -51,4 +55,19 @@ void notify_feed_commented_test() { "title", "content", 123L, targetUserId ); } + + @Test + @DisplayName("차단 관계면 알림을 만들지 않는다") + void suppress_notification_when_blocked() { + // given + Long targetUserId = 10L; + Long actorUserId = 20L; + given(userBlockQueryPort.existsBlockBetween(targetUserId, actorUserId)).willReturn(true); + + // when + sut.notifyFeedCommented(targetUserId, actorUserId, "alice", 99L); + + // then + verify(notificationSyncExecutor, never()).execute(any(), any(), any(), any(), any()); + } } diff --git a/src/test/java/konkuk/thip/notification/application/service/RoomNotificationOrchestratorSyncImplUnitTest.java b/src/test/java/konkuk/thip/notification/application/service/RoomNotificationOrchestratorSyncImplUnitTest.java index 906281b04..d5da980e5 100644 --- a/src/test/java/konkuk/thip/notification/application/service/RoomNotificationOrchestratorSyncImplUnitTest.java +++ b/src/test/java/konkuk/thip/notification/application/service/RoomNotificationOrchestratorSyncImplUnitTest.java @@ -6,12 +6,16 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; +import konkuk.thip.user.application.port.out.UserBlockQueryPort; import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; @ExtendWith(MockitoExtension.class) @@ -20,6 +24,7 @@ class RoomNotificationOrchestratorSyncImplUnitTest { @Mock NotificationSyncExecutor notificationSyncExecutor; @Mock RoomEventCommandPort roomEventCommandPort; + @Mock UserBlockQueryPort userBlockQueryPort; @InjectMocks RoomNotificationOrchestratorSyncImpl sut; @@ -52,4 +57,19 @@ void notify_room_post_commented() { "title", "content", 123L, targetUserId ); } + + @Test + @DisplayName("차단 관계면 알림을 만들지 않는다") + void suppress_notification_when_blocked() { + // given + Long targetUserId = 10L; + Long actorUserId = 20L; + given(userBlockQueryPort.existsBlockBetween(targetUserId, actorUserId)).willReturn(true); + + // when + sut.notifyRoomPostCommented(targetUserId, actorUserId, "alice", 1L, 2, 3L, PostType.RECORD); + + // then + verify(notificationSyncExecutor, never()).execute(any(), any(), any(), any(), any()); + } } diff --git a/src/test/java/konkuk/thip/room/application/service/RoomJoinServiceTest.java b/src/test/java/konkuk/thip/room/application/service/RoomJoinServiceTest.java index e2e040286..5642c2b54 100644 --- a/src/test/java/konkuk/thip/room/application/service/RoomJoinServiceTest.java +++ b/src/test/java/konkuk/thip/room/application/service/RoomJoinServiceTest.java @@ -8,6 +8,7 @@ import konkuk.thip.room.application.port.out.RoomParticipantCommandPort; import konkuk.thip.room.domain.Room; import konkuk.thip.room.domain.RoomParticipant; +import konkuk.thip.user.application.port.out.UserBlockQueryPort; import konkuk.thip.user.application.port.out.UserCommandPort; import konkuk.thip.user.domain.User; import konkuk.thip.user.domain.value.Alias; @@ -33,6 +34,7 @@ class RoomJoinServiceTest { private RoomJoinService roomJoinService; private UserCommandPort userCommandPort; private RoomNotificationOrchestratorSyncImpl roomNotificationOrchestratorSyncImpl; + private UserBlockQueryPort userBlockQueryPort; private final Long ROOM_ID = 1L; private final Long USER_ID = 2L; @@ -47,11 +49,13 @@ void setUp() { roomParticipantCommandPort = mock(RoomParticipantCommandPort.class); userCommandPort = mock(UserCommandPort.class); roomNotificationOrchestratorSyncImpl = mock(RoomNotificationOrchestratorSyncImpl.class); + userBlockQueryPort = mock(UserBlockQueryPort.class); roomJoinService = new RoomJoinService( roomCommandPort, roomParticipantCommandPort, userCommandPort, + userBlockQueryPort, roomNotificationOrchestratorSyncImpl ); } diff --git a/src/test/java/konkuk/thip/user/adapter/in/web/BlockContentHiddenApiTest.java b/src/test/java/konkuk/thip/user/adapter/in/web/BlockContentHiddenApiTest.java new file mode 100644 index 000000000..862691823 --- /dev/null +++ b/src/test/java/konkuk/thip/user/adapter/in/web/BlockContentHiddenApiTest.java @@ -0,0 +1,272 @@ +package konkuk.thip.user.adapter.in.web; + +import konkuk.thip.book.adapter.out.jpa.BookJpaEntity; +import konkuk.thip.book.adapter.out.persistence.repository.BookJpaRepository; +import konkuk.thip.comment.adapter.out.jpa.CommentJpaEntity; +import konkuk.thip.comment.adapter.out.persistence.repository.CommentJpaRepository; +import konkuk.thip.common.util.TestEntityFactory; +import konkuk.thip.post.domain.PostType; +import konkuk.thip.feed.adapter.out.jpa.FeedJpaEntity; +import konkuk.thip.feed.adapter.out.persistence.repository.FeedJpaRepository; +import konkuk.thip.feed.adapter.out.persistence.repository.SavedFeedJpaRepository; +import konkuk.thip.user.adapter.out.jpa.UserJpaEntity; +import konkuk.thip.user.adapter.out.persistence.repository.UserJpaRepository; +import konkuk.thip.user.adapter.out.persistence.repository.block.UserBlockJpaRepository; +import konkuk.thip.user.domain.value.Alias; +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.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import static org.hamcrest.Matchers.hasItem; +import static org.hamcrest.Matchers.hasSize; +import static org.hamcrest.Matchers.not; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@ActiveProfiles("test") +@AutoConfigureMockMvc(addFilters = false) +@DisplayName("[통합] 차단 후 콘텐츠 노출 차단 검증") +class BlockContentHiddenApiTest { + + @Autowired private MockMvc mockMvc; + + @Autowired private UserJpaRepository userJpaRepository; + @Autowired private UserBlockJpaRepository userBlockJpaRepository; + @Autowired private FeedJpaRepository feedJpaRepository; + @Autowired private BookJpaRepository bookJpaRepository; + @Autowired private SavedFeedJpaRepository savedFeedJpaRepository; + @Autowired private CommentJpaRepository commentJpaRepository; + @Autowired private JdbcTemplate jdbcTemplate; + + private UserJpaEntity viewer; // 차단하는 사람 + private UserJpaEntity blocked; // 차단당하는 사람 + private BookJpaEntity book; + + @BeforeEach + void setUp() { + Alias alias = TestEntityFactory.createLiteratureAlias(); + viewer = userJpaRepository.save(TestEntityFactory.createUser(alias, "viewer")); + blocked = userJpaRepository.save(TestEntityFactory.createUser(alias, "blockeduser")); + book = bookJpaRepository.save(TestEntityFactory.createBookWithISBN("9788954682152")); + } + + @AfterEach + void tearDown() { + userBlockJpaRepository.deleteAllInBatch(); + savedFeedJpaRepository.deleteAllInBatch(); + // comments 는 parent_id 로 자기 자신을 참조하므로 답글부터 지워야 FK 제약에 걸리지 않는다 + jdbcTemplate.update("DELETE FROM comments WHERE parent_id IS NOT NULL"); + commentJpaRepository.deleteAllInBatch(); + feedJpaRepository.deleteAllInBatch(); + bookJpaRepository.deleteAllInBatch(); + userJpaRepository.deleteAllInBatch(); + } + + @Test + @DisplayName("[성공] 차단하면 홈 피드에서 그 사용자의 글이 사라진다.") + void blockedUserFeed_disappears_from_home_feed() throws Exception { + // given : 상대가 공개 피드를 작성했고, 차단 전에는 보인다 + feedJpaRepository.save(TestEntityFactory.createFeed(blocked, book, true)); + + mockMvc.perform(get("/feeds").requestAttr("userId", viewer.getUserId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.feedList", hasSize(1))); + + // when : 차단 + block(viewer, blocked); + + // then : 홈 피드에서 사라진다 + mockMvc.perform(get("/feeds").requestAttr("userId", viewer.getUserId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.feedList", hasSize(0))); + } + + @Test + @DisplayName("[성공] 차단은 양방향이다 - 차단당한 사용자에게도 차단한 사용자의 글이 사라진다.") + void block_hides_content_both_ways() throws Exception { + // given : viewer 가 공개 피드를 작성. 차단 전에는 blocked 에게 보인다 + feedJpaRepository.save(TestEntityFactory.createFeed(viewer, book, true)); + + mockMvc.perform(get("/feeds").requestAttr("userId", blocked.getUserId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.feedList", hasSize(1))); + + // when : viewer 가 blocked 를 차단 (차단당한 쪽은 아무 행동도 하지 않았다) + block(viewer, blocked); + + // then : 차단당한 쪽에서도 상대 글이 사라진다 + mockMvc.perform(get("/feeds").requestAttr("userId", blocked.getUserId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.feedList", hasSize(0))); + } + + @Test + @DisplayName("[성공] 차단하면 사용자 검색 결과에서 사라진다.") + void blockedUser_disappears_from_user_search() throws Exception { + // 다른 테스트가 남긴 유저에 영향받지 않도록 개수가 아니라 대상 포함 여부로 검증한다 + int blockedUserId = blocked.getUserId().intValue(); + + // given : 차단 전에는 검색된다 + mockMvc.perform(get("/users") + .param("keyword", "blockeduser") + .param("isFinalized", "false") + .requestAttr("userId", viewer.getUserId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.userList[*].userId", hasItem(blockedUserId))); + + // when + block(viewer, blocked); + + // then + mockMvc.perform(get("/users") + .param("keyword", "blockeduser") + .param("isFinalized", "false") + .requestAttr("userId", viewer.getUserId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.userList[*].userId", not(hasItem(blockedUserId)))); + } + + @Test + @DisplayName("[성공] 차단하면 저장한 피드 목록에서도 사라진다. (저장 관계 자체는 지우지 않는다)") + void blockedUserFeed_disappears_from_saved_feeds() throws Exception { + // given : 상대 피드를 저장해 둔 상태 + FeedJpaEntity feed = feedJpaRepository.save(TestEntityFactory.createFeed(blocked, book, true)); + savedFeedJpaRepository.save(TestEntityFactory.createSavedFeed(viewer, feed)); + + mockMvc.perform(get("/feeds/saved").requestAttr("userId", viewer.getUserId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.feedList", hasSize(1))); + + // when + block(viewer, blocked); + + // then : 목록에서는 사라지지만 saved_feeds row 는 남아 있어 차단 해제 시 복원된다 + mockMvc.perform(get("/feeds/saved").requestAttr("userId", viewer.getUserId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.feedList", hasSize(0))); + } + + @Test + @DisplayName("[404] 차단한 사용자의 피드는 단건 상세로 직접 진입해도 볼 수 없다.") + void blockedUserFeed_single_view_returns_404() throws Exception { + // given + FeedJpaEntity feed = feedJpaRepository.save(TestEntityFactory.createFeed(blocked, book, true)); + block(viewer, blocked); + + // when & then : 목록에서 숨겨도 딥링크로 들어올 수 있으므로 서비스에서 막는다 + mockMvc.perform(get("/feeds/{feedId}", feed.getPostId()) + .requestAttr("userId", viewer.getUserId())) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("[404] 차단한 사용자의 프로필 피드 목록에 진입할 수 없다.") + void blockedUser_profile_feeds_returns_404() throws Exception { + // given + feedJpaRepository.save(TestEntityFactory.createFeed(blocked, book, true)); + block(viewer, blocked); + + // when & then + mockMvc.perform(get("/feeds/users/{userId}", blocked.getUserId()) + .requestAttr("userId", viewer.getUserId())) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("[404] 차단한 사용자의 프로필 정보를 조회할 수 없다.") + void blockedUser_profile_info_returns_404() throws Exception { + // given + block(viewer, blocked); + + // when & then + mockMvc.perform(get("/feeds/users/{userId}/info", blocked.getUserId()) + .requestAttr("userId", viewer.getUserId())) + .andExpect(status().isNotFound()); + } + + @Test + @DisplayName("[성공] 차단하면 댓글 목록에서 그 사용자의 댓글이 사라진다.") + void blockedUserComment_disappears_from_comment_list() throws Exception { + // given : 내 피드에 상대가 댓글을 달았다 + FeedJpaEntity feed = feedJpaRepository.save(TestEntityFactory.createFeed(viewer, book, true)); + commentJpaRepository.save(TestEntityFactory.createComment(feed, blocked, PostType.FEED)); + + mockMvc.perform(get("/comments/{postId}", feed.getPostId()) + .param("postType", "FEED") + .requestAttr("userId", viewer.getUserId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.commentList", hasSize(1))); + + // when + block(viewer, blocked); + + // then + mockMvc.perform(get("/comments/{postId}", feed.getPostId()) + .param("postType", "FEED") + .requestAttr("userId", viewer.getUserId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.commentList", hasSize(0))); + } + + @Test + @DisplayName("[성공] 차단한 사용자의 루트 댓글은 내 답글까지 스레드째로 사라진다.") + void blockedUserRootComment_hides_whole_thread() throws Exception { + // given : 상대의 루트 댓글에 내가 답글을 달아둔 상태 + FeedJpaEntity feed = feedJpaRepository.save(TestEntityFactory.createFeed(viewer, book, true)); + CommentJpaEntity rootComment = commentJpaRepository.save( + TestEntityFactory.createComment(feed, blocked, PostType.FEED)); + commentJpaRepository.save( + TestEntityFactory.createReplyComment(feed, viewer, PostType.FEED, rootComment)); + + mockMvc.perform(get("/comments/{postId}", feed.getPostId()) + .param("postType", "FEED") + .requestAttr("userId", viewer.getUserId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.commentList", hasSize(1))); + + // when + block(viewer, blocked); + + // then : 루트가 숨겨지므로 그 아래 내 답글도 함께 사라진다 (확정된 정책) + mockMvc.perform(get("/comments/{postId}", feed.getPostId()) + .param("postType", "FEED") + .requestAttr("userId", viewer.getUserId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.commentList", hasSize(0))); + } + + @Test + @DisplayName("[성공] 차단을 해제하면 다시 보인다.") + void unblock_restores_visibility() throws Exception { + // given + feedJpaRepository.save(TestEntityFactory.createFeed(blocked, book, true)); + block(viewer, blocked); + + mockMvc.perform(get("/feeds").requestAttr("userId", viewer.getUserId())) + .andExpect(jsonPath("$.data.feedList", hasSize(0))); + + // when : 차단 해제 + userBlockJpaRepository.findByUserAndBlockedUser(viewer.getUserId(), blocked.getUserId()) + .ifPresent(userBlockJpaRepository::delete); + userBlockJpaRepository.flush(); + + // then + mockMvc.perform(get("/feeds").requestAttr("userId", viewer.getUserId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.feedList", hasSize(1))); + } + + private void block(UserJpaEntity blocker, UserJpaEntity target) { + userBlockJpaRepository.save(TestEntityFactory.createUserBlock(blocker, target)); + userBlockJpaRepository.flush(); + } +} diff --git a/src/test/java/konkuk/thip/user/adapter/in/web/BlockInteractionBlockedApiTest.java b/src/test/java/konkuk/thip/user/adapter/in/web/BlockInteractionBlockedApiTest.java new file mode 100644 index 000000000..c9fefac58 --- /dev/null +++ b/src/test/java/konkuk/thip/user/adapter/in/web/BlockInteractionBlockedApiTest.java @@ -0,0 +1,126 @@ + package konkuk.thip.user.adapter.in.web; + +import konkuk.thip.book.adapter.out.jpa.BookJpaEntity; +import konkuk.thip.book.adapter.out.persistence.repository.BookJpaRepository; +import konkuk.thip.comment.adapter.out.persistence.repository.CommentJpaRepository; +import konkuk.thip.common.exception.code.ErrorCode; +import konkuk.thip.common.util.TestEntityFactory; +import konkuk.thip.feed.adapter.out.jpa.FeedJpaEntity; +import konkuk.thip.feed.adapter.out.persistence.repository.FeedJpaRepository; +import konkuk.thip.feed.adapter.out.persistence.repository.SavedFeedJpaRepository; +import konkuk.thip.notification.adapter.out.persistence.repository.NotificationJpaRepository; +import konkuk.thip.post.adapter.out.persistence.repository.PostLikeJpaRepository; +import konkuk.thip.user.adapter.out.jpa.UserJpaEntity; +import konkuk.thip.user.adapter.out.persistence.repository.UserJpaRepository; +import konkuk.thip.user.adapter.out.persistence.repository.block.UserBlockJpaRepository; +import konkuk.thip.user.domain.value.Alias; +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.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@ActiveProfiles("test") +@AutoConfigureMockMvc(addFilters = false) +@DisplayName("[통합] 차단 상태에서의 상호작용 차단 검증") +class BlockInteractionBlockedApiTest { + + @Autowired private MockMvc mockMvc; + + @Autowired private UserJpaRepository userJpaRepository; + @Autowired private UserBlockJpaRepository userBlockJpaRepository; + @Autowired private BookJpaRepository bookJpaRepository; + @Autowired private FeedJpaRepository feedJpaRepository; + @Autowired private SavedFeedJpaRepository savedFeedJpaRepository; + @Autowired private CommentJpaRepository commentJpaRepository; + @Autowired private PostLikeJpaRepository postLikeJpaRepository; + @Autowired private NotificationJpaRepository notificationJpaRepository; + + private UserJpaEntity viewer; + private UserJpaEntity blocked; + private FeedJpaEntity blockedUserFeed; + + @BeforeEach + void setUp() { + Alias alias = TestEntityFactory.createLiteratureAlias(); + viewer = userJpaRepository.save(TestEntityFactory.createUser(alias, "viewer")); + blocked = userJpaRepository.save(TestEntityFactory.createUser(alias, "blockeduser")); + + BookJpaEntity book = bookJpaRepository.save(TestEntityFactory.createBookWithISBN("9788954682152")); + blockedUserFeed = feedJpaRepository.save(TestEntityFactory.createFeed(blocked, book, true)); + + userBlockJpaRepository.save(TestEntityFactory.createUserBlock(viewer, blocked)); + userBlockJpaRepository.flush(); + } + + @AfterEach + void tearDown() { + notificationJpaRepository.deleteAllInBatch(); + postLikeJpaRepository.deleteAllInBatch(); + userBlockJpaRepository.deleteAllInBatch(); + savedFeedJpaRepository.deleteAllInBatch(); + commentJpaRepository.deleteAllInBatch(); + feedJpaRepository.deleteAllInBatch(); + bookJpaRepository.deleteAllInBatch(); + userJpaRepository.deleteAllInBatch(); + } + + @Test + @DisplayName("[400] 차단한 사용자의 피드에 좋아요할 수 없다.") + void cannot_like_blocked_user_feed() throws Exception { + mockMvc.perform(post("/feeds/{feedId}/likes", blockedUserFeed.getPostId()) + .requestAttr("userId", viewer.getUserId()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"type\": true}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ErrorCode.USER_BLOCKED_CANNOT_INTERACT.getCode())); + } + + @Test + @DisplayName("[400] 차단한 사용자의 피드에 댓글을 달 수 없다.") + void cannot_comment_on_blocked_user_feed() throws Exception { + mockMvc.perform(post("/comments/{postId}", blockedUserFeed.getPostId()) + .requestAttr("userId", viewer.getUserId()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"content\": \"댓글\", \"isReplyRequest\": false, \"parentId\": null, \"postType\": \"FEED\"}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ErrorCode.USER_BLOCKED_CANNOT_INTERACT.getCode())); + } + + @Test + @DisplayName("[400] 차단한 사용자의 피드를 저장할 수 없다.") + void cannot_save_blocked_user_feed() throws Exception { + mockMvc.perform(post("/feeds/{feedId}/saved", blockedUserFeed.getPostId()) + .requestAttr("userId", viewer.getUserId()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"type\": true}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ErrorCode.USER_BLOCKED_CANNOT_INTERACT.getCode())); + } + + @Test + @DisplayName("[성공] 차단 관계에서는 알림이 생성되지 않는다.") + void notification_is_suppressed_between_blocked_users() throws Exception { + long before = notificationJpaRepository.count(); + + // 차단 상태에서 팔로우를 시도하면 거부되므로 알림도 생기지 않는다 + mockMvc.perform(post("/users/following/{followingUserId}", blocked.getUserId()) + .requestAttr("userId", viewer.getUserId()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"type\": true}")) + .andExpect(status().isBadRequest()); + + assertThat(notificationJpaRepository.count()).isEqualTo(before); + } +} diff --git a/src/test/java/konkuk/thip/user/adapter/in/web/BlockRoomContentHiddenApiTest.java b/src/test/java/konkuk/thip/user/adapter/in/web/BlockRoomContentHiddenApiTest.java new file mode 100644 index 000000000..2b5cd63e9 --- /dev/null +++ b/src/test/java/konkuk/thip/user/adapter/in/web/BlockRoomContentHiddenApiTest.java @@ -0,0 +1,151 @@ +package konkuk.thip.user.adapter.in.web; + +import konkuk.thip.book.adapter.out.jpa.BookJpaEntity; +import konkuk.thip.book.adapter.out.persistence.repository.BookJpaRepository; +import konkuk.thip.common.util.TestEntityFactory; +import konkuk.thip.room.adapter.out.jpa.RoomJpaEntity; +import konkuk.thip.room.adapter.out.persistence.repository.RoomJpaRepository; +import konkuk.thip.room.adapter.out.persistence.repository.roomparticipant.RoomParticipantJpaRepository; +import konkuk.thip.room.domain.value.Category; +import konkuk.thip.room.domain.value.RoomParticipantRole; +import konkuk.thip.roompost.adapter.out.persistence.repository.attendancecheck.AttendanceCheckJpaRepository; +import konkuk.thip.roompost.adapter.out.persistence.repository.record.RecordJpaRepository; +import konkuk.thip.user.adapter.out.jpa.UserJpaEntity; +import konkuk.thip.user.adapter.out.persistence.repository.UserJpaRepository; +import konkuk.thip.user.adapter.out.persistence.repository.block.UserBlockJpaRepository; +import konkuk.thip.user.domain.value.Alias; +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.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import static org.hamcrest.Matchers.hasSize; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@ActiveProfiles("test") +@AutoConfigureMockMvc(addFilters = false) +@DisplayName("[통합] 차단 후 모임방 콘텐츠 노출 차단 검증") +class BlockRoomContentHiddenApiTest { + + @Autowired private MockMvc mockMvc; + + @Autowired private UserJpaRepository userJpaRepository; + @Autowired private UserBlockJpaRepository userBlockJpaRepository; + @Autowired private BookJpaRepository bookJpaRepository; + @Autowired private RoomJpaRepository roomJpaRepository; + @Autowired private RoomParticipantJpaRepository roomParticipantJpaRepository; + @Autowired private RecordJpaRepository recordJpaRepository; + @Autowired private AttendanceCheckJpaRepository attendanceCheckJpaRepository; + + private UserJpaEntity viewer; + private UserJpaEntity blocked; + private RoomJpaEntity room; + + @BeforeEach + void setUp() { + Alias alias = TestEntityFactory.createLiteratureAlias(); + viewer = userJpaRepository.save(TestEntityFactory.createUser(alias, "viewer")); + blocked = userJpaRepository.save(TestEntityFactory.createUser(alias, "blockedUser")); + + BookJpaEntity book = bookJpaRepository.save(TestEntityFactory.createBookWithISBN("9788954682152")); + Category category = TestEntityFactory.createLiteratureCategory(); + room = roomJpaRepository.save(TestEntityFactory.createRoom(book, category)); + + // 둘 다 같은 방에 참여 중 (viewer 가 방장) + roomParticipantJpaRepository.save( + TestEntityFactory.createRoomParticipant(room, viewer, RoomParticipantRole.HOST, 0.0)); + roomParticipantJpaRepository.save( + TestEntityFactory.createRoomParticipant(room, blocked, RoomParticipantRole.MEMBER, 0.0)); + } + + @AfterEach + void tearDown() { + userBlockJpaRepository.deleteAllInBatch(); + attendanceCheckJpaRepository.deleteAllInBatch(); + recordJpaRepository.deleteAllInBatch(); + roomParticipantJpaRepository.deleteAllInBatch(); + roomJpaRepository.deleteAllInBatch(); + bookJpaRepository.deleteAllInBatch(); + userJpaRepository.deleteAllInBatch(); + } + + @Test + @DisplayName("[성공] 차단하면 그룹 기록 목록에서 그 사용자의 기록이 사라진다.") + void blockedUserRecord_disappears_from_group_records() throws Exception { + // given + recordJpaRepository.save(TestEntityFactory.createRecord(blocked, room)); + + mockMvc.perform(get("/rooms/{roomId}/posts", room.getRoomId()) + .param("type", "group") + .param("sort", "latest") + .requestAttr("userId", viewer.getUserId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.postList", hasSize(1))); + + // when + block(viewer, blocked); + + // then + mockMvc.perform(get("/rooms/{roomId}/posts", room.getRoomId()) + .param("type", "group") + .param("sort", "latest") + .requestAttr("userId", viewer.getUserId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.postList", hasSize(0))); + } + + @Test + @DisplayName("[성공] 차단하면 오늘의 한마디 목록에서 그 사용자의 글이 사라진다.") + void blockedUserAttendanceCheck_disappears() throws Exception { + // given + attendanceCheckJpaRepository.save( + TestEntityFactory.createAttendanceCheck("오늘의 한마디", room, blocked)); + + mockMvc.perform(get("/rooms/{roomId}/daily-greeting", room.getRoomId()) + .requestAttr("userId", viewer.getUserId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.todayCommentList", hasSize(1))); + + // when + block(viewer, blocked); + + // then + mockMvc.perform(get("/rooms/{roomId}/daily-greeting", room.getRoomId()) + .requestAttr("userId", viewer.getUserId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.todayCommentList", hasSize(0))); + } + + @Test + @DisplayName("[성공] 차단하면 모임방 멤버 목록에서 그 사용자가 사라진다. (이미 참여 중인 방 자체는 유지)") + void blockedUser_disappears_from_member_list() throws Exception { + // given : 차단 전에는 두 명 모두 보인다 + mockMvc.perform(get("/rooms/{roomId}/users", room.getRoomId()) + .requestAttr("userId", viewer.getUserId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.userList", hasSize(2))); + + // when + block(viewer, blocked); + + // then : 차단 사용자만 빠지고 방은 그대로 조회된다 + mockMvc.perform(get("/rooms/{roomId}/users", room.getRoomId()) + .requestAttr("userId", viewer.getUserId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.userList", hasSize(1))) + .andExpect(jsonPath("$.data.userList[0].userId").value(viewer.getUserId())); + } + + private void block(UserJpaEntity blocker, UserJpaEntity target) { + userBlockJpaRepository.save(TestEntityFactory.createUserBlock(blocker, target)); + userBlockJpaRepository.flush(); + } +} diff --git a/src/test/java/konkuk/thip/user/adapter/in/web/UserBlockApiTest.java b/src/test/java/konkuk/thip/user/adapter/in/web/UserBlockApiTest.java new file mode 100644 index 000000000..db362caf5 --- /dev/null +++ b/src/test/java/konkuk/thip/user/adapter/in/web/UserBlockApiTest.java @@ -0,0 +1,203 @@ +package konkuk.thip.user.adapter.in.web; + +import konkuk.thip.common.exception.code.ErrorCode; +import konkuk.thip.common.util.TestEntityFactory; +import konkuk.thip.user.adapter.out.jpa.UserBlockJpaEntity; +import konkuk.thip.user.adapter.out.jpa.UserJpaEntity; +import konkuk.thip.user.adapter.out.persistence.repository.UserJpaRepository; +import konkuk.thip.user.adapter.out.persistence.repository.block.UserBlockJpaRepository; +import konkuk.thip.notification.adapter.out.persistence.repository.NotificationJpaRepository; +import konkuk.thip.user.adapter.out.persistence.repository.following.FollowingJpaRepository; +import konkuk.thip.user.domain.value.Alias; +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.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@ActiveProfiles("test") +@AutoConfigureMockMvc(addFilters = false) +@DisplayName("[통합] 사용자 차단 상태 변경 api 통합 테스트") +class UserBlockApiTest { + + @Autowired private MockMvc mockMvc; + + @Autowired private UserJpaRepository userJpaRepository; + @Autowired private UserBlockJpaRepository userBlockJpaRepository; + @Autowired private FollowingJpaRepository followingJpaRepository; + @Autowired private NotificationJpaRepository notificationJpaRepository; + + private static final String BLOCK_API_PATH = "/users/block/{targetUserId}"; + + private UserJpaEntity user; + private UserJpaEntity target; + + @BeforeEach + void setUp() { + Alias alias = TestEntityFactory.createLiteratureAlias(); + user = userJpaRepository.save(TestEntityFactory.createUser(alias, "차단하는사람")); + target = userJpaRepository.save(TestEntityFactory.createUser(alias, "차단당하는사람")); + } + + @AfterEach + void tearDown() { + // 팔로우 API 호출 시 알림이 생성되므로 users 보다 먼저 정리해야 FK 제약에 걸리지 않는다 + notificationJpaRepository.deleteAllInBatch(); + userBlockJpaRepository.deleteAllInBatch(); + followingJpaRepository.deleteAllInBatch(); + userJpaRepository.deleteAllInBatch(); + } + + @Test + @DisplayName("[성공] 차단 요청 후 차단 해제 요청 시 차단 관계가 생성되었다가 삭제된다.") + void changeBlockState_block_then_unblock() throws Exception { + // when : 차단 요청 + mockMvc.perform(post(BLOCK_API_PATH, target.getUserId()) + .requestAttr("userId", user.getUserId()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"type\": true}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.isBlocked").value(true)); + + // then : 차단 관계가 저장된다 + UserBlockJpaEntity blockEntity = userBlockJpaRepository + .findByUserAndBlockedUser(user.getUserId(), target.getUserId()).orElseThrow(); + assertThat(blockEntity.getStatus().name()).isEqualTo("ACTIVE"); + + // when : 차단 해제 요청 + mockMvc.perform(post(BLOCK_API_PATH, target.getUserId()) + .requestAttr("userId", user.getUserId()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"type\": false}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.isBlocked").value(false)); + + // then : 차단 관계가 삭제된다 (hard delete) + Optional deleted = userBlockJpaRepository + .findByUserAndBlockedUser(user.getUserId(), target.getUserId()); + assertThat(deleted).isEmpty(); + } + + @Test + @DisplayName("[성공] 차단하면 양쪽 팔로우 관계가 해제되고 팔로워 수가 감소한다.") + void changeBlockState_block_unfollows_both_ways() throws Exception { + // given : 팔로우 API 로 서로 팔로우한 상태를 만든다 (followerCount 까지 정확히 반영하기 위해) + follow(user.getUserId(), target.getUserId()); + follow(target.getUserId(), user.getUserId()); + + assertThat(userJpaRepository.findById(user.getUserId()).orElseThrow().getFollowerCount()).isEqualTo(1); + assertThat(userJpaRepository.findById(target.getUserId()).orElseThrow().getFollowerCount()).isEqualTo(1); + + // when : 차단 + mockMvc.perform(post(BLOCK_API_PATH, target.getUserId()) + .requestAttr("userId", user.getUserId()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"type\": true}")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.isBlocked").value(true)); + + // then : 양방향 팔로우가 모두 삭제된다 + assertThat(followingJpaRepository.findByUserAndTargetUser(user.getUserId(), target.getUserId())).isEmpty(); + assertThat(followingJpaRepository.findByUserAndTargetUser(target.getUserId(), user.getUserId())).isEmpty(); + + // then : 양쪽 팔로워 수가 감소한다 + assertThat(userJpaRepository.findById(user.getUserId()).orElseThrow().getFollowerCount()).isZero(); + assertThat(userJpaRepository.findById(target.getUserId()).orElseThrow().getFollowerCount()).isZero(); + } + + @Test + @DisplayName("[성공] 차단을 해제해도 팔로우 관계는 복구되지 않는다.") + void changeBlockState_unblock_does_not_restore_follow() throws Exception { + // given : 팔로우 후 차단 + follow(user.getUserId(), target.getUserId()); + + mockMvc.perform(post(BLOCK_API_PATH, target.getUserId()) + .requestAttr("userId", user.getUserId()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"type\": true}")) + .andExpect(status().isOk()); + + // when : 차단 해제 + mockMvc.perform(post(BLOCK_API_PATH, target.getUserId()) + .requestAttr("userId", user.getUserId()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"type\": false}")) + .andExpect(status().isOk()); + + // then : 팔로우는 복구되지 않는다 + assertThat(followingJpaRepository.findByUserAndTargetUser(user.getUserId(), target.getUserId())).isEmpty(); + } + + @Test + @DisplayName("[400 에러 발생] 이미 차단한 사용자를 다시 차단할 수 없다.") + void changeBlockState_already_blocked_fail() throws Exception { + // given + userBlockJpaRepository.save(TestEntityFactory.createUserBlock(user, target)); + + // when & then + mockMvc.perform(post(BLOCK_API_PATH, target.getUserId()) + .requestAttr("userId", user.getUserId()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"type\": true}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ErrorCode.USER_ALREADY_BLOCKED.getCode())); + } + + @Test + @DisplayName("[400 에러 발생] 차단하지 않은 사용자를 차단 해제할 수 없다.") + void changeBlockState_already_unblocked_fail() throws Exception { + mockMvc.perform(post(BLOCK_API_PATH, target.getUserId()) + .requestAttr("userId", user.getUserId()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"type\": false}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ErrorCode.USER_ALREADY_UNBLOCKED.getCode())); + } + + @Test + @DisplayName("[400 에러 발생] 자기 자신은 차단할 수 없다.") + void changeBlockState_self_block_fail() throws Exception { + mockMvc.perform(post(BLOCK_API_PATH, user.getUserId()) + .requestAttr("userId", user.getUserId()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"type\": true}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ErrorCode.USER_CANNOT_BLOCK_SELF.getCode())); + } + + @Test + @DisplayName("[400 에러 발생] 차단 관계인 사용자는 팔로우할 수 없다.") + void follow_blocked_user_fail() throws Exception { + // given : 상대가 나를 차단한 상태 + userBlockJpaRepository.save(TestEntityFactory.createUserBlock(target, user)); + + // when & then + mockMvc.perform(post("/users/following/{followingUserId}", target.getUserId()) + .requestAttr("userId", user.getUserId()) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"type\": true}")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(ErrorCode.USER_BLOCKED_CANNOT_INTERACT.getCode())); + } + + private void follow(Long followerUserId, Long targetUserId) throws Exception { + mockMvc.perform(post("/users/following/{followingUserId}", targetUserId) + .requestAttr("userId", followerUserId) + .contentType(MediaType.APPLICATION_JSON) + .content("{\"type\": true}")) + .andExpect(status().isOk()); + } +} diff --git a/src/test/java/konkuk/thip/user/adapter/in/web/UserGetBlockedUsersApiTest.java b/src/test/java/konkuk/thip/user/adapter/in/web/UserGetBlockedUsersApiTest.java new file mode 100644 index 000000000..2ed2cf347 --- /dev/null +++ b/src/test/java/konkuk/thip/user/adapter/in/web/UserGetBlockedUsersApiTest.java @@ -0,0 +1,141 @@ +package konkuk.thip.user.adapter.in.web; + +import konkuk.thip.common.util.TestEntityFactory; +import konkuk.thip.user.adapter.out.jpa.UserJpaEntity; +import konkuk.thip.user.adapter.out.persistence.repository.UserJpaRepository; +import konkuk.thip.user.adapter.out.persistence.repository.block.UserBlockJpaRepository; +import konkuk.thip.user.domain.value.Alias; +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.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.hasSize; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@ActiveProfiles("test") +@AutoConfigureMockMvc(addFilters = false) +@DisplayName("[통합] 차단 목록 조회 api 통합 테스트") +class UserGetBlockedUsersApiTest { + + @Autowired private MockMvc mockMvc; + + @Autowired private UserJpaRepository userJpaRepository; + @Autowired private UserBlockJpaRepository userBlockJpaRepository; + + private static final String BLOCKED_USERS_API_PATH = "/users/blocks"; + + private UserJpaEntity user; + private UserJpaEntity blockedUser; + + @BeforeEach + void setUp() { + Alias alias = TestEntityFactory.createLiteratureAlias(); + user = userJpaRepository.save(TestEntityFactory.createUser(alias, "차단하는사람")); + blockedUser = userJpaRepository.save(TestEntityFactory.createUser(alias, "차단당한사람")); + } + + @AfterEach + void tearDown() { + userBlockJpaRepository.deleteAllInBatch(); + userJpaRepository.deleteAllInBatch(); + } + + @Test + @DisplayName("[성공] 내가 차단한 사용자 목록을 조회한다.") + void getBlockedUsers_success() throws Exception { + // given + userBlockJpaRepository.save(TestEntityFactory.createUserBlock(user, blockedUser)); + + // when & then + mockMvc.perform(get(BLOCKED_USERS_API_PATH) + .requestAttr("userId", user.getUserId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.blockedUsers", hasSize(1))) + .andExpect(jsonPath("$.data.blockedUsers[0].userId").value(blockedUser.getUserId())) + .andExpect(jsonPath("$.data.blockedUsers[0].nickname").value("차단당한사람")) + .andExpect(jsonPath("$.data.totalBlockedUserCount").value(1)) + .andExpect(jsonPath("$.data.isLast").value(true)); + } + + @Test + @DisplayName("[성공] 차단한 사용자가 없으면 빈 목록을 반환한다.") + void getBlockedUsers_empty() throws Exception { + mockMvc.perform(get(BLOCKED_USERS_API_PATH) + .requestAttr("userId", user.getUserId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.blockedUsers", hasSize(0))) + .andExpect(jsonPath("$.data.totalBlockedUserCount").value(0)) + .andExpect(jsonPath("$.data.isLast").value(true)); + } + + @Test + @DisplayName("[성공] 차단 목록은 size 만큼 끊어 커서로 이어진다. (무한스크롤)") + void getBlockedUsers_paging() throws Exception { + // given : 25명을 차단한다 + Alias alias = TestEntityFactory.createLiteratureAlias(); + for (int i = 0; i < 25; i++) { + UserJpaEntity target = userJpaRepository.save(TestEntityFactory.createUser(alias, "blocked" + i)); + userBlockJpaRepository.save(TestEntityFactory.createUserBlock(user, target)); + } + userBlockJpaRepository.flush(); + + // when & then : 1페이지 10개, 총 개수는 첫 페이지에만 내려온다 + String cursor = readPage(null, 10, 25, false); + + // when & then : 2페이지 10개, 총 개수는 null + cursor = readPage(cursor, 10, null, false); + + // when & then : 3페이지 5개, isLast = true, nextCursor 없음 + readPage(cursor, 5, null, true); + } + + /** + * 한 페이지를 조회해 크기·총개수·isLast 를 검증하고 다음 커서를 돌려준다. + */ + private String readPage(String cursor, int expectedSize, Integer expectedTotal, boolean expectedLast) throws Exception { + var request = get(BLOCKED_USERS_API_PATH).requestAttr("userId", user.getUserId()).param("size", "10"); + if (cursor != null) { + request = request.param("cursor", cursor); + } + + String body = mockMvc.perform(request) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.blockedUsers", hasSize(expectedSize))) + .andExpect(jsonPath("$.data.isLast").value(expectedLast)) + .andExpect(expectedTotal == null + ? jsonPath("$.data.totalBlockedUserCount").doesNotExist() + : jsonPath("$.data.totalBlockedUserCount").value(expectedTotal)) + .andReturn().getResponse().getContentAsString(); + + String next = com.jayway.jsonpath.JsonPath.parse(body).read("$.data.nextCursor", String.class); + if (expectedLast) { + assertThat(next).isNull(); + } else { + assertThat(next).isNotBlank(); + } + return next; + } + + @Test + @DisplayName("[성공] 나를 차단한 사용자는 내 차단 목록에 나타나지 않는다.") + void getBlockedUsers_excludes_reverse_block() throws Exception { + // given : 상대가 나를 차단 + userBlockJpaRepository.save(TestEntityFactory.createUserBlock(blockedUser, user)); + + // when & then : 내 차단 목록은 비어 있다 + mockMvc.perform(get(BLOCKED_USERS_API_PATH) + .requestAttr("userId", user.getUserId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.blockedUsers", hasSize(0))); + } +} diff --git a/src/test/java/konkuk/thip/user/application/service/UserBlockServiceTest.java b/src/test/java/konkuk/thip/user/application/service/UserBlockServiceTest.java new file mode 100644 index 000000000..a9bafad97 --- /dev/null +++ b/src/test/java/konkuk/thip/user/application/service/UserBlockServiceTest.java @@ -0,0 +1,160 @@ +package konkuk.thip.user.application.service; + +import konkuk.thip.common.exception.BusinessException; +import konkuk.thip.common.exception.InvalidStateException; +import konkuk.thip.user.application.port.in.dto.UserBlockCommand; +import konkuk.thip.user.application.port.out.FollowingCommandPort; +import konkuk.thip.user.application.port.out.UserBlockCommandPort; +import konkuk.thip.user.application.port.out.UserCommandPort; +import konkuk.thip.user.application.service.block.UserBlockService; +import konkuk.thip.user.domain.Following; +import konkuk.thip.user.domain.User; +import konkuk.thip.user.domain.UserBlock; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; + +@DisplayName("[단위] UserBlockService 단위 테스트") +class UserBlockServiceTest { + + private UserBlockCommandPort userBlockCommandPort; + private FollowingCommandPort followingCommandPort; + private UserCommandPort userCommandPort; + private UserBlockService userBlockService; + + @BeforeEach + void setUp() { + userBlockCommandPort = mock(UserBlockCommandPort.class); + followingCommandPort = mock(FollowingCommandPort.class); + userCommandPort = mock(UserCommandPort.class); + userBlockService = new UserBlockService(userBlockCommandPort, followingCommandPort, userCommandPort); + } + + @Nested + @DisplayName("차단 요청(type = true)") + class Block { + + @Test + @DisplayName("차단 관계가 없으면 차단을 저장하고 true 를 반환한다.") + void block_newRelation() { + // given + Long userId = 1L, targetUserId = 2L; + when(userBlockCommandPort.findByUserIdAndTargetUserId(userId, targetUserId)).thenReturn(Optional.empty()); + when(userCommandPort.findByIdWithLock(userId)).thenReturn(createUserWithFollowerCount(userId, 0)); + when(userCommandPort.findByIdWithLock(targetUserId)).thenReturn(createUserWithFollowerCount(targetUserId, 0)); + when(followingCommandPort.findByUserIdAndTargetUserId(any(), any())).thenReturn(Optional.empty()); + + // when + Boolean result = userBlockService.changeBlockState(new UserBlockCommand(userId, targetUserId, true)); + + // then + assertThat(result).isTrue(); + ArgumentCaptor captor = ArgumentCaptor.forClass(UserBlock.class); + verify(userBlockCommandPort).save(captor.capture()); + assertThat(captor.getValue().getUserId()).isEqualTo(userId); + assertThat(captor.getValue().getBlockedUserId()).isEqualTo(targetUserId); + } + + @Test + @DisplayName("양방향 팔로우가 존재하면 둘 다 해제하고 팔로워 수를 감소시킨다.") + void block_unfollowsBothWays() { + // given + Long userId = 1L, targetUserId = 2L; + User user = createUserWithFollowerCount(userId, 1); + User target = createUserWithFollowerCount(targetUserId, 1); + + when(userBlockCommandPort.findByUserIdAndTargetUserId(userId, targetUserId)).thenReturn(Optional.empty()); + when(userCommandPort.findByIdWithLock(userId)).thenReturn(user); + when(userCommandPort.findByIdWithLock(targetUserId)).thenReturn(target); + when(followingCommandPort.findByUserIdAndTargetUserId(userId, targetUserId)) + .thenReturn(Optional.of(Following.withoutId(userId, targetUserId))); + when(followingCommandPort.findByUserIdAndTargetUserId(targetUserId, userId)) + .thenReturn(Optional.of(Following.withoutId(targetUserId, userId))); + + // when + userBlockService.changeBlockState(new UserBlockCommand(userId, targetUserId, true)); + + // then + verify(followingCommandPort, times(2)).deleteFollowing(any(Following.class), any(User.class)); + assertThat(user.getFollowerCount()).isZero(); + assertThat(target.getFollowerCount()).isZero(); + } + + @Test + @DisplayName("이미 차단한 사용자를 다시 차단하면 예외가 발생한다.") + void block_alreadyBlocked() { + // given + Long userId = 1L, targetUserId = 2L; + when(userBlockCommandPort.findByUserIdAndTargetUserId(userId, targetUserId)) + .thenReturn(Optional.of(UserBlock.withoutId(userId, targetUserId))); + + // when & then + assertThatThrownBy(() -> userBlockService.changeBlockState(new UserBlockCommand(userId, targetUserId, true))) + .isInstanceOf(InvalidStateException.class); + verify(userBlockCommandPort, never()).save(any()); + } + + @Test + @DisplayName("자기 자신을 차단하면 예외가 발생한다.") + void block_self() { + assertThatThrownBy(() -> userBlockService.changeBlockState(new UserBlockCommand(1L, 1L, true))) + .isInstanceOf(BusinessException.class); + } + } + + @Nested + @DisplayName("차단 해제 요청(type = false)") + class Unblock { + + @Test + @DisplayName("차단을 해제하면 차단 관계만 삭제하고 팔로우는 복구하지 않는다.") + void unblock_doesNotRestoreFollow() { + // given + Long userId = 1L, targetUserId = 2L; + when(userBlockCommandPort.findByUserIdAndTargetUserId(userId, targetUserId)) + .thenReturn(Optional.of(UserBlock.withoutId(userId, targetUserId))); + + // when + Boolean result = userBlockService.changeBlockState(new UserBlockCommand(userId, targetUserId, false)); + + // then + assertThat(result).isFalse(); + verify(userBlockCommandPort).deleteBlock(any(UserBlock.class)); + verify(followingCommandPort, never()).save(any(), any()); + verify(userCommandPort, never()).findByIdWithLock(any()); + } + + @Test + @DisplayName("차단하지 않은 사용자를 해제하면 예외가 발생한다.") + void unblock_notBlocked() { + // given + Long userId = 1L, targetUserId = 2L; + when(userBlockCommandPort.findByUserIdAndTargetUserId(userId, targetUserId)).thenReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> userBlockService.changeBlockState(new UserBlockCommand(userId, targetUserId, false))) + .isInstanceOf(InvalidStateException.class); + verify(userBlockCommandPort, never()).deleteBlock(any()); + } + } + + private User createUserWithFollowerCount(Long id, int followerCount) { + return User.builder() + .id(id) + .nickname("테스터" + id) + .userRole("USER") + .oauth2Id("kakao_" + id) + .followerCount(followerCount) + .recordReviewCount(0) + .build(); + } +} diff --git a/src/test/java/konkuk/thip/user/application/service/UserFollowServiceTest.java b/src/test/java/konkuk/thip/user/application/service/UserFollowServiceTest.java index d11a8c673..a78d96369 100644 --- a/src/test/java/konkuk/thip/user/application/service/UserFollowServiceTest.java +++ b/src/test/java/konkuk/thip/user/application/service/UserFollowServiceTest.java @@ -4,6 +4,7 @@ import konkuk.thip.notification.application.service.FeedNotificationOrchestratorSyncImpl; import konkuk.thip.user.application.port.in.dto.UserFollowCommand; import konkuk.thip.user.application.port.out.FollowingCommandPort; +import konkuk.thip.user.application.port.out.UserBlockQueryPort; import konkuk.thip.user.application.port.out.UserCommandPort; import konkuk.thip.user.application.service.following.UserFollowService; import konkuk.thip.user.domain.Following; @@ -27,6 +28,7 @@ class UserFollowServiceTest { private FollowingCommandPort followingCommandPort; private UserCommandPort userCommandPort; + private UserBlockQueryPort userBlockQueryPort; private UserFollowService userFollowService; private FeedNotificationOrchestratorSyncImpl feedNotificationOrchestratorSyncImpl; @@ -35,8 +37,9 @@ class UserFollowServiceTest { void setUp() { followingCommandPort = mock(FollowingCommandPort.class); userCommandPort = mock(UserCommandPort.class); + userBlockQueryPort = mock(UserBlockQueryPort.class); feedNotificationOrchestratorSyncImpl = mock(FeedNotificationOrchestratorSyncImpl.class); - userFollowService = new UserFollowService(followingCommandPort, userCommandPort, feedNotificationOrchestratorSyncImpl); + userFollowService = new UserFollowService(followingCommandPort, userCommandPort, userBlockQueryPort, feedNotificationOrchestratorSyncImpl); } @Nested diff --git a/src/test/java/konkuk/thip/user/domain/UserBlockTest.java b/src/test/java/konkuk/thip/user/domain/UserBlockTest.java new file mode 100644 index 000000000..825e26587 --- /dev/null +++ b/src/test/java/konkuk/thip/user/domain/UserBlockTest.java @@ -0,0 +1,58 @@ +package konkuk.thip.user.domain; + +import konkuk.thip.common.entity.StatusType; +import konkuk.thip.common.exception.InvalidStateException; +import konkuk.thip.common.exception.code.ErrorCode; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@DisplayName("[단위] UserBlock 도메인 테스트") +class UserBlockTest { + + @Test + @DisplayName("withoutId 로 생성하면 ACTIVE 상태의 차단 관계가 만들어진다.") + void withoutId_creates_active_block() { + UserBlock userBlock = UserBlock.withoutId(1L, 2L); + + assertThat(userBlock.getUserId()).isEqualTo(1L); + assertThat(userBlock.getBlockedUserId()).isEqualTo(2L); + assertThat(userBlock.getStatus()).isEqualTo(StatusType.ACTIVE); + } + + @Nested + @DisplayName("validateBlockState") + class ValidateBlockState { + + @Test + @DisplayName("차단 관계가 없는 상태에서 차단 요청하면 true 를 반환한다.") + void block_request_when_not_blocked() { + assertThat(UserBlock.validateBlockState(false, true)).isTrue(); + } + + @Test + @DisplayName("차단 관계가 있는 상태에서 해제 요청하면 false 를 반환한다.") + void unblock_request_when_blocked() { + assertThat(UserBlock.validateBlockState(true, false)).isFalse(); + } + + @Test + @DisplayName("이미 차단한 사용자를 다시 차단하면 예외가 발생한다.") + void block_request_when_already_blocked() { + assertThatThrownBy(() -> UserBlock.validateBlockState(true, true)) + .isInstanceOf(InvalidStateException.class) + .hasMessageContaining(ErrorCode.USER_ALREADY_BLOCKED.getMessage()); + } + + @Test + @DisplayName("차단하지 않은 사용자를 해제하면 예외가 발생한다.") + void unblock_request_when_not_blocked() { + assertThatThrownBy(() -> UserBlock.validateBlockState(false, false)) + .isInstanceOf(InvalidStateException.class) + .hasMessageContaining(ErrorCode.USER_ALREADY_UNBLOCKED.getMessage()); + } + } +} diff --git a/src/test/resources/application-test.properties b/src/test/resources/application-test.properties new file mode 100644 index 000000000..98bc6832f --- /dev/null +++ b/src/test/resources/application-test.properties @@ -0,0 +1,6 @@ +# CI 에서는 application.yml(dev) 이 프로필과 무관하게 항상 로드되어 MySQL 전용 설정이 test 프로필까지 상속된다. +# H2 는 SET time_zone 문법을 모른다. +spring.datasource.hikari.connection-init-sql= +# 테스트가 jdbcTemplate 로 created_at 을 직접 쓰는 곳이 있어, JVM 타임존과 어긋나면 커서 경계가 깨진다. +# build.gradle 의 test task 가 JVM 을 Asia/Seoul 로 고정하므로 여기도 같은 값으로 맞춘다. +spring.jpa.properties.hibernate.jdbc.time_zone=Asia/Seoul