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/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 294dd10f..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; @@ -17,6 +17,6 @@ public record GetCommunityMainResponse( @Schema(description = "동정 요청 새록 목록 (최대 3개)") List pendingCollections, - @Schema(description = "최근 자유게시판 글 (최대 3개)") - List recentFreeBoardPosts + @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..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(3); + 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/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/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 e87d0eeb..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; @@ -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(); @@ -94,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); @@ -110,14 +111,15 @@ 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(), 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/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 +) { +} 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(); + } +} 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..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,9 +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.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; @@ -24,6 +28,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; @@ -34,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) { @@ -100,6 +106,7 @@ void setUp() { communityQueryService = new CommunityQueryService( communityRepository, dataAssembler, + communityWebMapper, freeBoardPostQueryService ); } @@ -213,4 +220,54 @@ 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 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 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 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 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 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); + + // Then + 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); + } }