From b05a063b16a0c255248ca5d0eb29226238584336 Mon Sep 17 00:00:00 2001 From: pizzazoa Date: Sat, 4 Apr 2026 16:20:29 +0900 Subject: [PATCH 1/7] =?UTF-8?q?feat():=20=EC=9E=90=EC=9C=A0=EA=B2=8C?= =?UTF-8?q?=EC=8B=9C=ED=8C=90=20=EA=B4=80=EB=A0=A8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 메인에 노출되는 최근 글 수 3 -> 5 - 수정 시간을 응답에 포함 --- .../response/GetCommunityMainResponse.java | 2 +- .../application/CommunityQueryService.java | 2 +- .../FreeBoardPostPreviewResponse.java | 4 +- .../FreeBoardPostQueryService.java | 3 +- .../CommunityQueryServiceTest.java | 43 +++++++++++++++++++ 5 files changed, 50 insertions(+), 4 deletions(-) diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/community/api/dto/response/GetCommunityMainResponse.java b/src/main/java/org/devkor/apu/saerok_server/domain/community/api/dto/response/GetCommunityMainResponse.java index 294dd10f..a83e8074 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/community/api/dto/response/GetCommunityMainResponse.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/community/api/dto/response/GetCommunityMainResponse.java @@ -17,6 +17,6 @@ public record GetCommunityMainResponse( @Schema(description = "동정 요청 새록 목록 (최대 3개)") List pendingCollections, - @Schema(description = "최근 자유게시판 글 (최대 3개)") + @Schema(description = "최근 자유게시판 글 (최대 5개)") List recentFreeBoardPosts ) {} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/community/application/CommunityQueryService.java b/src/main/java/org/devkor/apu/saerok_server/domain/community/application/CommunityQueryService.java index 3aa7f25c..45067b1e 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/community/application/CommunityQueryService.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/community/application/CommunityQueryService.java @@ -33,7 +33,7 @@ public GetCommunityMainResponse getCommunityMain(Long userId) { List recentCollections = communityRepository.findRecentPublicCollections(mainCommand); List popularCollections = communityRepository.findPopularCollections(mainCommand); List pendingCollections = communityRepository.findPendingBirdIdCollections(pendingCommand); - List recentFreeBoardPosts = freeBoardPostQueryService.getRecentPostsForMain(3); + List recentFreeBoardPosts = freeBoardPostQueryService.getRecentPostsForMain(5); return new GetCommunityMainResponse( dataAssembler.toCollectionInfos(recentCollections, userId), diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/freeboard/api/dto/response/FreeBoardPostPreviewResponse.java b/src/main/java/org/devkor/apu/saerok_server/domain/freeboard/api/dto/response/FreeBoardPostPreviewResponse.java index 715cd155..a7fbfdf4 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/freeboard/api/dto/response/FreeBoardPostPreviewResponse.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/freeboard/api/dto/response/FreeBoardPostPreviewResponse.java @@ -18,6 +18,8 @@ public record FreeBoardPostPreviewResponse( @Schema(description = "게시글 내용", example = "오늘 한강공원에서 백로를 발견했어요!", requiredMode = Schema.RequiredMode.REQUIRED) String content, @Schema(description = "작성 시각", example = "2025-07-05T03:10:00", requiredMode = Schema.RequiredMode.REQUIRED) - LocalDateTime createdAt + LocalDateTime createdAt, + @Schema(description = "최종 수정 시각", example = "2025-07-05T04:20:00", requiredMode = Schema.RequiredMode.REQUIRED) + LocalDateTime updatedAt ) { } diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/freeboard/application/FreeBoardPostQueryService.java b/src/main/java/org/devkor/apu/saerok_server/domain/freeboard/application/FreeBoardPostQueryService.java index e87d0eeb..9b4abef6 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/freeboard/application/FreeBoardPostQueryService.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/freeboard/application/FreeBoardPostQueryService.java @@ -117,7 +117,8 @@ public List getRecentPostsForMain(int limit) { profileImageUrls.get(authorId), thumbnailProfileImageUrls.get(authorId), post.getContent(), - OffsetDateTimeLocalizer.toSeoulLocalDateTime(post.getCreatedAt()) + OffsetDateTimeLocalizer.toSeoulLocalDateTime(post.getCreatedAt()), + OffsetDateTimeLocalizer.toSeoulLocalDateTime(post.getUpdatedAt()) ); }) .toList(); diff --git a/src/test/java/org/devkor/apu/saerok_server/domain/community/application/CommunityQueryServiceTest.java b/src/test/java/org/devkor/apu/saerok_server/domain/community/application/CommunityQueryServiceTest.java index 4cf6b0bf..b0596ea2 100644 --- a/src/test/java/org/devkor/apu/saerok_server/domain/community/application/CommunityQueryServiceTest.java +++ b/src/test/java/org/devkor/apu/saerok_server/domain/community/application/CommunityQueryServiceTest.java @@ -4,7 +4,9 @@ import org.devkor.apu.saerok_server.domain.collection.core.entity.UserBirdCollection; import org.devkor.apu.saerok_server.domain.community.api.dto.common.CommunityCollectionInfo; import org.devkor.apu.saerok_server.domain.community.api.dto.response.GetCommunityCollectionsResponse; +import org.devkor.apu.saerok_server.domain.community.api.dto.response.GetCommunityMainResponse; import org.devkor.apu.saerok_server.domain.community.application.dto.CommunityQueryCommand; +import org.devkor.apu.saerok_server.domain.freeboard.api.dto.response.FreeBoardPostPreviewResponse; import org.devkor.apu.saerok_server.domain.community.core.repository.CommunityRepository; import org.devkor.apu.saerok_server.domain.dex.bird.core.entity.Bird; import org.devkor.apu.saerok_server.domain.dex.bird.core.entity.BirdName; @@ -24,6 +26,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; import static org.mockito.Mockito.verifyNoInteractions; import static org.springframework.test.util.ReflectionTestUtils.setField; @@ -213,4 +216,44 @@ void getRecentCollections_withMixedCollections_correctlySetsParticipantCount() { assertThat(secondItem.bird().koreanName()).isEqualTo("까치"); assertThat(secondItem.note()).isEqualTo("까치를 발견했어요!"); } + + @Test + @DisplayName("커뮤니티 메인 조회 시 자유게시판 최신 글 5건이 포함된다") + void getCommunityMain_returnsRecentFreeBoardPosts() { + // Given + Long userId = 1L; + + given(communityRepository.findRecentPublicCollections(org.mockito.ArgumentMatchers.any())) + .willReturn(List.of()); + given(communityRepository.findPopularCollections(org.mockito.ArgumentMatchers.any())) + .willReturn(List.of()); + given(communityRepository.findPendingBirdIdCollections(org.mockito.ArgumentMatchers.any())) + .willReturn(List.of()); + given(dataAssembler.toCollectionInfos(List.of(), userId)) + .willReturn(List.of()); + + List freeBoardPosts = List.of( + new FreeBoardPostPreviewResponse(1L, 10L, "유저A", "https://img/a.jpg", "https://img/thumb/a.webp", + "오늘 한강에서 백로를 봤어요!", LocalDateTime.of(2025, 7, 5, 15, 0), LocalDateTime.of(2025, 7, 5, 15, 0)), + new FreeBoardPostPreviewResponse(2L, 11L, "유저B", "https://img/b.jpg", "https://img/thumb/b.webp", + "참새 귀엽다", LocalDateTime.of(2025, 7, 5, 14, 30), LocalDateTime.of(2025, 7, 5, 14, 30)), + new FreeBoardPostPreviewResponse(3L, 12L, "유저C", "https://img/c.jpg", "https://img/thumb/c.webp", + "까치 발견!", LocalDateTime.of(2025, 7, 5, 14, 0), LocalDateTime.of(2025, 7, 5, 14, 0)), + new FreeBoardPostPreviewResponse(4L, 13L, "유저D", "https://img/d.jpg", "https://img/thumb/d.webp", + "비둘기가 많네요", LocalDateTime.of(2025, 7, 5, 13, 30), LocalDateTime.of(2025, 7, 5, 13, 30)), + new FreeBoardPostPreviewResponse(5L, 14L, "유저E", "https://img/e.jpg", "https://img/thumb/e.webp", + "딱따구리 소리가 들려요", LocalDateTime.of(2025, 7, 5, 13, 0), LocalDateTime.of(2025, 7, 5, 13, 0)) + ); + given(freeBoardPostQueryService.getRecentPostsForMain(5)) + .willReturn(freeBoardPosts); + + // When + GetCommunityMainResponse response = communityQueryService.getCommunityMain(userId); + + // Then + assertThat(response.recentFreeBoardPosts()).hasSize(5); + assertThat(response.recentFreeBoardPosts().get(0).postId()).isEqualTo(1L); + assertThat(response.recentFreeBoardPosts().get(4).postId()).isEqualTo(5L); + then(freeBoardPostQueryService).should().getRecentPostsForMain(5); + } } From 14fa0ef347655a480a64b9eb31f932e047af4c7e Mon Sep 17 00:00:00 2001 From: pizzazoa Date: Sat, 4 Apr 2026 16:26:44 +0900 Subject: [PATCH 2/7] =?UTF-8?q?feat():=20=EC=9E=90=EC=9C=A0=EA=B2=8C?= =?UTF-8?q?=EC=8B=9C=ED=8C=90=20=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EC=8B=9C=EC=97=90=EB=8F=84=20=EC=88=98=EC=A0=95=EB=90=9C=20?= =?UTF-8?q?=EC=8B=9C=EA=B0=81=20=EC=9D=91=EB=8B=B5=EC=97=90=20=ED=8F=AC?= =?UTF-8?q?=ED=95=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../freeboard/api/dto/response/GetFreeBoardPostsResponse.java | 4 +++- .../freeboard/application/FreeBoardPostQueryService.java | 3 ++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/freeboard/api/dto/response/GetFreeBoardPostsResponse.java b/src/main/java/org/devkor/apu/saerok_server/domain/freeboard/api/dto/response/GetFreeBoardPostsResponse.java index d50c2f1f..2aaa1e2d 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/freeboard/api/dto/response/GetFreeBoardPostsResponse.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/freeboard/api/dto/response/GetFreeBoardPostsResponse.java @@ -30,7 +30,9 @@ public record Item( @Schema(description = "내가 작성한 게시글 여부 (비로그인 시 false)", example = "false", requiredMode = Schema.RequiredMode.REQUIRED) boolean isMine, @Schema(description = "작성 시각", example = "2025-07-05T03:10:00", requiredMode = Schema.RequiredMode.REQUIRED) - LocalDateTime createdAt + LocalDateTime createdAt, + @Schema(description = "최종 수정 시각", example = "2025-07-05T04:20:00", requiredMode = Schema.RequiredMode.REQUIRED) + LocalDateTime updatedAt ) { } } diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/freeboard/application/FreeBoardPostQueryService.java b/src/main/java/org/devkor/apu/saerok_server/domain/freeboard/application/FreeBoardPostQueryService.java index 9b4abef6..778984e2 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/freeboard/application/FreeBoardPostQueryService.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/freeboard/application/FreeBoardPostQueryService.java @@ -60,7 +60,8 @@ public GetFreeBoardPostsResponse getPosts(Long userId, FreeBoardPostQueryCommand post.getContent(), commentCounts.getOrDefault(post.getId(), 0L), isMine, - OffsetDateTimeLocalizer.toSeoulLocalDateTime(post.getCreatedAt()) + OffsetDateTimeLocalizer.toSeoulLocalDateTime(post.getCreatedAt()), + OffsetDateTimeLocalizer.toSeoulLocalDateTime(post.getUpdatedAt()) ); }) .toList(); From b4459760edcd0fa9fc596d7c6ee51d81c2208dcb Mon Sep 17 00:00:00 2001 From: pizzazoa Date: Sat, 4 Apr 2026 18:32:28 +0900 Subject: [PATCH 3/7] =?UTF-8?q?refactor():=20=EC=95=8C=EB=A6=BC=20?= =?UTF-8?q?=EB=B0=9C=EC=86=A1=20=EB=A1=9C=EC=A7=81=EC=9D=84=20=EC=9D=B4?= =?UTF-8?q?=EB=B2=A4=ED=8A=B8=20-=20=EB=A6=AC=EC=8A=A4=EB=84=88=20?= =?UTF-8?q?=EA=B5=AC=EC=A1=B0=EB=A1=9C=20=EB=A6=AC=ED=8C=A9=ED=86=A0?= =?UTF-8?q?=EB=A7=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../BirdIdSuggestionCommandService.java | 21 +-- .../application/CollectionCommandService.java | 2 +- .../CollectionCommentCommandService.java | 50 ++---- .../CollectionLikeCommandService.java | 19 +-- .../event/CollectionNotificationEvent.java | 22 +++ .../event/CollectionNotificationWorker.java | 94 +++++++++++ .../payload/ActionNotificationPayload.java | 2 +- .../payload/BatchedNotificationPayload.java | 2 +- .../payload/NotificationPayloadExtras.java | 21 +++ .../payload/SystemNotificationPayload.java | 2 +- .../BirdIdSuggestionCommandServiceTest.java | 51 ++---- .../CollectionCommandServiceTest.java | 12 +- .../CollectionCommentCommandServiceTest.java | 100 ++++------- .../CollectionLikeCommandServiceTest.java | 46 ++---- .../CollectionNotificationWorkerTest.java | 156 ++++++++++++++++++ 15 files changed, 394 insertions(+), 206 deletions(-) create mode 100644 src/main/java/org/devkor/apu/saerok_server/domain/collection/application/event/CollectionNotificationEvent.java create mode 100644 src/main/java/org/devkor/apu/saerok_server/domain/collection/application/event/CollectionNotificationWorker.java create mode 100644 src/main/java/org/devkor/apu/saerok_server/domain/notification/application/model/payload/NotificationPayloadExtras.java create mode 100644 src/test/java/org/devkor/apu/saerok_server/domain/collection/application/event/CollectionNotificationWorkerTest.java diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/BirdIdSuggestionCommandService.java b/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/BirdIdSuggestionCommandService.java index 0b1964a5..0140728c 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/BirdIdSuggestionCommandService.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/BirdIdSuggestionCommandService.java @@ -1,23 +1,21 @@ package org.devkor.apu.saerok_server.domain.collection.application; -import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; import org.devkor.apu.saerok_server.domain.collection.api.dto.response.*; +import org.devkor.apu.saerok_server.domain.collection.application.event.CollectionNotificationEvent; import org.devkor.apu.saerok_server.domain.collection.core.entity.*; import org.devkor.apu.saerok_server.domain.collection.core.repository.*; import org.devkor.apu.saerok_server.domain.dex.bird.core.entity.Bird; import org.devkor.apu.saerok_server.domain.dex.bird.core.repository.BirdRepository; -import org.devkor.apu.saerok_server.domain.notification.application.model.dsl.ActionKind; -import org.devkor.apu.saerok_server.domain.notification.application.model.dsl.Actor; -import org.devkor.apu.saerok_server.domain.notification.application.facade.NotifyActionDsl; -import org.devkor.apu.saerok_server.domain.notification.application.model.dsl.Target; import org.devkor.apu.saerok_server.domain.admin.stat.application.BirdIdRequestHistoryRecorder; import org.devkor.apu.saerok_server.domain.user.core.entity.User; import org.devkor.apu.saerok_server.domain.user.core.repository.UserRepository; import org.devkor.apu.saerok_server.global.shared.exception.BadRequestException; import org.devkor.apu.saerok_server.global.shared.exception.ForbiddenException; import org.devkor.apu.saerok_server.global.shared.exception.NotFoundException; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import java.time.OffsetDateTime; @@ -30,8 +28,8 @@ public class BirdIdSuggestionCommandService { private final CollectionRepository collectionRepo; private final BirdRepository birdRepo; private final UserRepository userRepo; - private final NotifyActionDsl notifyAction; private final BirdIdRequestHistoryRecorder birdReqHistory; + private final ApplicationEventPublisher eventPublisher; public SuggestBirdIdResponse suggest(Long userId, Long collectionId, Long birdId) { User user = userRepo.findById(userId) @@ -88,12 +86,11 @@ public SuggestBirdIdResponse suggest(Long userId, Long collectionId, Long birdId } // 최초 제안인 경우에만 알림 발송 if (!birdAlreadySuggested) { - notifyAction - .by(Actor.of(userId, user.getNickname())) - .on(Target.collection(collectionId)) - .did(ActionKind.SUGGEST_BIRD_ID) - .suggestedName(bird.getName().getKoreanName()) - .to(collection.getUser().getId()); + eventPublisher.publishEvent(new CollectionNotificationEvent.BirdIdSuggested( + userId, user.getNickname(), + collectionId, collection.getUser().getId(), + bird.getName().getKoreanName() + )); } return new SuggestBirdIdResponse(suggestionId); diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionCommandService.java b/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionCommandService.java index fadcc9a2..0124445f 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionCommandService.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionCommandService.java @@ -1,6 +1,5 @@ package org.devkor.apu.saerok_server.domain.collection.application; -import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; import org.devkor.apu.saerok_server.domain.collection.api.dto.response.UpdateCollectionResponse; import org.devkor.apu.saerok_server.domain.collection.application.dto.CreateCollectionCommand; @@ -26,6 +25,7 @@ import org.devkor.apu.saerok_server.global.shared.infra.ImageService; import org.locationtech.jts.geom.Point; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import java.time.OffsetDateTime; import java.util.List; diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionCommentCommandService.java b/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionCommentCommandService.java index ad179d0b..55dc1c50 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionCommentCommandService.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionCommentCommandService.java @@ -1,24 +1,22 @@ package org.devkor.apu.saerok_server.domain.collection.application; -import jakarta.transaction.Transactional; import lombok.RequiredArgsConstructor; import org.devkor.apu.saerok_server.domain.collection.api.dto.request.CreateCollectionCommentRequest; import org.devkor.apu.saerok_server.domain.collection.api.dto.request.UpdateCollectionCommentRequest; import org.devkor.apu.saerok_server.domain.collection.api.dto.response.CreateCollectionCommentResponse; import org.devkor.apu.saerok_server.domain.collection.api.dto.response.UpdateCollectionCommentResponse; +import org.devkor.apu.saerok_server.domain.collection.application.event.CollectionNotificationEvent; import org.devkor.apu.saerok_server.domain.collection.core.entity.UserBirdCollection; import org.devkor.apu.saerok_server.domain.collection.core.entity.UserBirdCollectionComment; import org.devkor.apu.saerok_server.domain.collection.core.repository.CollectionCommentRepository; import org.devkor.apu.saerok_server.domain.collection.core.repository.CollectionRepository; -import org.devkor.apu.saerok_server.domain.notification.application.model.dsl.ActionKind; -import org.devkor.apu.saerok_server.domain.notification.application.model.dsl.Actor; -import org.devkor.apu.saerok_server.domain.notification.application.facade.NotifyActionDsl; -import org.devkor.apu.saerok_server.domain.notification.application.model.dsl.Target; import org.devkor.apu.saerok_server.domain.user.core.entity.User; import org.devkor.apu.saerok_server.domain.user.core.repository.UserRepository; import org.devkor.apu.saerok_server.global.shared.exception.ForbiddenException; import org.devkor.apu.saerok_server.global.shared.exception.NotFoundException; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; @Service @Transactional @@ -28,7 +26,7 @@ public class CollectionCommentCommandService { private final CollectionCommentRepository commentRepository; private final CollectionRepository collectionRepository; private final UserRepository userRepository; - private final NotifyActionDsl notifyAction; + private final ApplicationEventPublisher eventPublisher; /* 댓글 작성 */ public CreateCollectionCommentResponse createComment(Long userId, @@ -70,39 +68,13 @@ public CreateCollectionCommentResponse createComment(Long userId, commentRepository.save(comment); // 알림 전송 - if (parentComment != null) { - // 대댓글인 경우 - // 1) 원댓글 작성자에게 REPLY 알림 - if (!parentComment.getUser().getId().equals(userId)) { - notifyAction - .by(Actor.of(userId, user.getNickname())) - .on(Target.comment(parentComment.getId())) - .did(ActionKind.REPLY) - .comment(req.content()) - .to(parentComment.getUser().getId()); - } - - // 2) 컬렉션 소유자에게 COMMENT 알림 (원댓글 작성자와 다른 경우에만) - if (!collection.getUser().getId().equals(userId) - && !collection.getUser().getId().equals(parentComment.getUser().getId())) { - notifyAction - .by(Actor.of(userId, user.getNickname())) - .on(Target.collection(collectionId)) - .did(ActionKind.COMMENT) - .comment(req.content()) - .to(collection.getUser().getId()); - } - } else { - // 원댓글인 경우 - if (!collection.getUser().getId().equals(userId)) { - notifyAction - .by(Actor.of(userId, user.getNickname())) - .on(Target.collection(collectionId)) - .did(ActionKind.COMMENT) - .comment(req.content()) - .to(collection.getUser().getId()); - } - } + eventPublisher.publishEvent(new CollectionNotificationEvent.CommentCreated( + userId, user.getNickname(), + collectionId, collection.getUser().getId(), + parentComment != null ? parentComment.getId() : null, + parentComment != null ? parentComment.getUser().getId() : null, + req.content() + )); return new CreateCollectionCommentResponse(comment.getId()); } diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionLikeCommandService.java b/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionLikeCommandService.java index f166c2aa..bf6b0e85 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionLikeCommandService.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionLikeCommandService.java @@ -2,18 +2,16 @@ import lombok.RequiredArgsConstructor; import org.devkor.apu.saerok_server.domain.collection.api.dto.response.LikeStatusResponse; +import org.devkor.apu.saerok_server.domain.collection.application.event.CollectionNotificationEvent; import org.devkor.apu.saerok_server.domain.collection.core.entity.UserBirdCollection; import org.devkor.apu.saerok_server.domain.collection.core.entity.UserBirdCollectionLike; import org.devkor.apu.saerok_server.domain.collection.core.repository.CollectionLikeRepository; import org.devkor.apu.saerok_server.domain.collection.core.repository.CollectionRepository; -import org.devkor.apu.saerok_server.domain.notification.application.model.dsl.ActionKind; -import org.devkor.apu.saerok_server.domain.notification.application.model.dsl.Actor; -import org.devkor.apu.saerok_server.domain.notification.application.facade.NotifyActionDsl; -import org.devkor.apu.saerok_server.domain.notification.application.model.dsl.Target; import org.devkor.apu.saerok_server.domain.user.core.entity.User; import org.devkor.apu.saerok_server.domain.user.core.repository.UserRepository; import org.devkor.apu.saerok_server.global.shared.exception.BadRequestException; import org.devkor.apu.saerok_server.global.shared.exception.NotFoundException; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -25,7 +23,7 @@ public class CollectionLikeCommandService { private final CollectionLikeRepository collectionLikeRepository; private final CollectionRepository collectionRepository; private final UserRepository userRepository; - private final NotifyActionDsl notifyAction; + private final ApplicationEventPublisher eventPublisher; /** * 좋아요 토글 (추가/제거) @@ -51,13 +49,12 @@ public LikeStatusResponse toggleLikeResponse(Long userId, Long collectionId) { UserBirdCollectionLike like = new UserBirdCollectionLike(user, collection); collectionLikeRepository.save(like); - // 자신의 컬렉션이 아닌 경우에만 푸시 알림 발송 + // 알림 전송 if (!collection.getUser().getId().equals(userId)) { - notifyAction - .by(Actor.of(userId, user.getNickname())) - .on(Target.collection(collectionId)) - .did(ActionKind.LIKE) - .to(collection.getUser().getId()); + eventPublisher.publishEvent(new CollectionNotificationEvent.CollectionLiked( + userId, user.getNickname(), + collectionId, collection.getUser().getId() + )); } return new LikeStatusResponse(true); diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/event/CollectionNotificationEvent.java b/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/event/CollectionNotificationEvent.java new file mode 100644 index 00000000..ac1adc57 --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/event/CollectionNotificationEvent.java @@ -0,0 +1,22 @@ +package org.devkor.apu.saerok_server.domain.collection.application.event; + +public sealed interface CollectionNotificationEvent { + + record CommentCreated( + Long actorId, String actorNickname, + Long collectionId, Long collectionOwnerId, + Long parentCommentId, Long parentCommentOwnerId, + String commentContent + ) implements CollectionNotificationEvent {} + + record CollectionLiked( + Long actorId, String actorNickname, + Long collectionId, Long collectionOwnerId + ) implements CollectionNotificationEvent {} + + record BirdIdSuggested( + Long actorId, String actorNickname, + Long collectionId, Long collectionOwnerId, + String suggestedBirdName + ) implements CollectionNotificationEvent {} +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/event/CollectionNotificationWorker.java b/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/event/CollectionNotificationWorker.java new file mode 100644 index 00000000..9629e459 --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/event/CollectionNotificationWorker.java @@ -0,0 +1,94 @@ +package org.devkor.apu.saerok_server.domain.collection.application.event; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.devkor.apu.saerok_server.domain.notification.application.facade.NotifyActionDsl; +import org.devkor.apu.saerok_server.domain.notification.application.model.dsl.ActionKind; +import org.devkor.apu.saerok_server.domain.notification.application.model.dsl.Actor; +import org.devkor.apu.saerok_server.domain.notification.application.model.dsl.Target; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Slf4j +@Component +@RequiredArgsConstructor +public class CollectionNotificationWorker { + + private final NotifyActionDsl notifyAction; + + @Async("pushNotificationExecutor") + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handle(CollectionNotificationEvent.CommentCreated event) { + try { + Actor actor = Actor.of(event.actorId(), event.actorNickname()); + + if (event.parentCommentId() != null) { + // 대댓글: 원댓글 작성자에게 REPLY 알림 + if (!event.parentCommentOwnerId().equals(event.actorId())) { + notifyAction + .by(actor) + .on(Target.comment(event.parentCommentId())) + .did(ActionKind.REPLY) + .comment(event.commentContent()) + .to(event.parentCommentOwnerId()); + } + // 컬렉션 소유자에게 COMMENT 알림 (원댓글 작성자와 다른 경우에만) + if (!event.collectionOwnerId().equals(event.actorId()) + && !event.collectionOwnerId().equals(event.parentCommentOwnerId())) { + notifyAction + .by(actor) + .on(Target.collection(event.collectionId())) + .did(ActionKind.COMMENT) + .comment(event.commentContent()) + .to(event.collectionOwnerId()); + } + } else { + // 원댓글: 컬렉션 소유자에게 COMMENT 알림 + if (!event.collectionOwnerId().equals(event.actorId())) { + notifyAction + .by(actor) + .on(Target.collection(event.collectionId())) + .did(ActionKind.COMMENT) + .comment(event.commentContent()) + .to(event.collectionOwnerId()); + } + } + } catch (Exception e) { + log.error("Failed to send collection comment notification: collectionId={}, actorId={}", + event.collectionId(), event.actorId(), e); + } + } + + @Async("pushNotificationExecutor") + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handle(CollectionNotificationEvent.CollectionLiked event) { + try { + notifyAction + .by(Actor.of(event.actorId(), event.actorNickname())) + .on(Target.collection(event.collectionId())) + .did(ActionKind.LIKE) + .to(event.collectionOwnerId()); + } catch (Exception e) { + log.error("Failed to send collection like notification: collectionId={}, actorId={}", + event.collectionId(), event.actorId(), e); + } + } + + @Async("pushNotificationExecutor") + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handle(CollectionNotificationEvent.BirdIdSuggested event) { + try { + notifyAction + .by(Actor.of(event.actorId(), event.actorNickname())) + .on(Target.collection(event.collectionId())) + .did(ActionKind.SUGGEST_BIRD_ID) + .suggestedName(event.suggestedBirdName()) + .to(event.collectionOwnerId()); + } catch (Exception e) { + log.error("Failed to send bird ID suggestion notification: collectionId={}, actorId={}", + event.collectionId(), event.actorId(), e); + } + } +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/model/payload/ActionNotificationPayload.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/model/payload/ActionNotificationPayload.java index 4f54e398..e070cc85 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/model/payload/ActionNotificationPayload.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/model/payload/ActionNotificationPayload.java @@ -21,7 +21,7 @@ public record ActionNotificationPayload( ) implements NotificationPayload { public ActionNotificationPayload { - extras = (extras == null) ? Map.of() : Map.copyOf(extras); + extras = NotificationPayloadExtras.sanitize(extras); } @Override diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/model/payload/BatchedNotificationPayload.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/model/payload/BatchedNotificationPayload.java index 592c6528..9ec1e880 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/model/payload/BatchedNotificationPayload.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/model/payload/BatchedNotificationPayload.java @@ -25,7 +25,7 @@ public record BatchedNotificationPayload( ) implements NotificationPayload { public BatchedNotificationPayload { - extras = (extras == null) ? Map.of() : Map.copyOf(extras); + extras = NotificationPayloadExtras.sanitize(extras); } public static BatchedNotificationPayload fromBatch(NotificationBatch batch) { diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/model/payload/NotificationPayloadExtras.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/model/payload/NotificationPayloadExtras.java new file mode 100644 index 00000000..492e042d --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/model/payload/NotificationPayloadExtras.java @@ -0,0 +1,21 @@ +package org.devkor.apu.saerok_server.domain.notification.application.model.payload; + +import java.util.Map; +import java.util.stream.Collectors; + +final class NotificationPayloadExtras { + + private NotificationPayloadExtras() { + } + + static Map sanitize(Map extras) { + if (extras == null || extras.isEmpty()) { + return Map.of(); + } + + return extras.entrySet().stream() + .filter(entry -> entry.getKey() != null) + .filter(entry -> entry.getValue() != null) + .collect(Collectors.toUnmodifiableMap(Map.Entry::getKey, Map.Entry::getValue)); + } +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/model/payload/SystemNotificationPayload.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/model/payload/SystemNotificationPayload.java index 6dbed226..6142df59 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/model/payload/SystemNotificationPayload.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/model/payload/SystemNotificationPayload.java @@ -21,6 +21,6 @@ public record SystemNotificationPayload( ) implements NotificationPayload { public SystemNotificationPayload { - extras = (extras == null) ? Map.of() : Map.copyOf(extras); + extras = NotificationPayloadExtras.sanitize(extras); } } diff --git a/src/test/java/org/devkor/apu/saerok_server/domain/collection/application/BirdIdSuggestionCommandServiceTest.java b/src/test/java/org/devkor/apu/saerok_server/domain/collection/application/BirdIdSuggestionCommandServiceTest.java index a24d5004..0a84b6e5 100644 --- a/src/test/java/org/devkor/apu/saerok_server/domain/collection/application/BirdIdSuggestionCommandServiceTest.java +++ b/src/test/java/org/devkor/apu/saerok_server/domain/collection/application/BirdIdSuggestionCommandServiceTest.java @@ -8,16 +8,10 @@ import org.devkor.apu.saerok_server.domain.collection.core.repository.CollectionRepository; import org.devkor.apu.saerok_server.domain.dex.bird.core.entity.Bird; import org.devkor.apu.saerok_server.domain.dex.bird.core.repository.BirdRepository; -import org.devkor.apu.saerok_server.domain.notification.application.facade.NotificationPublisher; -import org.devkor.apu.saerok_server.domain.notification.application.facade.NotifyActionDsl; -import org.devkor.apu.saerok_server.domain.notification.application.model.dsl.TargetType; -import org.devkor.apu.saerok_server.domain.notification.application.model.payload.ActionNotificationPayload; -import org.devkor.apu.saerok_server.domain.notification.application.model.payload.NotificationPayload; -import org.devkor.apu.saerok_server.domain.notification.core.entity.NotificationSubject; -import org.devkor.apu.saerok_server.domain.notification.core.entity.NotificationAction; +import org.devkor.apu.saerok_server.domain.admin.stat.application.BirdIdRequestHistoryRecorder; +import org.devkor.apu.saerok_server.domain.collection.application.event.CollectionNotificationEvent; import org.devkor.apu.saerok_server.domain.user.core.entity.User; import org.devkor.apu.saerok_server.domain.user.core.repository.UserRepository; -import org.devkor.apu.saerok_server.domain.admin.stat.application.BirdIdRequestHistoryRecorder; // ★ 추가 import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; @@ -26,11 +20,10 @@ import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.test.util.ReflectionTestUtils; -import java.util.Map; import java.util.Optional; -import java.util.HashMap; import static org.assertj.core.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; @@ -43,26 +36,15 @@ class BirdIdSuggestionCommandServiceTest { @Mock CollectionRepository collectionRepo; @Mock BirdRepository birdRepo; @Mock UserRepository userRepo; - @Mock NotificationPublisher publisher; - @Mock BirdIdRequestHistoryRecorder birdReqHistory; // ★ 추가 + @Mock BirdIdRequestHistoryRecorder birdReqHistory; + @Mock ApplicationEventPublisher eventPublisher; BirdIdSuggestionCommandService sut; @BeforeEach void setUp() { - NotifyActionDsl notifyActionDsl = new NotifyActionDsl( - publisher, - (target, base) -> { - Map extras = base == null ? new HashMap<>() : new HashMap<>(base); - if (target.type() == TargetType.COLLECTION) { - extras.put("collectionId", target.id()); - extras.put("collectionImageUrl", "dummy"); - } - return extras; - } - ); sut = new BirdIdSuggestionCommandService( - suggestionRepo, collectionRepo, birdRepo, userRepo, notifyActionDsl, birdReqHistory // ★ 변경 + suggestionRepo, collectionRepo, birdRepo, userRepo, birdReqHistory, eventPublisher ); } @@ -127,17 +109,14 @@ void firstTime() { assertThat(res.suggestionId()).isEqualTo(999L); verify(suggestionRepo, times(2)).save(any(BirdIdSuggestion.class)); - ArgumentCaptor payloadCap = ArgumentCaptor.forClass(NotificationPayload.class); - verify(publisher).push(payloadCap.capture()); - - ActionNotificationPayload p = (ActionNotificationPayload) payloadCap.getValue(); - assertThat(p.subject()).isEqualTo(NotificationSubject.COLLECTION); - assertThat(p.action()).isEqualTo(NotificationAction.SUGGEST_BIRD_ID); - assertThat(p.recipientId()).isEqualTo(2L); - assertThat(p.actorId()).isEqualTo(1L); - Map extras = p.extras(); - assertThat(extras.get("collectionId")).isEqualTo(100L); - assertThat(extras).containsKey("collectionImageUrl"); + ArgumentCaptor eventCap = + ArgumentCaptor.forClass(CollectionNotificationEvent.BirdIdSuggested.class); + verify(eventPublisher).publishEvent(eventCap.capture()); + + var event = eventCap.getValue(); + assertThat(event.actorId()).isEqualTo(1L); + assertThat(event.collectionId()).isEqualTo(100L); + assertThat(event.collectionOwnerId()).isEqualTo(2L); } // 이하 기존 테스트 동일 … @@ -155,7 +134,7 @@ void alreadySuggested() { when(suggestionRepo.existsByCollectionIdAndBirdIdAndType(100L, 5L, SuggestionType.SUGGEST)).thenReturn(true); sut.suggest(1L, 100L, 5L); - verify(publisher, never()).push(any()); + verify(eventPublisher, never()).publishEvent(any(CollectionNotificationEvent.BirdIdSuggested.class)); } // 나머지 예외 케이스 테스트들 그대로… diff --git a/src/test/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionCommandServiceTest.java b/src/test/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionCommandServiceTest.java index 57b047e5..6f5a2236 100644 --- a/src/test/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionCommandServiceTest.java +++ b/src/test/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionCommandServiceTest.java @@ -34,6 +34,7 @@ import static org.assertj.core.api.Assertions.*; import static org.mockito.BDDMockito.*; import static org.mockito.Mockito.any; +import static org.mockito.Mockito.same; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -48,8 +49,8 @@ class CollectionCommandServiceTest { @Mock private ImageDomainService imageDomainService; @Mock private CollectionWebMapper collectionWebMapper; @Mock private ImageService imageService; - @Mock private BirdIdRequestHistoryRecorder birdReqHistory; // ★ 유지 - @Mock private ImageVariantService imageVariantService; // ★ 추가 + @Mock private BirdIdRequestHistoryRecorder birdReqHistory; + @Mock private ImageVariantService imageVariantService; private CollectionCommandService service; @@ -63,8 +64,8 @@ void setUp() { imageDomainService, collectionWebMapper, imageService, - birdReqHistory, // ★ 유지 - imageVariantService // ★ 추가 + birdReqHistory, + imageVariantService ); } @@ -121,8 +122,7 @@ void createCollection_success_withBird() { assertThat(saved.getNote()).isEqualTo(note); assertThat(saved.getAccessLevel()).isEqualTo(accessLevel); - // ‘대기 시작’ 기록 호출 여부는 상황에 따라 다를 수 있어 엄격 검증은 생략 - then(birdReqHistory).should().onCollectionCreatedIfPending(eq(saved), any()); + then(birdReqHistory).should().onCollectionCreatedIfPending(same(saved), any()); } @Test diff --git a/src/test/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionCommentCommandServiceTest.java b/src/test/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionCommentCommandServiceTest.java index 16184bf1..8c18606d 100644 --- a/src/test/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionCommentCommandServiceTest.java +++ b/src/test/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionCommentCommandServiceTest.java @@ -11,13 +11,7 @@ import org.devkor.apu.saerok_server.domain.collection.core.repository.CollectionRepository; import org.devkor.apu.saerok_server.domain.collection.core.service.CommentContentResolver; import org.devkor.apu.saerok_server.domain.collection.mapper.CollectionCommentWebMapper; -import org.devkor.apu.saerok_server.domain.notification.application.facade.NotificationPublisher; -import org.devkor.apu.saerok_server.domain.notification.application.facade.NotifyActionDsl; -import org.devkor.apu.saerok_server.domain.notification.application.model.dsl.TargetType; -import org.devkor.apu.saerok_server.domain.notification.application.model.payload.ActionNotificationPayload; -import org.devkor.apu.saerok_server.domain.notification.application.model.payload.NotificationPayload; -import org.devkor.apu.saerok_server.domain.notification.core.entity.NotificationAction; -import org.devkor.apu.saerok_server.domain.notification.core.entity.NotificationSubject; +import org.devkor.apu.saerok_server.domain.collection.application.event.CollectionNotificationEvent; import org.devkor.apu.saerok_server.domain.user.core.entity.User; import org.devkor.apu.saerok_server.domain.user.core.repository.UserRepository; import org.devkor.apu.saerok_server.domain.user.core.service.UserProfileImageUrlService; @@ -28,10 +22,9 @@ import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; -import java.util.Map; import java.util.Optional; -import java.util.HashMap; import static org.assertj.core.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; @@ -52,7 +45,7 @@ class CollectionCommentCommandServiceTest { @Mock CollectionCommentRepository commentRepo; @Mock CollectionRepository collectionRepo; @Mock UserRepository userRepo; - @Mock NotificationPublisher publisher; + @Mock ApplicationEventPublisher eventPublisher; @Mock CollectionCommentLikeRepository commentLikeRepo; @Mock CollectionCommentWebMapper collectionCommentWebMapper; @@ -80,22 +73,7 @@ private static UserBirdCollectionComment comment(long id, User u, UserBirdCollec @BeforeEach void init() { - NotifyActionDsl notifyActionDsl = new NotifyActionDsl( - publisher, - (target, base) -> { - Map extras = base == null ? new HashMap<>() : new HashMap<>(base); - if (target.type() == TargetType.COLLECTION) { - extras.put("collectionId", target.id()); - extras.put("collectionImageUrl", "dummy"); - } else if (target.type() == TargetType.COMMENT) { - extras.put("commentId", target.id()); - extras.put("collectionId", 999L); // dummy collection id - extras.put("collectionImageUrl", "dummy"); - } - return extras; - } - ); - sut = new CollectionCommentCommandService(commentRepo, collectionRepo, userRepo, notifyActionDsl); + sut = new CollectionCommentCommandService(commentRepo, collectionRepo, userRepo, eventPublisher); querySut = new CollectionCommentQueryService( commentRepo, collectionRepo, commentLikeRepo, collectionCommentWebMapper, userProfileImageUrlService, commentContentResolver @@ -124,18 +102,16 @@ void success() { assertThat(res.commentId()).isEqualTo(COMMENT_ID); verify(commentRepo).save(any()); - ArgumentCaptor payloadCap = ArgumentCaptor.forClass(NotificationPayload.class); - verify(publisher).push(payloadCap.capture()); - - ActionNotificationPayload p = (ActionNotificationPayload) payloadCap.getValue(); - assertThat(p.subject()).isEqualTo(NotificationSubject.COLLECTION); - assertThat(p.action()).isEqualTo(NotificationAction.COMMENT); - assertThat(p.recipientId()).isEqualTo(OTHER_ID); - assertThat(p.actorId()).isEqualTo(OWNER_ID); - Map extras = p.extras(); - assertThat(extras.get("collectionId")).isEqualTo(COLL_ID); - assertThat(extras.get("comment")).isEqualTo("Nice"); - assertThat(extras).containsKey("collectionImageUrl"); + ArgumentCaptor eventCap = + ArgumentCaptor.forClass(CollectionNotificationEvent.CommentCreated.class); + verify(eventPublisher).publishEvent(eventCap.capture()); + + var event = eventCap.getValue(); + assertThat(event.actorId()).isEqualTo(OWNER_ID); + assertThat(event.collectionId()).isEqualTo(COLL_ID); + assertThat(event.collectionOwnerId()).isEqualTo(OTHER_ID); + assertThat(event.parentCommentId()).isNull(); + assertThat(event.commentContent()).isEqualTo("Nice"); } @Test @DisplayName("사용자 없음 → NotFoundException") @@ -161,7 +137,9 @@ void ownCollectionComment_noPush() { assertThat(res.commentId()).isEqualTo(COMMENT_ID); verify(commentRepo).save(any()); - verifyNoInteractions(publisher); + + // 자기 컬렉션이어도 이벤트는 발행됨 (Worker에서 self 체크) + verify(eventPublisher).publishEvent(any(CollectionNotificationEvent.CommentCreated.class)); } @Test @DisplayName("대댓글 작성 성공 - 원댓글 작성자와 컬렉션 소유자 모두 다른 경우 (2개 알림)") @@ -192,24 +170,17 @@ void createReply_success_twoNotifications() { assertThat(res.commentId()).isEqualTo(replyId); verify(commentRepo).save(any()); - ArgumentCaptor payloadCap = ArgumentCaptor.forClass(NotificationPayload.class); - verify(publisher, times(2)).push(payloadCap.capture()); - - var notifications = payloadCap.getAllValues(); - - // 첫 번째 알림: 원댓글 작성자에게 REPLY 알림 - ActionNotificationPayload replyNotif = (ActionNotificationPayload) notifications.get(0); - assertThat(replyNotif.subject()).isEqualTo(NotificationSubject.COMMENT); - assertThat(replyNotif.action()).isEqualTo(NotificationAction.REPLY); - assertThat(replyNotif.recipientId()).isEqualTo(parentCommentOwnerId); - assertThat(replyNotif.actorId()).isEqualTo(commenterId); - - // 두 번째 알림: 컬렉션 소유자에게 COMMENT 알림 - ActionNotificationPayload commentNotif = (ActionNotificationPayload) notifications.get(1); - assertThat(commentNotif.subject()).isEqualTo(NotificationSubject.COLLECTION); - assertThat(commentNotif.action()).isEqualTo(NotificationAction.COMMENT); - assertThat(commentNotif.recipientId()).isEqualTo(collectionOwnerId); - assertThat(commentNotif.actorId()).isEqualTo(commenterId); + ArgumentCaptor eventCap = + ArgumentCaptor.forClass(CollectionNotificationEvent.CommentCreated.class); + verify(eventPublisher).publishEvent(eventCap.capture()); + + var event = eventCap.getValue(); + assertThat(event.actorId()).isEqualTo(commenterId); + assertThat(event.collectionId()).isEqualTo(COLL_ID); + assertThat(event.collectionOwnerId()).isEqualTo(collectionOwnerId); + assertThat(event.parentCommentId()).isEqualTo(parentCommentId); + assertThat(event.parentCommentOwnerId()).isEqualTo(parentCommentOwnerId); + assertThat(event.commentContent()).isEqualTo("reply content"); } @Test @DisplayName("대댓글 작성 성공 - 원댓글 작성자 = 컬렉션 소유자인 경우 (1개 알림)") @@ -237,14 +208,15 @@ void createReply_success_oneNotification() { assertThat(res.commentId()).isEqualTo(replyId); - ArgumentCaptor payloadCap = ArgumentCaptor.forClass(NotificationPayload.class); - verify(publisher, times(1)).push(payloadCap.capture()); + ArgumentCaptor eventCap = + ArgumentCaptor.forClass(CollectionNotificationEvent.CommentCreated.class); + verify(eventPublisher).publishEvent(eventCap.capture()); - // 원댓글 작성자에게만 REPLY 알림 (컬렉션 소유자와 동일인이므로 중복 제거됨) - ActionNotificationPayload notif = (ActionNotificationPayload) payloadCap.getValue(); - assertThat(notif.subject()).isEqualTo(NotificationSubject.COMMENT); - assertThat(notif.action()).isEqualTo(NotificationAction.REPLY); - assertThat(notif.recipientId()).isEqualTo(parentAndCollectionOwnerId); + var event = eventCap.getValue(); + assertThat(event.actorId()).isEqualTo(commenterId); + assertThat(event.parentCommentId()).isEqualTo(parentCommentId); + assertThat(event.parentCommentOwnerId()).isEqualTo(parentAndCollectionOwnerId); + assertThat(event.collectionOwnerId()).isEqualTo(parentAndCollectionOwnerId); } @Test @DisplayName("삭제된 댓글에 대댓글 작성 → ForbiddenException") diff --git a/src/test/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionLikeCommandServiceTest.java b/src/test/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionLikeCommandServiceTest.java index 0539d8fd..52f43531 100644 --- a/src/test/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionLikeCommandServiceTest.java +++ b/src/test/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionLikeCommandServiceTest.java @@ -5,13 +5,7 @@ import org.devkor.apu.saerok_server.domain.collection.core.entity.UserBirdCollectionLike; import org.devkor.apu.saerok_server.domain.collection.core.repository.CollectionLikeRepository; import org.devkor.apu.saerok_server.domain.collection.core.repository.CollectionRepository; -import org.devkor.apu.saerok_server.domain.notification.application.facade.NotificationPublisher; -import org.devkor.apu.saerok_server.domain.notification.application.facade.NotifyActionDsl; -import org.devkor.apu.saerok_server.domain.notification.application.model.dsl.TargetType; -import org.devkor.apu.saerok_server.domain.notification.application.model.payload.ActionNotificationPayload; -import org.devkor.apu.saerok_server.domain.notification.application.model.payload.NotificationPayload; -import org.devkor.apu.saerok_server.domain.notification.core.entity.NotificationSubject; -import org.devkor.apu.saerok_server.domain.notification.core.entity.NotificationAction; +import org.devkor.apu.saerok_server.domain.collection.application.event.CollectionNotificationEvent; import org.devkor.apu.saerok_server.domain.user.core.entity.User; import org.devkor.apu.saerok_server.domain.user.core.repository.UserRepository; import org.devkor.apu.saerok_server.global.shared.exception.NotFoundException; @@ -20,11 +14,10 @@ import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.test.util.ReflectionTestUtils; -import java.util.Map; import java.util.Optional; -import java.util.HashMap; import static org.junit.jupiter.api.Assertions.*; import static org.mockito.BDDMockito.given; @@ -38,23 +31,12 @@ class CollectionLikeCommandServiceTest { @Mock CollectionLikeRepository collectionLikeRepository; @Mock CollectionRepository collectionRepository; @Mock UserRepository userRepository; - @Mock NotificationPublisher publisher; + @Mock ApplicationEventPublisher eventPublisher; @BeforeEach void setUp() { - NotifyActionDsl notifyActionDsl = new NotifyActionDsl( - publisher, - (target, base) -> { - Map extras = base == null ? new HashMap<>() : new HashMap<>(base); - if (target.type() == TargetType.COLLECTION) { - extras.put("collectionId", target.id()); - extras.put("collectionImageUrl", "dummy"); - } - return extras; - } - ); collectionLikeCommandService = new CollectionLikeCommandService( - collectionLikeRepository, collectionRepository, userRepository, notifyActionDsl + collectionLikeRepository, collectionRepository, userRepository, eventPublisher ); } @@ -81,17 +63,13 @@ void toggleLike_addLike_success() { assertTrue(response.isLiked()); verify(collectionLikeRepository).existsByUserIdAndCollectionId(userId, collectionId); - ArgumentCaptor payloadCap = ArgumentCaptor.forClass(NotificationPayload.class); - verify(publisher).push(payloadCap.capture()); - - ActionNotificationPayload p = (ActionNotificationPayload) payloadCap.getValue(); - assertEquals(NotificationSubject.COLLECTION, p.subject()); - assertEquals(NotificationAction.LIKE, p.action()); - assertEquals(999L, p.recipientId()); - assertEquals(userId, p.actorId()); - Map extras = p.extras(); - assertEquals(collectionId, extras.get("collectionId")); - assertTrue(extras.containsKey("collectionImageUrl")); + ArgumentCaptor eventCap = + ArgumentCaptor.forClass(CollectionNotificationEvent.CollectionLiked.class); + verify(eventPublisher).publishEvent(eventCap.capture()); + + var event = eventCap.getValue(); + assertEquals(userId, event.actorId()); + assertEquals(999L, event.collectionOwnerId()); } @Test @@ -114,7 +92,7 @@ void toggleLike_removeLike_success() { assertFalse(response.isLiked()); verify(collectionLikeRepository).existsByUserIdAndCollectionId(userId, collectionId); verify(collectionLikeRepository).findByUserIdAndCollectionId(userId, collectionId); - verifyNoInteractions(publisher); + verifyNoInteractions(eventPublisher); } @Test diff --git a/src/test/java/org/devkor/apu/saerok_server/domain/collection/application/event/CollectionNotificationWorkerTest.java b/src/test/java/org/devkor/apu/saerok_server/domain/collection/application/event/CollectionNotificationWorkerTest.java new file mode 100644 index 00000000..3dbfc9ad --- /dev/null +++ b/src/test/java/org/devkor/apu/saerok_server/domain/collection/application/event/CollectionNotificationWorkerTest.java @@ -0,0 +1,156 @@ +package org.devkor.apu.saerok_server.domain.collection.application.event; + +import org.devkor.apu.saerok_server.domain.notification.application.facade.NotificationPublisher; +import org.devkor.apu.saerok_server.domain.notification.application.facade.NotifyActionDsl; +import org.devkor.apu.saerok_server.domain.notification.application.model.dsl.TargetType; +import org.devkor.apu.saerok_server.domain.notification.application.model.payload.ActionNotificationPayload; +import org.devkor.apu.saerok_server.domain.notification.application.model.payload.NotificationPayload; +import org.devkor.apu.saerok_server.domain.notification.application.port.TargetMetadataPort; +import org.devkor.apu.saerok_server.domain.notification.core.entity.NotificationAction; +import org.devkor.apu.saerok_server.domain.notification.core.entity.NotificationSubject; +import org.devkor.apu.saerok_server.domain.notification.core.entity.NotificationType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.times; + +@ExtendWith(MockitoExtension.class) +class CollectionNotificationWorkerTest { + + @Mock private NotificationPublisher publisher; + + private CollectionNotificationWorker worker; + + @BeforeEach + void setUp() { + TargetMetadataPort metadataPort = (target, baseExtras) -> { + Map extras = baseExtras == null ? new HashMap<>() : new HashMap<>(baseExtras); + + if (target.type() == TargetType.COLLECTION) { + extras.put("collectionId", target.id()); + extras.put("collectionImageUrl", "https://example.com/collections/" + target.id() + ".webp"); + } else { + extras.put("commentId", target.id()); + extras.put("collectionId", 999L); + extras.put("collectionImageUrl", "https://example.com/comments/" + target.id() + ".webp"); + } + return extras; + }; + + worker = new CollectionNotificationWorker(new NotifyActionDsl(publisher, metadataPort)); + } + + @Test + @DisplayName("대댓글 알림은 원댓글 작성자와 컬렉션 소유자에게 각각 생성된다") + void handle_replyComment_generatesTwoNotifications() { + worker.handle(new CollectionNotificationEvent.CommentCreated( + 1L, "replier", + 100L, 3L, + 200L, 2L, + "reply body" + )); + + ArgumentCaptor payloadCaptor = ArgumentCaptor.forClass(NotificationPayload.class); + verify(publisher, times(2)).push(payloadCaptor.capture()); + + List payloads = payloadCaptor.getAllValues().stream() + .map(ActionNotificationPayload.class::cast) + .toList(); + + assertThat(payloads) + .extracting(ActionNotificationPayload::recipientId, ActionNotificationPayload::type) + .containsExactlyInAnyOrder( + org.assertj.core.groups.Tuple.tuple(2L, NotificationType.REPLIED_TO_COMMENT), + org.assertj.core.groups.Tuple.tuple(3L, NotificationType.COMMENTED_ON_COLLECTION) + ); + + ActionNotificationPayload replyPayload = payloads.stream() + .filter(payload -> payload.type() == NotificationType.REPLIED_TO_COMMENT) + .findFirst() + .orElseThrow(); + + assertThat(replyPayload.subject()).isEqualTo(NotificationSubject.COMMENT); + assertThat(replyPayload.action()).isEqualTo(NotificationAction.REPLY); + assertThat(replyPayload.relatedId()).isEqualTo(999L); + assertThat(replyPayload.extras()).containsEntry("commentId", 200L); + assertThat(replyPayload.extras()).containsEntry("collectionId", 999L); + assertThat(replyPayload.extras()).containsEntry("comment", "reply body"); + } + + @Test + @DisplayName("자기 컬렉션 원댓글은 알림을 생성하지 않는다") + void handle_selfComment_skipsNotifications() { + worker.handle(new CollectionNotificationEvent.CommentCreated( + 1L, "owner", + 100L, 1L, + null, null, + "self comment" + )); + + verifyNoInteractions(publisher); + } + + @Test + @DisplayName("좋아요 알림은 컬렉션 좋아요 payload 하나를 생성한다") + void handle_collectionLiked_generatesNotification() { + worker.handle(new CollectionNotificationEvent.CollectionLiked( + 1L, "liker", + 100L, 2L + )); + + ArgumentCaptor payloadCaptor = ArgumentCaptor.forClass(NotificationPayload.class); + verify(publisher).push(payloadCaptor.capture()); + + ActionNotificationPayload payload = (ActionNotificationPayload) payloadCaptor.getValue(); + assertThat(payload.recipientId()).isEqualTo(2L); + assertThat(payload.subject()).isEqualTo(NotificationSubject.COLLECTION); + assertThat(payload.action()).isEqualTo(NotificationAction.LIKE); + assertThat(payload.type()).isEqualTo(NotificationType.LIKED_ON_COLLECTION); + assertThat(payload.relatedId()).isEqualTo(100L); + assertThat(payload.extras()).containsEntry("collectionId", 100L); + } + + @Test + @DisplayName("동정 제안 알림은 제안된 새 이름을 포함한 payload를 생성한다") + void handle_birdIdSuggested_generatesNotification() { + worker.handle(new CollectionNotificationEvent.BirdIdSuggested( + 1L, "suggester", + 100L, 2L, + "직박구리" + )); + + ArgumentCaptor payloadCaptor = ArgumentCaptor.forClass(NotificationPayload.class); + verify(publisher).push(payloadCaptor.capture()); + + ActionNotificationPayload payload = (ActionNotificationPayload) payloadCaptor.getValue(); + assertThat(payload.recipientId()).isEqualTo(2L); + assertThat(payload.type()).isEqualTo(NotificationType.SUGGESTED_BIRD_ID_ON_COLLECTION); + assertThat(payload.extras()).containsEntry("collectionId", 100L); + assertThat(payload.extras()).containsEntry("suggestedName", "직박구리"); + } + + @Test + @DisplayName("발송 중 예외가 나도 워커는 예외를 외부로 전파하지 않는다") + void handle_likeFailure_swallowsException() { + doThrow(new IllegalStateException("push failed")).when(publisher).push(org.mockito.ArgumentMatchers.any()); + + assertThatCode(() -> worker.handle(new CollectionNotificationEvent.CollectionLiked( + 1L, "liker", + 100L, 2L + ))).doesNotThrowAnyException(); + } +} From 7628f882e582d42c38a69109563b62e98ed35fb2 Mon Sep 17 00:00:00 2001 From: pizzazoa Date: Sat, 18 Apr 2026 14:05:08 +0900 Subject: [PATCH 4/7] =?UTF-8?q?refactor:=20community=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=EC=97=90=20FreeBoard=20=EB=8F=84=EB=A9=94=EC=9D=B8=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B4=EB=A5=BC=20=EB=B0=9B=EB=8A=94=20dto=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 사유: 도메인 간 의존 관계 느슨하게 하기 위함 --- .../common/CommunityFreeBoardPostInfo.java | 25 +++++++++++++++++++ .../response/GetCommunityMainResponse.java | 4 +-- .../application/CommunityQueryService.java | 8 ++++-- .../community/mapper/CommunityWebMapper.java | 4 +++ .../FreeBoardPostQueryService.java | 6 ++--- .../application/dto/FreeBoardPostPreview.java | 15 +++++++++++ 6 files changed, 55 insertions(+), 7 deletions(-) create mode 100644 src/main/java/org/devkor/apu/saerok_server/domain/community/api/dto/common/CommunityFreeBoardPostInfo.java create mode 100644 src/main/java/org/devkor/apu/saerok_server/domain/freeboard/application/dto/FreeBoardPostPreview.java diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/community/api/dto/common/CommunityFreeBoardPostInfo.java b/src/main/java/org/devkor/apu/saerok_server/domain/community/api/dto/common/CommunityFreeBoardPostInfo.java new file mode 100644 index 00000000..951de4d6 --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/community/api/dto/common/CommunityFreeBoardPostInfo.java @@ -0,0 +1,25 @@ +package org.devkor.apu.saerok_server.domain.community.api.dto.common; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.LocalDateTime; + +public record CommunityFreeBoardPostInfo( + @Schema(description = "게시글 ID", example = "1", requiredMode = Schema.RequiredMode.REQUIRED) + Long postId, + @Schema(description = "작성자 ID", example = "3", requiredMode = Schema.RequiredMode.REQUIRED) + Long userId, + @Schema(description = "작성자 닉네임", example = "새록마스터", requiredMode = Schema.RequiredMode.REQUIRED) + String nickname, + @Schema(description = "작성자 프로필 이미지 URL", requiredMode = Schema.RequiredMode.REQUIRED) + String profileImageUrl, + @Schema(description = "작성자 썸네일 프로필 이미지 URL (320px 너비)", requiredMode = Schema.RequiredMode.REQUIRED) + String thumbnailProfileImageUrl, + @Schema(description = "게시글 내용", example = "오늘 한강공원에서 백로를 발견했어요!", requiredMode = Schema.RequiredMode.REQUIRED) + String content, + @Schema(description = "작성 시각", example = "2025-07-05T03:10:00", requiredMode = Schema.RequiredMode.REQUIRED) + LocalDateTime createdAt, + @Schema(description = "최종 수정 시각", example = "2025-07-05T04:20:00", requiredMode = Schema.RequiredMode.REQUIRED) + LocalDateTime updatedAt +) { +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/community/api/dto/response/GetCommunityMainResponse.java b/src/main/java/org/devkor/apu/saerok_server/domain/community/api/dto/response/GetCommunityMainResponse.java index a83e8074..d0335a51 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/community/api/dto/response/GetCommunityMainResponse.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/community/api/dto/response/GetCommunityMainResponse.java @@ -2,7 +2,7 @@ import io.swagger.v3.oas.annotations.media.Schema; import org.devkor.apu.saerok_server.domain.community.api.dto.common.CommunityCollectionInfo; -import org.devkor.apu.saerok_server.domain.freeboard.api.dto.response.FreeBoardPostPreviewResponse; +import org.devkor.apu.saerok_server.domain.community.api.dto.common.CommunityFreeBoardPostInfo; import java.util.List; @@ -18,5 +18,5 @@ public record GetCommunityMainResponse( List pendingCollections, @Schema(description = "최근 자유게시판 글 (최대 5개)") - List recentFreeBoardPosts + List recentFreeBoardPosts ) {} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/community/application/CommunityQueryService.java b/src/main/java/org/devkor/apu/saerok_server/domain/community/application/CommunityQueryService.java index 45067b1e..b1bdb233 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/community/application/CommunityQueryService.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/community/application/CommunityQueryService.java @@ -6,9 +6,10 @@ import org.devkor.apu.saerok_server.domain.community.api.dto.response.GetCommunityMainResponse; import org.devkor.apu.saerok_server.domain.community.api.dto.response.GetCommunitySearchResponse; import org.devkor.apu.saerok_server.domain.community.api.dto.response.GetCommunitySearchUsersResponse; +import org.devkor.apu.saerok_server.domain.community.api.dto.common.CommunityFreeBoardPostInfo; import org.devkor.apu.saerok_server.domain.community.application.dto.CommunityQueryCommand; import org.devkor.apu.saerok_server.domain.community.core.repository.CommunityRepository; -import org.devkor.apu.saerok_server.domain.freeboard.api.dto.response.FreeBoardPostPreviewResponse; +import org.devkor.apu.saerok_server.domain.community.mapper.CommunityWebMapper; import org.devkor.apu.saerok_server.domain.freeboard.application.FreeBoardPostQueryService; import org.devkor.apu.saerok_server.domain.user.core.entity.User; import org.springframework.stereotype.Service; @@ -23,6 +24,7 @@ public class CommunityQueryService { private final CommunityRepository communityRepository; private final CommunityDataAssembler dataAssembler; + private final CommunityWebMapper communityWebMapper; private final FreeBoardPostQueryService freeBoardPostQueryService; public GetCommunityMainResponse getCommunityMain(Long userId) { @@ -33,7 +35,9 @@ public GetCommunityMainResponse getCommunityMain(Long userId) { List recentCollections = communityRepository.findRecentPublicCollections(mainCommand); List popularCollections = communityRepository.findPopularCollections(mainCommand); List pendingCollections = communityRepository.findPendingBirdIdCollections(pendingCommand); - List recentFreeBoardPosts = freeBoardPostQueryService.getRecentPostsForMain(5); + List recentFreeBoardPosts = freeBoardPostQueryService.getRecentPostsForMain(5).stream() + .map(communityWebMapper::toCommunityFreeBoardPostInfo) + .toList(); return new GetCommunityMainResponse( dataAssembler.toCollectionInfos(recentCollections, userId), diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/community/mapper/CommunityWebMapper.java b/src/main/java/org/devkor/apu/saerok_server/domain/community/mapper/CommunityWebMapper.java index 87504c44..aa101213 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/community/mapper/CommunityWebMapper.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/community/mapper/CommunityWebMapper.java @@ -2,7 +2,9 @@ import org.devkor.apu.saerok_server.domain.collection.core.entity.UserBirdCollection; import org.devkor.apu.saerok_server.domain.community.api.dto.common.CommunityCollectionInfo; +import org.devkor.apu.saerok_server.domain.community.api.dto.common.CommunityFreeBoardPostInfo; import org.devkor.apu.saerok_server.domain.community.api.dto.common.CommunityUserInfo; +import org.devkor.apu.saerok_server.domain.freeboard.application.dto.FreeBoardPostPreview; import org.devkor.apu.saerok_server.domain.user.core.entity.User; import org.devkor.apu.saerok_server.global.shared.util.OffsetDateTimeLocalizer; import org.mapstruct.Mapper; @@ -48,6 +50,8 @@ CommunityCollectionInfo toCommunityCollectionInfo( @Mapping(target = "thumbnailProfileImageUrl", source = "thumbnailProfileImageUrl") CommunityUserInfo toCommunityUserInfo(User user, String profileImageUrl, String thumbnailProfileImageUrl); + CommunityFreeBoardPostInfo toCommunityFreeBoardPostInfo(FreeBoardPostPreview post); + default CommunityCollectionInfo.BirdInfo mapBirdInfo(UserBirdCollection collection) { if (collection.getBird() == null) { return null; diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/freeboard/application/FreeBoardPostQueryService.java b/src/main/java/org/devkor/apu/saerok_server/domain/freeboard/application/FreeBoardPostQueryService.java index 778984e2..b35c4cbb 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/freeboard/application/FreeBoardPostQueryService.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/freeboard/application/FreeBoardPostQueryService.java @@ -1,9 +1,9 @@ package org.devkor.apu.saerok_server.domain.freeboard.application; import lombok.RequiredArgsConstructor; -import org.devkor.apu.saerok_server.domain.freeboard.api.dto.response.FreeBoardPostPreviewResponse; import org.devkor.apu.saerok_server.domain.freeboard.api.dto.response.GetFreeBoardPostDetailResponse; import org.devkor.apu.saerok_server.domain.freeboard.api.dto.response.GetFreeBoardPostsResponse; +import org.devkor.apu.saerok_server.domain.freeboard.application.dto.FreeBoardPostPreview; import org.devkor.apu.saerok_server.domain.freeboard.application.dto.FreeBoardPostQueryCommand; import org.devkor.apu.saerok_server.domain.freeboard.core.entity.FreeBoardPost; import org.devkor.apu.saerok_server.domain.freeboard.core.repository.FreeBoardPostCommentRepository; @@ -95,7 +95,7 @@ public GetFreeBoardPostDetailResponse getPostDetail(Long postId, Long userId) { } /* 커뮤니티 메인용 최신 게시글 미리보기 */ - public List getRecentPostsForMain(int limit) { + public List getRecentPostsForMain(int limit) { FreeBoardPostQueryCommand command = new FreeBoardPostQueryCommand(1, limit); List posts = postRepository.findAll(command); @@ -111,7 +111,7 @@ public List getRecentPostsForMain(int limit) { return posts.stream() .map(post -> { Long authorId = post.getUser().getId(); - return new FreeBoardPostPreviewResponse( + return new FreeBoardPostPreview( post.getId(), authorId, post.getUser().getNickname(), diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/freeboard/application/dto/FreeBoardPostPreview.java b/src/main/java/org/devkor/apu/saerok_server/domain/freeboard/application/dto/FreeBoardPostPreview.java new file mode 100644 index 00000000..4b4309b5 --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/freeboard/application/dto/FreeBoardPostPreview.java @@ -0,0 +1,15 @@ +package org.devkor.apu.saerok_server.domain.freeboard.application.dto; + +import java.time.LocalDateTime; + +public record FreeBoardPostPreview( + Long postId, + Long userId, + String nickname, + String profileImageUrl, + String thumbnailProfileImageUrl, + String content, + LocalDateTime createdAt, + LocalDateTime updatedAt +) { +} From 7472fc668a788420f70014fef2727e22643135bc Mon Sep 17 00:00:00 2001 From: pizzazoa Date: Sat, 18 Apr 2026 14:06:40 +0900 Subject: [PATCH 5/7] =?UTF-8?q?test:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CommunityQueryServiceTest.java | 28 ++++++++++++++----- 1 file changed, 21 insertions(+), 7 deletions(-) diff --git a/src/test/java/org/devkor/apu/saerok_server/domain/community/application/CommunityQueryServiceTest.java b/src/test/java/org/devkor/apu/saerok_server/domain/community/application/CommunityQueryServiceTest.java index b0596ea2..9f1a713b 100644 --- a/src/test/java/org/devkor/apu/saerok_server/domain/community/application/CommunityQueryServiceTest.java +++ b/src/test/java/org/devkor/apu/saerok_server/domain/community/application/CommunityQueryServiceTest.java @@ -3,11 +3,13 @@ import org.devkor.apu.saerok_server.domain.collection.core.entity.AccessLevelType; import org.devkor.apu.saerok_server.domain.collection.core.entity.UserBirdCollection; import org.devkor.apu.saerok_server.domain.community.api.dto.common.CommunityCollectionInfo; +import org.devkor.apu.saerok_server.domain.community.api.dto.common.CommunityFreeBoardPostInfo; import org.devkor.apu.saerok_server.domain.community.api.dto.response.GetCommunityCollectionsResponse; import org.devkor.apu.saerok_server.domain.community.api.dto.response.GetCommunityMainResponse; import org.devkor.apu.saerok_server.domain.community.application.dto.CommunityQueryCommand; -import org.devkor.apu.saerok_server.domain.freeboard.api.dto.response.FreeBoardPostPreviewResponse; import org.devkor.apu.saerok_server.domain.community.core.repository.CommunityRepository; +import org.devkor.apu.saerok_server.domain.community.mapper.CommunityWebMapper; +import org.devkor.apu.saerok_server.domain.freeboard.application.dto.FreeBoardPostPreview; import org.devkor.apu.saerok_server.domain.dex.bird.core.entity.Bird; import org.devkor.apu.saerok_server.domain.dex.bird.core.entity.BirdName; import org.devkor.apu.saerok_server.domain.freeboard.application.FreeBoardPostQueryService; @@ -37,6 +39,7 @@ class CommunityQueryServiceTest { @Mock CommunityRepository communityRepository; @Mock CommunityDataAssembler dataAssembler; + @Mock CommunityWebMapper communityWebMapper; @Mock FreeBoardPostQueryService freeBoardPostQueryService; private static User user(Long id, String nickname) { @@ -103,6 +106,7 @@ void setUp() { communityQueryService = new CommunityQueryService( communityRepository, dataAssembler, + communityWebMapper, freeBoardPostQueryService ); } @@ -232,21 +236,30 @@ void getCommunityMain_returnsRecentFreeBoardPosts() { given(dataAssembler.toCollectionInfos(List.of(), userId)) .willReturn(List.of()); - List freeBoardPosts = List.of( - new FreeBoardPostPreviewResponse(1L, 10L, "유저A", "https://img/a.jpg", "https://img/thumb/a.webp", + List freeBoardPosts = List.of( + new FreeBoardPostPreview(1L, 10L, "유저A", "https://img/a.jpg", "https://img/thumb/a.webp", "오늘 한강에서 백로를 봤어요!", LocalDateTime.of(2025, 7, 5, 15, 0), LocalDateTime.of(2025, 7, 5, 15, 0)), - new FreeBoardPostPreviewResponse(2L, 11L, "유저B", "https://img/b.jpg", "https://img/thumb/b.webp", + new FreeBoardPostPreview(2L, 11L, "유저B", "https://img/b.jpg", "https://img/thumb/b.webp", "참새 귀엽다", LocalDateTime.of(2025, 7, 5, 14, 30), LocalDateTime.of(2025, 7, 5, 14, 30)), - new FreeBoardPostPreviewResponse(3L, 12L, "유저C", "https://img/c.jpg", "https://img/thumb/c.webp", + new FreeBoardPostPreview(3L, 12L, "유저C", "https://img/c.jpg", "https://img/thumb/c.webp", "까치 발견!", LocalDateTime.of(2025, 7, 5, 14, 0), LocalDateTime.of(2025, 7, 5, 14, 0)), - new FreeBoardPostPreviewResponse(4L, 13L, "유저D", "https://img/d.jpg", "https://img/thumb/d.webp", + new FreeBoardPostPreview(4L, 13L, "유저D", "https://img/d.jpg", "https://img/thumb/d.webp", "비둘기가 많네요", LocalDateTime.of(2025, 7, 5, 13, 30), LocalDateTime.of(2025, 7, 5, 13, 30)), - new FreeBoardPostPreviewResponse(5L, 14L, "유저E", "https://img/e.jpg", "https://img/thumb/e.webp", + new FreeBoardPostPreview(5L, 14L, "유저E", "https://img/e.jpg", "https://img/thumb/e.webp", "딱따구리 소리가 들려요", LocalDateTime.of(2025, 7, 5, 13, 0), LocalDateTime.of(2025, 7, 5, 13, 0)) ); given(freeBoardPostQueryService.getRecentPostsForMain(5)) .willReturn(freeBoardPosts); + for (FreeBoardPostPreview post : freeBoardPosts) { + given(communityWebMapper.toCommunityFreeBoardPostInfo(post)) + .willReturn(new CommunityFreeBoardPostInfo( + post.postId(), post.userId(), post.nickname(), + post.profileImageUrl(), post.thumbnailProfileImageUrl(), + post.content(), post.createdAt(), post.updatedAt() + )); + } + // When GetCommunityMainResponse response = communityQueryService.getCommunityMain(userId); @@ -254,6 +267,7 @@ void getCommunityMain_returnsRecentFreeBoardPosts() { assertThat(response.recentFreeBoardPosts()).hasSize(5); assertThat(response.recentFreeBoardPosts().get(0).postId()).isEqualTo(1L); assertThat(response.recentFreeBoardPosts().get(4).postId()).isEqualTo(5L); + assertThat(response.recentFreeBoardPosts().get(0)).isInstanceOf(CommunityFreeBoardPostInfo.class); then(freeBoardPostQueryService).should().getRecentPostsForMain(5); } } From 5faa8031932d4f2b65958c8cde056b2d4ef6178a Mon Sep 17 00:00:00 2001 From: pizzazoa Date: Sat, 18 Apr 2026 15:47:39 +0900 Subject: [PATCH 6/7] =?UTF-8?q?feat:=20=EC=96=B4=EB=93=9C=EB=AF=BC=20?= =?UTF-8?q?=ED=8A=B9=EC=A0=95=20=EB=8C=80=EC=83=81=EC=97=90=EA=B2=8C?= =?UTF-8?q?=EB=A7=8C=20=EC=95=8C=EB=A6=BC=20=EB=B0=9C=EC=86=A1=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../audit/core/entity/AdminAuditAction.java | 3 +- .../core/entity/AdminAuditTargetType.java | 3 +- .../api/AdminNotificationController.java | 55 ++++++++++ .../dto/request/AdminSendMessageRequest.java | 31 ++++++ .../AdminNotificationCommandService.java | 52 +++++++++ .../event/AdminNotificationEvent.java | 17 +++ .../event/AdminNotificationWorker.java | 57 ++++++++++ .../AdminFreeBoardReportCommandService.java | 11 ++ .../AdminReportCommandService.java | 13 ++- .../core/entity/NotificationType.java | 4 +- .../config/notification-messages.yml | 8 +- ...dminFreeBoardReportCommandServiceTest.java | 2 + .../AdminReportCommandServiceTest.java | 2 + .../event/AdminNotificationWorkerTest.java | 103 ++++++++++++++++++ 14 files changed, 356 insertions(+), 5 deletions(-) create mode 100644 src/main/java/org/devkor/apu/saerok_server/domain/admin/notification/api/AdminNotificationController.java create mode 100644 src/main/java/org/devkor/apu/saerok_server/domain/admin/notification/api/dto/request/AdminSendMessageRequest.java create mode 100644 src/main/java/org/devkor/apu/saerok_server/domain/admin/notification/application/AdminNotificationCommandService.java create mode 100644 src/main/java/org/devkor/apu/saerok_server/domain/admin/notification/application/event/AdminNotificationEvent.java create mode 100644 src/main/java/org/devkor/apu/saerok_server/domain/admin/notification/application/event/AdminNotificationWorker.java create mode 100644 src/test/java/org/devkor/apu/saerok_server/domain/admin/notification/application/event/AdminNotificationWorkerTest.java diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/admin/audit/core/entity/AdminAuditAction.java b/src/main/java/org/devkor/apu/saerok_server/domain/admin/audit/core/entity/AdminAuditAction.java index 18dcafb1..58642679 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/admin/audit/core/entity/AdminAuditAction.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/admin/audit/core/entity/AdminAuditAction.java @@ -17,5 +17,6 @@ public enum AdminAuditAction { ANNOUNCEMENT_UPDATED, ANNOUNCEMENT_DELETED, FREEBOARD_POST_DELETED, - FREEBOARD_COMMENT_DELETED + FREEBOARD_COMMENT_DELETED, + ADMIN_MESSAGE_SENT, } diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/admin/audit/core/entity/AdminAuditTargetType.java b/src/main/java/org/devkor/apu/saerok_server/domain/admin/audit/core/entity/AdminAuditTargetType.java index 0a40c6bc..d9b600ea 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/admin/audit/core/entity/AdminAuditTargetType.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/admin/audit/core/entity/AdminAuditTargetType.java @@ -12,5 +12,6 @@ public enum AdminAuditTargetType { REPORT_FREEBOARD_POST, REPORT_FREEBOARD_COMMENT, FREEBOARD_POST, - FREEBOARD_COMMENT + FREEBOARD_COMMENT, + ADMIN_MESSAGE, } diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/admin/notification/api/AdminNotificationController.java b/src/main/java/org/devkor/apu/saerok_server/domain/admin/notification/api/AdminNotificationController.java new file mode 100644 index 00000000..68823cf1 --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/admin/notification/api/AdminNotificationController.java @@ -0,0 +1,55 @@ +package org.devkor.apu.saerok_server.domain.admin.notification.api; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.devkor.apu.saerok_server.domain.admin.notification.api.dto.request.AdminSendMessageRequest; +import org.devkor.apu.saerok_server.domain.admin.notification.application.AdminNotificationCommandService; +import org.devkor.apu.saerok_server.global.security.principal.UserPrincipal; +import org.springframework.http.HttpStatus; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "Admin Notification API", description = "관리자 알림 전송 API") +@RestController +@RequiredArgsConstructor +@RequestMapping("${api_prefix}/admin/notifications") +public class AdminNotificationController { + + private final AdminNotificationCommandService commandService; + + @PostMapping("/messages") + @ResponseStatus(HttpStatus.NO_CONTENT) + @PreAuthorize("@perm.has('ADMIN_ANNOUNCEMENT_WRITE')") + @Operation( + summary = "특정 사용자들에게 관리자 메시지 전송", + description = "지정한 사용자 목록에게 커스텀 알림을 발송합니다.", + security = @SecurityRequirement(name = "bearerAuth"), + responses = { + @ApiResponse(responseCode = "204", description = "전송 성공"), + @ApiResponse(responseCode = "400", description = "잘못된 요청", content = @Content), + @ApiResponse(responseCode = "401", description = "인증 실패", content = @Content), + @ApiResponse(responseCode = "403", description = "관리자 권한 없음", content = @Content) + } + ) + public void sendMessage( + @AuthenticationPrincipal UserPrincipal admin, + @Valid @RequestBody AdminSendMessageRequest request + ) { + commandService.sendMessageToUsers( + admin.getId(), + request.getUserIds(), + request.getTitle(), + request.getBody() + ); + } +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/admin/notification/api/dto/request/AdminSendMessageRequest.java b/src/main/java/org/devkor/apu/saerok_server/domain/admin/notification/api/dto/request/AdminSendMessageRequest.java new file mode 100644 index 00000000..15bd50ae --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/admin/notification/api/dto/request/AdminSendMessageRequest.java @@ -0,0 +1,31 @@ +package org.devkor.apu.saerok_server.domain.admin.notification.api.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +@Data +@NoArgsConstructor +@Schema(description = "관리자 메시지 전송 요청") +public class AdminSendMessageRequest { + + @NotNull + @Size(min = 1) + @Schema(description = "수신자 사용자 ID 목록", example = "[1, 2, 3]") + private List userIds; + + @NotBlank + @Size(max = 100) + @Schema(description = "알림 제목", example = "안내 사항") + private String title; + + @NotBlank + @Size(max = 500) + @Schema(description = "알림 내용", example = "서비스 이용에 참고해 주세요.") + private String body; +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/admin/notification/application/AdminNotificationCommandService.java b/src/main/java/org/devkor/apu/saerok_server/domain/admin/notification/application/AdminNotificationCommandService.java new file mode 100644 index 00000000..d99ca560 --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/admin/notification/application/AdminNotificationCommandService.java @@ -0,0 +1,52 @@ +package org.devkor.apu.saerok_server.domain.admin.notification.application; + +import lombok.RequiredArgsConstructor; +import org.devkor.apu.saerok_server.domain.admin.audit.core.entity.AdminAuditAction; +import org.devkor.apu.saerok_server.domain.admin.audit.core.entity.AdminAuditLog; +import org.devkor.apu.saerok_server.domain.admin.audit.core.entity.AdminAuditTargetType; +import org.devkor.apu.saerok_server.domain.admin.audit.core.repository.AdminAuditLogRepository; +import org.devkor.apu.saerok_server.domain.admin.notification.application.event.AdminNotificationEvent; +import org.devkor.apu.saerok_server.domain.user.core.entity.User; +import org.devkor.apu.saerok_server.domain.user.core.repository.UserRepository; +import org.devkor.apu.saerok_server.global.shared.exception.NotFoundException; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@Service +@Transactional +@RequiredArgsConstructor +public class AdminNotificationCommandService { + + private final ApplicationEventPublisher eventPublisher; + private final AdminAuditLogRepository adminAuditLogRepository; + private final UserRepository userRepository; + + public void sendMessageToUsers(Long adminUserId, List userIds, String title, String body) { + eventPublisher.publishEvent( + new AdminNotificationEvent.AdminMessageSent(userIds, title, body) + ); + + User admin = userRepository.findById(adminUserId) + .orElseThrow(() -> new NotFoundException("관리자 계정이 존재하지 않아요")); + + Map metadata = new LinkedHashMap<>(); + metadata.put("recipientCount", userIds.size()); + metadata.put("recipientIds", userIds); + metadata.put("title", title); + metadata.put("body", body); + + adminAuditLogRepository.save(AdminAuditLog.of( + admin, + AdminAuditAction.ADMIN_MESSAGE_SENT, + AdminAuditTargetType.ADMIN_MESSAGE, + null, + null, + metadata + )); + } +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/admin/notification/application/event/AdminNotificationEvent.java b/src/main/java/org/devkor/apu/saerok_server/domain/admin/notification/application/event/AdminNotificationEvent.java new file mode 100644 index 00000000..8a805d1b --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/admin/notification/application/event/AdminNotificationEvent.java @@ -0,0 +1,17 @@ +package org.devkor.apu.saerok_server.domain.admin.notification.application.event; + +import java.util.List; + +public sealed interface AdminNotificationEvent { + + record AdminMessageSent( + List recipientIds, + String title, + String body + ) implements AdminNotificationEvent {} + + record ContentDeletedByReport( + Long contentOwnerId, + String reason + ) implements AdminNotificationEvent {} +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/admin/notification/application/event/AdminNotificationWorker.java b/src/main/java/org/devkor/apu/saerok_server/domain/admin/notification/application/event/AdminNotificationWorker.java new file mode 100644 index 00000000..263a2b54 --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/admin/notification/application/event/AdminNotificationWorker.java @@ -0,0 +1,57 @@ +package org.devkor.apu.saerok_server.domain.admin.notification.application.event; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.devkor.apu.saerok_server.domain.notification.application.facade.NotifySystemService; +import org.devkor.apu.saerok_server.domain.notification.core.entity.NotificationType; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +import java.util.Map; + +@Slf4j +@Component +@RequiredArgsConstructor +public class AdminNotificationWorker { + + private final NotifySystemService notifySystemService; + + @Async("pushNotificationExecutor") + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handle(AdminNotificationEvent.AdminMessageSent event) { + try { + Map extras = Map.of( + "title", event.title(), + "body", event.body() + ); + notifySystemService.notifyUsersDeduplicatedPush( + event.recipientIds(), + NotificationType.SYSTEM_ADMIN_MESSAGE, + null, + extras + ); + } catch (Exception e) { + log.error("Failed to send admin message notification: recipientCount={}", + event.recipientIds().size(), e); + } + } + + @Async("pushNotificationExecutor") + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handle(AdminNotificationEvent.ContentDeletedByReport event) { + try { + Map extras = Map.of("reason", event.reason()); + notifySystemService.notifyUser( + event.contentOwnerId(), + NotificationType.SYSTEM_CONTENT_DELETED, + null, + extras + ); + } catch (Exception e) { + log.error("Failed to send content-deleted notification: ownerId={}", + event.contentOwnerId(), e); + } + } +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/admin/report/application/AdminFreeBoardReportCommandService.java b/src/main/java/org/devkor/apu/saerok_server/domain/admin/report/application/AdminFreeBoardReportCommandService.java index ece9f88e..20518360 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/admin/report/application/AdminFreeBoardReportCommandService.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/admin/report/application/AdminFreeBoardReportCommandService.java @@ -5,6 +5,7 @@ import org.devkor.apu.saerok_server.domain.admin.audit.core.entity.AdminAuditLog; import org.devkor.apu.saerok_server.domain.admin.audit.core.entity.AdminAuditTargetType; import org.devkor.apu.saerok_server.domain.admin.audit.core.repository.AdminAuditLogRepository; +import org.devkor.apu.saerok_server.domain.admin.notification.application.event.AdminNotificationEvent; import org.devkor.apu.saerok_server.domain.freeboard.core.entity.FreeBoardPost; import org.devkor.apu.saerok_server.domain.freeboard.core.entity.FreeBoardPostComment; import org.devkor.apu.saerok_server.domain.freeboard.core.entity.FreeBoardPostCommentReport; @@ -16,6 +17,7 @@ import org.devkor.apu.saerok_server.domain.user.core.entity.User; import org.devkor.apu.saerok_server.domain.user.core.repository.UserRepository; import org.devkor.apu.saerok_server.global.shared.exception.NotFoundException; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -35,6 +37,7 @@ public class AdminFreeBoardReportCommandService { private final AdminAuditLogRepository adminAuditLogRepository; private final UserRepository userRepository; + private final ApplicationEventPublisher eventPublisher; /* ───────────── 신고 무시 ───────────── */ @@ -128,6 +131,10 @@ public void deletePostByReport(Long adminUserId, Long reportId, String reason) { reportId, metadata )); + + // 콘텐츠 삭제 알림 + eventPublisher.publishEvent(new AdminNotificationEvent.ContentDeletedByReport( + report.getReportedUser().getId(), reason)); } public void deleteCommentByReport(Long adminUserId, Long reportId, String reason) { @@ -168,5 +175,9 @@ public void deleteCommentByReport(Long adminUserId, Long reportId, String reason reportId, metadata )); + + // 콘텐츠 삭제 알림 + eventPublisher.publishEvent(new AdminNotificationEvent.ContentDeletedByReport( + report.getReportedUser().getId(), reason)); } } diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/admin/report/application/AdminReportCommandService.java b/src/main/java/org/devkor/apu/saerok_server/domain/admin/report/application/AdminReportCommandService.java index a93b10aa..5d089126 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/admin/report/application/AdminReportCommandService.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/admin/report/application/AdminReportCommandService.java @@ -5,6 +5,7 @@ import org.devkor.apu.saerok_server.domain.admin.audit.core.entity.AdminAuditLog; import org.devkor.apu.saerok_server.domain.admin.audit.core.entity.AdminAuditTargetType; import org.devkor.apu.saerok_server.domain.admin.audit.core.repository.AdminAuditLogRepository; +import org.devkor.apu.saerok_server.domain.admin.notification.application.event.AdminNotificationEvent; import org.devkor.apu.saerok_server.domain.collection.core.entity.UserBirdCollection; import org.devkor.apu.saerok_server.domain.collection.core.entity.UserBirdCollectionComment; import org.devkor.apu.saerok_server.domain.collection.core.entity.UserBirdCollectionCommentReport; @@ -15,6 +16,7 @@ import org.devkor.apu.saerok_server.global.shared.exception.NotFoundException; import org.devkor.apu.saerok_server.global.shared.infra.ImageService; import org.devkor.apu.saerok_server.global.shared.util.TransactionUtils; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -39,6 +41,7 @@ public class AdminReportCommandService { // 감사/행위자 조회 private final AdminAuditLogRepository adminAuditLogRepository; private final UserRepository userRepository; + private final ApplicationEventPublisher eventPublisher; /* ───────────── 신고 무시 ───────────── */ @@ -138,7 +141,11 @@ public void deleteCollectionByReport(Long adminUserId, Long reportId, String rea metadata )); - // 5) 커밋 후 S3 삭제 + // 5) 콘텐츠 삭제 알림 + eventPublisher.publishEvent(new AdminNotificationEvent.ContentDeletedByReport( + report.getReportedUser().getId(), reason)); + + // 6) 커밋 후 S3 삭제 if (!objectKeys.isEmpty()) { TransactionUtils.runAfterCommitOrNow(() -> imageService.deleteAll(objectKeys)); } @@ -182,5 +189,9 @@ public void deleteCommentByReport(Long adminUserId, Long reportId, String reason reportId, metadata )); + + // 콘텐츠 삭제 알림 + eventPublisher.publishEvent(new AdminNotificationEvent.ContentDeletedByReport( + report.getReportedUser().getId(), reason)); } } diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/core/entity/NotificationType.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/core/entity/NotificationType.java index e8bda22b..98c9d22c 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/notification/core/entity/NotificationType.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/core/entity/NotificationType.java @@ -17,5 +17,7 @@ public enum NotificationType { SUGGESTED_BIRD_ID_ON_COLLECTION, // ---- System Notification Types ---- - SYSTEM_PUBLISHED_ANNOUNCEMENT + SYSTEM_PUBLISHED_ANNOUNCEMENT, + SYSTEM_ADMIN_MESSAGE, + SYSTEM_CONTENT_DELETED, } diff --git a/src/main/resources/config/notification-messages.yml b/src/main/resources/config/notification-messages.yml index 445394cd..41023359 100644 --- a/src/main/resources/config/notification-messages.yml +++ b/src/main/resources/config/notification-messages.yml @@ -22,4 +22,10 @@ notification-messages: batch-push-body: "{count}개의 새로운 의견이 공유되었어요. 확인해볼까요?" SYSTEM_PUBLISHED_ANNOUNCEMENT: push-title: "{title}" - push-body: "{body}" \ No newline at end of file + push-body: "{body}" + SYSTEM_ADMIN_MESSAGE: + push-title: "{title}" + push-body: "{body}" + SYSTEM_CONTENT_DELETED: + push-title: "콘텐츠 삭제 안내" + push-body: "회원님의 콘텐츠가 운영정책 위반으로 삭제되었어요. 사유: {reason}" \ No newline at end of file diff --git a/src/test/java/org/devkor/apu/saerok_server/domain/admin/application/AdminFreeBoardReportCommandServiceTest.java b/src/test/java/org/devkor/apu/saerok_server/domain/admin/application/AdminFreeBoardReportCommandServiceTest.java index 01d6925c..006a06b9 100644 --- a/src/test/java/org/devkor/apu/saerok_server/domain/admin/application/AdminFreeBoardReportCommandServiceTest.java +++ b/src/test/java/org/devkor/apu/saerok_server/domain/admin/application/AdminFreeBoardReportCommandServiceTest.java @@ -16,6 +16,7 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.test.util.ReflectionTestUtils; import java.util.Map; @@ -39,6 +40,7 @@ class AdminFreeBoardReportCommandServiceTest { @Mock FreeBoardPostCommentRepository commentRepository; @Mock AdminAuditLogRepository adminAuditLogRepository; @Mock UserRepository userRepository; + @Mock ApplicationEventPublisher eventPublisher; private static User user(long id) { User u = new User(); diff --git a/src/test/java/org/devkor/apu/saerok_server/domain/admin/application/AdminReportCommandServiceTest.java b/src/test/java/org/devkor/apu/saerok_server/domain/admin/application/AdminReportCommandServiceTest.java index 2f57a1ef..e0041674 100644 --- a/src/test/java/org/devkor/apu/saerok_server/domain/admin/application/AdminReportCommandServiceTest.java +++ b/src/test/java/org/devkor/apu/saerok_server/domain/admin/application/AdminReportCommandServiceTest.java @@ -20,6 +20,7 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.test.util.ReflectionTestUtils; import java.util.List; @@ -48,6 +49,7 @@ class AdminReportCommandServiceTest { @Mock AdminAuditLogRepository adminAuditLogRepository; @Mock UserRepository userRepository; + @Mock ApplicationEventPublisher eventPublisher; private static User user(long id) { User u = new User(); diff --git a/src/test/java/org/devkor/apu/saerok_server/domain/admin/notification/application/event/AdminNotificationWorkerTest.java b/src/test/java/org/devkor/apu/saerok_server/domain/admin/notification/application/event/AdminNotificationWorkerTest.java new file mode 100644 index 00000000..96db8ed9 --- /dev/null +++ b/src/test/java/org/devkor/apu/saerok_server/domain/admin/notification/application/event/AdminNotificationWorkerTest.java @@ -0,0 +1,103 @@ +package org.devkor.apu.saerok_server.domain.admin.notification.application.event; + +import org.devkor.apu.saerok_server.domain.notification.application.facade.NotifySystemService; +import org.devkor.apu.saerok_server.domain.notification.core.entity.NotificationType; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class AdminNotificationWorkerTest { + + @Mock private NotifySystemService notifySystemService; + + private AdminNotificationWorker worker; + + @BeforeEach + void setUp() { + worker = new AdminNotificationWorker(notifySystemService); + } + + @Test + @DisplayName("관리자 메시지 이벤트 처리 시 notifyUsersDeduplicatedPush를 올바르게 호출한다") + void handle_adminMessageSent_callsNotifyUsersDeduplicatedPush() { + List recipientIds = List.of(1L, 2L, 3L); + var event = new AdminNotificationEvent.AdminMessageSent(recipientIds, "안내 사항", "서비스 점검 예정입니다."); + + worker.handle(event); + + @SuppressWarnings("unchecked") + ArgumentCaptor> idsCaptor = ArgumentCaptor.forClass(List.class); + @SuppressWarnings("unchecked") + ArgumentCaptor> extrasCaptor = ArgumentCaptor.forClass(Map.class); + + verify(notifySystemService).notifyUsersDeduplicatedPush( + idsCaptor.capture(), + eq(NotificationType.SYSTEM_ADMIN_MESSAGE), + isNull(), + extrasCaptor.capture() + ); + + assertThat(idsCaptor.getValue()).containsExactly(1L, 2L, 3L); + assertThat(extrasCaptor.getValue()) + .containsEntry("title", "안내 사항") + .containsEntry("body", "서비스 점검 예정입니다."); + } + + @Test + @DisplayName("콘텐츠 삭제 이벤트 처리 시 notifyUser를 올바르게 호출한다") + void handle_contentDeletedByReport_callsNotifyUser() { + var event = new AdminNotificationEvent.ContentDeletedByReport(42L, "커뮤니티 가이드라인 위반"); + + worker.handle(event); + + @SuppressWarnings("unchecked") + ArgumentCaptor> extrasCaptor = ArgumentCaptor.forClass(Map.class); + + verify(notifySystemService).notifyUser( + eq(42L), + eq(NotificationType.SYSTEM_CONTENT_DELETED), + isNull(), + extrasCaptor.capture() + ); + + assertThat(extrasCaptor.getValue()).containsEntry("reason", "커뮤니티 가이드라인 위반"); + } + + @Test + @DisplayName("관리자 메시지 발송 중 예외가 나도 외부로 전파하지 않는다") + void handle_adminMessageSentFailure_swallowsException() { + doThrow(new RuntimeException("push failed")) + .when(notifySystemService).notifyUsersDeduplicatedPush(any(), any(), any(), any()); + + assertThatCode(() -> worker.handle( + new AdminNotificationEvent.AdminMessageSent(List.of(1L), "제목", "내용") + )).doesNotThrowAnyException(); + } + + @Test + @DisplayName("콘텐츠 삭제 알림 발송 중 예외가 나도 외부로 전파하지 않는다") + void handle_contentDeletedFailure_swallowsException() { + doThrow(new RuntimeException("push failed")) + .when(notifySystemService).notifyUser(any(), any(), any(), any()); + + assertThatCode(() -> worker.handle( + new AdminNotificationEvent.ContentDeletedByReport(1L, "사유") + )).doesNotThrowAnyException(); + } +} From 227b9cd5a10b949a31c6374623906599a81a35bb Mon Sep 17 00:00:00 2001 From: pizzazoa Date: Wed, 29 Apr 2026 22:09:11 +0900 Subject: [PATCH 7/7] =?UTF-8?q?feat:=20=EC=96=B4=EB=93=9C=EB=AF=BC=20?= =?UTF-8?q?=EC=9C=A0=EC=A0=80=20=EA=B2=80=EC=83=89,=20=EC=9C=A0=EC=A0=80?= =?UTF-8?q?=20=EB=AA=A9=EB=A1=9D=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/user/api/AdminUserController.java | 60 +++++++++++++++++ .../dto/response/AdminUserListResponse.java | 33 ++++++++++ .../application/AdminUserQueryService.java | 40 +++++++++++ .../user/core/repository/UserRepository.java | 47 +++++++++++++ .../core/repository/UserRepositoryTest.java | 66 +++++++++++++++++++ 5 files changed, 246 insertions(+) create mode 100644 src/main/java/org/devkor/apu/saerok_server/domain/admin/user/api/AdminUserController.java create mode 100644 src/main/java/org/devkor/apu/saerok_server/domain/admin/user/api/dto/response/AdminUserListResponse.java create mode 100644 src/main/java/org/devkor/apu/saerok_server/domain/admin/user/application/AdminUserQueryService.java diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/admin/user/api/AdminUserController.java b/src/main/java/org/devkor/apu/saerok_server/domain/admin/user/api/AdminUserController.java new file mode 100644 index 00000000..a7db38a9 --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/admin/user/api/AdminUserController.java @@ -0,0 +1,60 @@ +package org.devkor.apu.saerok_server.domain.admin.user.api; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.devkor.apu.saerok_server.domain.admin.user.api.dto.response.AdminUserListResponse; +import org.devkor.apu.saerok_server.domain.admin.user.application.AdminUserQueryService; +import org.devkor.apu.saerok_server.global.shared.exception.BadRequestException; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Tag(name = "Admin User API", description = "관리자 사용자 조회 API") +@RestController +@RequiredArgsConstructor +@RequestMapping("${api_prefix}/admin/users") +public class AdminUserController { + + private static final int MAX_PAGE_SIZE = 50; + + private final AdminUserQueryService queryService; + + @GetMapping + @PreAuthorize("@perm.has('ADMIN_ANNOUNCEMENT_WRITE')") + @Operation( + summary = "사용자 ID/닉네임 목록 조회", + description = "대상 공지 발송용 활성 사용자 ID와 닉네임 목록을 조회합니다.", + security = @SecurityRequirement(name = "bearerAuth"), + responses = { + @ApiResponse( + responseCode = "200", + description = "조회 성공", + content = @Content(schema = @Schema(implementation = AdminUserListResponse.class)) + ) + } + ) + public AdminUserListResponse listUsers( + @RequestParam(required = false) String q, + @RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "20") int size + ) { + validatePagination(page, size); + return queryService.listUsers(q, page, size); + } + + private void validatePagination(int page, int size) { + if (page < 1) { + throw new BadRequestException("page는 1 이상의 숫자로 입력해 주세요."); + } + if (size < 1 || size > MAX_PAGE_SIZE) { + throw new BadRequestException("size는 1 이상 50 이하의 숫자로 입력해 주세요."); + } + } +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/admin/user/api/dto/response/AdminUserListResponse.java b/src/main/java/org/devkor/apu/saerok_server/domain/admin/user/api/dto/response/AdminUserListResponse.java new file mode 100644 index 00000000..cc6bc6db --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/admin/user/api/dto/response/AdminUserListResponse.java @@ -0,0 +1,33 @@ +package org.devkor.apu.saerok_server.domain.admin.user.api.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.util.List; + +@Schema(description = "관리자 사용자 목록 응답") +public record AdminUserListResponse( + @Schema(description = "사용자 목록") + List users, + + @Schema(description = "현재 페이지", example = "1") + int page, + + @Schema(description = "페이지 크기", example = "20") + int size, + + @Schema(description = "전체 사용자 수", example = "120") + long totalElements, + + @Schema(description = "전체 페이지 수", example = "6") + int totalPages +) { + + public record Item( + @Schema(description = "사용자 ID", example = "501") + Long id, + + @Schema(description = "닉네임", example = "솔바람") + String nickname + ) { + } +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/admin/user/application/AdminUserQueryService.java b/src/main/java/org/devkor/apu/saerok_server/domain/admin/user/application/AdminUserQueryService.java new file mode 100644 index 00000000..d9ab9981 --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/admin/user/application/AdminUserQueryService.java @@ -0,0 +1,40 @@ +package org.devkor.apu.saerok_server.domain.admin.user.application; + +import lombok.RequiredArgsConstructor; +import org.devkor.apu.saerok_server.domain.admin.user.api.dto.response.AdminUserListResponse; +import org.devkor.apu.saerok_server.domain.user.core.entity.User; +import org.devkor.apu.saerok_server.domain.user.core.repository.UserRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class AdminUserQueryService { + + private final UserRepository userRepository; + + public AdminUserListResponse listUsers(String query, int page, int size) { + String normalizedQuery = normalizeQuery(query); + int offset = (page - 1) * size; + + List users = userRepository.findActiveNicknameUsers(normalizedQuery, offset, size); + long totalElements = userRepository.countActiveNicknameUsers(normalizedQuery); + int totalPages = totalElements == 0 ? 0 : (int) Math.ceil((double) totalElements / size); + + List items = users.stream() + .map(user -> new AdminUserListResponse.Item(user.getId(), user.getNickname())) + .toList(); + + return new AdminUserListResponse(items, page, size, totalElements, totalPages); + } + + private String normalizeQuery(String query) { + if (query == null || query.isBlank()) { + return null; + } + return query.trim(); + } +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/user/core/repository/UserRepository.java b/src/main/java/org/devkor/apu/saerok_server/domain/user/core/repository/UserRepository.java index 6db20445..7bb154f5 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/user/core/repository/UserRepository.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/user/core/repository/UserRepository.java @@ -62,6 +62,53 @@ public List findActiveUserIds(int offset, int limit) { .getResultList(); } + public List findActiveNicknameUsers(String nicknameQuery, int offset, int limit) { + String jpql = """ + SELECT u FROM User u + WHERE u.deletedAt IS NULL + AND u.signupStatus <> :withdrawn + AND u.nickname IS NOT NULL + AND TRIM(u.nickname) <> '' + """; + if (nicknameQuery != null) { + jpql += " AND u.nickname LIKE :nicknameQuery"; + } + jpql += " ORDER BY u.nickname ASC, u.id ASC"; + + var query = em.createQuery(jpql, User.class) + .setParameter("withdrawn", SignupStatusType.WITHDRAWN) + .setFirstResult(offset) + .setMaxResults(limit); + + if (nicknameQuery != null) { + query.setParameter("nicknameQuery", "%" + nicknameQuery + "%"); + } + + return query.getResultList(); + } + + public long countActiveNicknameUsers(String nicknameQuery) { + String jpql = """ + SELECT COUNT(u) FROM User u + WHERE u.deletedAt IS NULL + AND u.signupStatus <> :withdrawn + AND u.nickname IS NOT NULL + AND TRIM(u.nickname) <> '' + """; + if (nicknameQuery != null) { + jpql += " AND u.nickname LIKE :nicknameQuery"; + } + + var query = em.createQuery(jpql, Long.class) + .setParameter("withdrawn", SignupStatusType.WITHDRAWN); + + if (nicknameQuery != null) { + query.setParameter("nicknameQuery", "%" + nicknameQuery + "%"); + } + + return query.getSingleResult(); + } + public List findByIds(List ids) { if (ids == null || ids.isEmpty()) return List.of(); return em.createQuery( diff --git a/src/test/java/org/devkor/apu/saerok_server/domain/user/core/repository/UserRepositoryTest.java b/src/test/java/org/devkor/apu/saerok_server/domain/user/core/repository/UserRepositoryTest.java index 79be47cd..1f53c113 100644 --- a/src/test/java/org/devkor/apu/saerok_server/domain/user/core/repository/UserRepositoryTest.java +++ b/src/test/java/org/devkor/apu/saerok_server/domain/user/core/repository/UserRepositoryTest.java @@ -138,6 +138,72 @@ void findActiveUserIds_empty() { assertThat(activeIds).isEmpty(); } + @Test @DisplayName("findActiveNicknameUsers - 활성 닉네임 사용자만 닉네임순으로 조회") + void findActiveNicknameUsers() { + User charlie = user("charlie@example.com", "charlie"); + charlie.setSignupStatus(SignupStatusType.COMPLETED); + User bravo = user("bravo@example.com", "bravo"); + bravo.setSignupStatus(SignupStatusType.COMPLETED); + User withdrawn = user("withdrawn-nickname@example.com", "alpha"); + withdrawn.setSignupStatus(SignupStatusType.WITHDRAWN); + User deleted = user("deleted-nickname@example.com", "beta"); + User noNickname = user("no-nickname@example.com", null); + noNickname.setSignupStatus(SignupStatusType.COMPLETED); + User blankNickname = user("blank-nickname@example.com", ""); + blankNickname.setSignupStatus(SignupStatusType.COMPLETED); + em.flush(); + + deleted.softDelete(); + em.flush(); em.clear(); + + List users = repo.findActiveNicknameUsers(null, 0, 20); + + assertThat(users) + .extracting(User::getNickname) + .containsExactly("bravo", "charlie"); + assertThat(repo.countActiveNicknameUsers(null)).isEqualTo(2); + } + + @Test @DisplayName("findActiveNicknameUsers - 닉네임 검색과 페이징") + void findActiveNicknameUsers_queryAndPagination() { + User alpha = user("alpha@example.com", "alpha"); + alpha.setSignupStatus(SignupStatusType.COMPLETED); + User alpine = user("alpine@example.com", "alpine"); + alpine.setSignupStatus(SignupStatusType.COMPLETED); + User bravo = user("bravo-query@example.com", "bravo"); + bravo.setSignupStatus(SignupStatusType.COMPLETED); + em.flush(); em.clear(); + + List firstPage = repo.findActiveNicknameUsers("alp", 0, 1); + List secondPage = repo.findActiveNicknameUsers("alp", 1, 1); + + assertThat(firstPage) + .extracting(User::getNickname) + .containsExactly("alpha"); + assertThat(secondPage) + .extracting(User::getNickname) + .containsExactly("alpine"); + assertThat(repo.countActiveNicknameUsers("alp")).isEqualTo(2); + } + + @Test @DisplayName("findActiveNicknameUsers - 닉네임 중간 문자열 검색") + void findActiveNicknameUsers_containsQuery() { + User duli = user("duli@example.com", "둘리"); + duli.setSignupStatus(SignupStatusType.COMPLETED); + User pigeon = user("pigeon@example.com", "비둘기"); + pigeon.setSignupStatus(SignupStatusType.COMPLETED); + User magpie = user("magpie@example.com", "까치"); + magpie.setSignupStatus(SignupStatusType.COMPLETED); + em.flush(); em.clear(); + + List users = repo.findActiveNicknameUsers("둘", 0, 20); + + assertThat(users) + .extracting(User::getNickname) + .containsExactly("둘리", "비둘기"); + assertThat(repo.countActiveNicknameUsers("둘")).isEqualTo(2); + } + @Test @DisplayName("save - 중복 닉네임은 제약조건 위반") void save_duplicateNickname() { User u1 = user("user1@example.com");