diff --git a/src/main/java/konkuk/thip/common/exception/code/ErrorCode.java b/src/main/java/konkuk/thip/common/exception/code/ErrorCode.java index 9790bb1be..0d7f37207 100644 --- a/src/main/java/konkuk/thip/common/exception/code/ErrorCode.java +++ b/src/main/java/konkuk/thip/common/exception/code/ErrorCode.java @@ -231,6 +231,17 @@ public enum ErrorCode implements ResponseCode { * 205000 : notification error */ INVALID_NOTIFICATION_TYPE(HttpStatus.BAD_REQUEST, 205000, "유효하지 않은 알림 타입입니다."), + NOTIFICATION_REDIRECT_DATA_SERIALIZE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, 205001, "알림 리다이렉트 데이터 직렬화에 실패했습니다."), + NOTIFICATION_REDIRECT_DATA_DESERIALIZE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, 205002, "알림 리다이렉트 데이터 역직렬화에 실패했습니다."), + NOTIFICATION_NOT_FOUND(HttpStatus.NOT_FOUND, 205003, "존재하지 않는 NOTIFICATION 입니다."), + NOTIFICATION_ACCESS_FORBIDDEN(HttpStatus.FORBIDDEN, 205004, "알림 접근 권한이 없습니다."), + NOTIFICATION_ALREADY_CHECKED(HttpStatus.BAD_REQUEST, 205005, "이미 읽음 처리된 알림입니다."), + + + /** + * 300000 : util error + */ + INVALID_FE_PLATFORM(HttpStatus.BAD_REQUEST, 300000, "유효하지 않은 FE 플랫폼입니다."), ; diff --git a/src/main/java/konkuk/thip/common/swagger/SwaggerResponseDescription.java b/src/main/java/konkuk/thip/common/swagger/SwaggerResponseDescription.java index 81c2e1f04..37ac64015 100644 --- a/src/main/java/konkuk/thip/common/swagger/SwaggerResponseDescription.java +++ b/src/main/java/konkuk/thip/common/swagger/SwaggerResponseDescription.java @@ -358,7 +358,8 @@ public enum SwaggerResponseDescription { // Notiification FCM_TOKEN_REGISTER(new LinkedHashSet<>(Set.of( USER_NOT_FOUND, - FCM_TOKEN_NOT_FOUND + FCM_TOKEN_NOT_FOUND, + INVALID_FE_PLATFORM ))), FCM_TOKEN_ENABLE_STATE_CHANGE(new LinkedHashSet<>(Set.of( USER_NOT_FOUND, @@ -376,6 +377,11 @@ public enum SwaggerResponseDescription { NOTIFICATION_SHOW(new LinkedHashSet<>(Set.of( INVALID_NOTIFICATION_TYPE ))), + NOTIFICATION_MARK_TO_CHECKED(new LinkedHashSet<>(Set.of( + NOTIFICATION_NOT_FOUND, + NOTIFICATION_ACCESS_FORBIDDEN, + NOTIFICATION_ALREADY_CHECKED + ))), ; private final Set errorCodeList; diff --git a/src/main/java/konkuk/thip/feed/application/service/FeedCreateService.java b/src/main/java/konkuk/thip/feed/application/service/FeedCreateService.java index 4d61ce21d..b439dfcb5 100644 --- a/src/main/java/konkuk/thip/feed/application/service/FeedCreateService.java +++ b/src/main/java/konkuk/thip/feed/application/service/FeedCreateService.java @@ -71,7 +71,7 @@ private void sendNotifications(FeedCreateCommand command, Long savedFeedId) { List targetUsers = userQueryPort.getAllFollowersByUserId(command.userId()); User actorUser = userCommandPort.findById(command.userId()); for (User targetUser : targetUsers) { - feedNotificationOrchestrator.notifyFolloweeNewPost(targetUser.getId(), actorUser.getId(), actorUser.getNickname(), savedFeedId); + feedNotificationOrchestrator.notifyFolloweeNewFeed(targetUser.getId(), actorUser.getId(), actorUser.getNickname(), savedFeedId); } } diff --git a/src/main/java/konkuk/thip/message/adapter/in/event/MessageFeedEventListener.java b/src/main/java/konkuk/thip/message/adapter/in/event/MessageFeedEventListener.java index e8e97855e..10f9a7427 100644 --- a/src/main/java/konkuk/thip/message/adapter/in/event/MessageFeedEventListener.java +++ b/src/main/java/konkuk/thip/message/adapter/in/event/MessageFeedEventListener.java @@ -34,8 +34,8 @@ public void onFeedCommentReplied(FeedEvents.FeedCommentRepliedEvent e) { @Async @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) - public void onFolloweeNewPost(FeedEvents.FolloweeNewPostEvent e) { - feedUseCase.handleFolloweeNewPost(e); + public void onFolloweeNewFeed(FeedEvents.FolloweeNewFeedEvent e) { + feedUseCase.handleFolloweeNewFeed(e); } @Async diff --git a/src/main/java/konkuk/thip/message/adapter/out/event/FeedEventPublisherAdapter.java b/src/main/java/konkuk/thip/message/adapter/out/event/FeedEventPublisherAdapter.java index f210e480b..59084d828 100644 --- a/src/main/java/konkuk/thip/message/adapter/out/event/FeedEventPublisherAdapter.java +++ b/src/main/java/konkuk/thip/message/adapter/out/event/FeedEventPublisherAdapter.java @@ -14,89 +14,73 @@ public class FeedEventPublisherAdapter implements FeedEventCommandPort { @Override public void publishFollowEvent( - String title, String content, - Long targetUserId, Long actorUserId, String actorUsername) { + String title, String content, Long notificationId, + Long targetUserId) { publisher.publishEvent(FeedEvents.FollowerEvent.builder() .title(title) .content(content) + .notificationId(notificationId) .targetUserId(targetUserId) - .actorUserId(actorUserId) - .actorUsername(actorUsername) .build()); } @Override public void publishFeedCommentedEvent( - String title, String content, - Long targetUserId, Long actorUserId, String actorUsername, - Long feedId) { + String title, String content, Long notificationId, + Long targetUserId) { publisher.publishEvent(FeedEvents.FeedCommentedEvent.builder() .title(title) .content(content) + .notificationId(notificationId) .targetUserId(targetUserId) - .actorUserId(actorUserId) - .actorUsername(actorUsername) - .feedId(feedId) .build()); } @Override public void publishFeedRepliedEvent( - String title, String content, - Long targetUserId, Long actorUserId, String actorUsername, - Long feedId) { + String title, String content, Long notificationId, + Long targetUserId) { publisher.publishEvent(FeedEvents.FeedCommentRepliedEvent.builder() .title(title) .content(content) + .notificationId(notificationId) .targetUserId(targetUserId) - .actorUserId(actorUserId) - .actorUsername(actorUsername) - .feedId(feedId) .build()); } @Override - public void publishFolloweeNewPostEvent( - String title, String content, - Long targetUserId, Long actorUserId, String actorUsername, - Long feedId) { - publisher.publishEvent(FeedEvents.FolloweeNewPostEvent.builder() + public void publishFolloweeNewFeedEvent( + String title, String content, Long notificationId, + Long targetUserId) { + publisher.publishEvent(FeedEvents.FolloweeNewFeedEvent.builder() .title(title) .content(content) + .notificationId(notificationId) .targetUserId(targetUserId) - .actorUserId(actorUserId) - .actorUsername(actorUsername) - .feedId(feedId) .build()); } @Override public void publishFeedLikedEvent( - String title, String content, - Long targetUserId, Long actorUserId, String actorUsername, - Long feedId) { + String title, String content, Long notificationId, + Long targetUserId) { publisher.publishEvent(FeedEvents.FeedLikedEvent.builder() .title(title) .content(content) + .notificationId(notificationId) .targetUserId(targetUserId) - .actorUserId(actorUserId) - .actorUsername(actorUsername) - .feedId(feedId) .build()); } @Override public void publishFeedCommentLikedEvent( - String title, String content, - Long targetUserId, Long actorUserId, String actorUsername, - Long feedId) { + String title, String content, Long notificationId, + Long targetUserId) { publisher.publishEvent(FeedEvents.FeedCommentLikedEvent.builder() .title(title) .content(content) + .notificationId(notificationId) .targetUserId(targetUserId) - .actorUserId(actorUserId) - .actorUsername(actorUsername) - .feedId(feedId) .build()); } -} \ No newline at end of file +} diff --git a/src/main/java/konkuk/thip/message/adapter/out/event/RoomEventPublisherAdapter.java b/src/main/java/konkuk/thip/message/adapter/out/event/RoomEventPublisherAdapter.java index 873b42131..f63d58c9e 100644 --- a/src/main/java/konkuk/thip/message/adapter/out/event/RoomEventPublisherAdapter.java +++ b/src/main/java/konkuk/thip/message/adapter/out/event/RoomEventPublisherAdapter.java @@ -14,148 +14,109 @@ public class RoomEventPublisherAdapter implements RoomEventCommandPort { @Override public void publishRoomPostCommentedEvent( - String title, String content, - Long targetUserId, Long actorUserId, String actorUsername, - Long roomId, Integer page, Long postId, String postType) { + String title, String content, Long notificationId, + Long targetUserId) { publisher.publishEvent(RoomEvents.RoomPostCommentedEvent.builder() .title(title) .content(content) + .notificationId(notificationId) .targetUserId(targetUserId) - .actorUserId(actorUserId) - .actorUsername(actorUsername) - .roomId(roomId) - .page(page) - .postId(postId) - .postType(postType) .build()); } @Override public void publishRoomVoteStartedEvent( - String title, String content, - Long targetUserId, Long roomId, String roomTitle, - Integer page, Long postId) { + String title, String content, Long notificationId, + Long targetUserId) { publisher.publishEvent(RoomEvents.RoomVoteStartedEvent.builder() .title(title) .content(content) + .notificationId(notificationId) .targetUserId(targetUserId) - .roomId(roomId) - .roomTitle(roomTitle) - .page(page) - .postId(postId) .build()); } @Override public void publishRoomRecordCreatedEvent( - String title, String content, - Long targetUserId, Long actorUserId, String actorUsername, - Long roomId, String roomTitle, Integer page, Long postId) { + String title, String content, Long notificationId, + Long targetUserId) { publisher.publishEvent(RoomEvents.RoomRecordCreatedEvent.builder() .title(title) .content(content) + .notificationId(notificationId) .targetUserId(targetUserId) - .actorUserId(actorUserId) - .actorUsername(actorUsername) - .roomId(roomId) - .roomTitle(roomTitle) - .page(page) - .postId(postId) .build()); } @Override public void publishRoomRecruitClosedEarlyEvent( - String title, String content, - Long targetUserId, Long roomId, String roomTitle) { + String title, String content, Long notificationId, + Long targetUserId) { publisher.publishEvent(RoomEvents.RoomRecruitClosedEarlyEvent.builder() .title(title) .content(content) + .notificationId(notificationId) .targetUserId(targetUserId) - .roomId(roomId) - .roomTitle(roomTitle) .build()); } @Override public void publishRoomActivityStartedEvent( - String title, String content, - Long targetUserId, Long roomId, String roomTitle) { + String title, String content, Long notificationId, + Long targetUserId) { publisher.publishEvent(RoomEvents.RoomActivityStartedEvent.builder() .title(title) .content(content) + .notificationId(notificationId) .targetUserId(targetUserId) - .roomId(roomId) - .roomTitle(roomTitle) .build()); } @Override public void publishRoomJoinEventToHost( - String title, String content, - Long hostUserId, Long roomId, String roomTitle, - Long actorUserId, String actorUsername) { + String title, String content, Long notificationId, + Long targetUserId) { publisher.publishEvent(RoomEvents.RoomJoinRequestedToOwnerEvent.builder() .title(title) .content(content) - .ownerUserId(hostUserId) - .roomId(roomId) - .roomTitle(roomTitle) - .applicantUserId(actorUserId) - .applicantUsername(actorUsername) + .notificationId(notificationId) + .targetUserId(targetUserId) .build()); } @Override public void publishRoomCommentLikedEvent( - String title, String content, - Long targetUserId, Long actorUserId, String actorUsername, - Long roomId, Integer page, Long postId, String postType) { + String title, String content, Long notificationId, + Long targetUserId) { publisher.publishEvent(RoomEvents.RoomCommentLikedEvent.builder() .title(title) .content(content) + .notificationId(notificationId) .targetUserId(targetUserId) - .actorUserId(actorUserId) - .actorUsername(actorUsername) - .roomId(roomId) - .page(page) - .postId(postId) - .postType(postType) .build()); } @Override public void publishRoomPostLikedEvent( - String title, String content, - Long targetUserId, Long actorUserId, String actorUsername, - Long roomId, Integer page, Long postId, String postType) { + String title, String content, Long notificationId, + Long targetUserId) { publisher.publishEvent(RoomEvents.RoomPostLikedEvent.builder() .title(title) .content(content) + .notificationId(notificationId) .targetUserId(targetUserId) - .actorUserId(actorUserId) - .actorUsername(actorUsername) - .roomId(roomId) - .page(page) - .postId(postId) - .postType(postType) .build()); } @Override public void publishRoomPostCommentRepliedEvent( - String title, String content, - Long targetUserId, Long actorUserId, String actorUsername, Long roomId, Integer page, Long postId, String postType) { + String title, String content, Long notificationId, + Long targetUserId) { publisher.publishEvent(RoomEvents.RoomPostCommentRepliedEvent.builder() .title(title) .content(content) + .notificationId(notificationId) .targetUserId(targetUserId) - .actorUserId(actorUserId) - .actorUsername(actorUsername) - .roomId(roomId) - .page(page) - .postId(postId) - .postType(postType) .build()); } -} \ No newline at end of file +} diff --git a/src/main/java/konkuk/thip/message/adapter/out/event/dto/FeedEvents.java b/src/main/java/konkuk/thip/message/adapter/out/event/dto/FeedEvents.java index 922721d64..a2fe8a85f 100644 --- a/src/main/java/konkuk/thip/message/adapter/out/event/dto/FeedEvents.java +++ b/src/main/java/konkuk/thip/message/adapter/out/event/dto/FeedEvents.java @@ -1,4 +1,3 @@ -// message/adapter/out/event/dto/FeedEvents.java package konkuk.thip.message.adapter.out.event.dto; import lombok.Builder; @@ -8,41 +7,36 @@ public class FeedEvents { // 누군가 나를 팔로우하는 경우 @Builder public record FollowerEvent( - String title, String content, - Long targetUserId, Long actorUserId, String actorUsername) {} + String title, String content, Long notificationId, + Long targetUserId) {} // 누군가 내 피드에 댓글을 다는 경우 @Builder public record FeedCommentedEvent( - String title, String content, - Long targetUserId, Long actorUserId, String actorUsername, - Long feedId) {} + String title, String content, Long notificationId, + Long targetUserId) {} // 누군가 내 댓글에 대댓글을 다는 경우 @Builder public record FeedCommentRepliedEvent( - String title, String content, - Long targetUserId, Long actorUserId, String actorUsername, - Long feedId) {} + String title, String content, Long notificationId, + Long targetUserId) {} - // 내가 팔로우하는 사람이 새 글을 올리는 경우 + // 내가 팔로우하는 사람이 새 피드를 올리는 경우 @Builder - public record FolloweeNewPostEvent( - String title, String content, - Long targetUserId, Long actorUserId, String actorUsername, - Long feedId) {} + public record FolloweeNewFeedEvent( + String title, String content, Long notificationId, + Long targetUserId) {} // 내 피드가 좋아요를 받는 경우 @Builder public record FeedLikedEvent( - String title, String content, - Long targetUserId, Long actorUserId, String actorUsername, - Long feedId) {} + String title, String content, Long notificationId, + Long targetUserId) {} // 내 피드 댓글이 좋아요를 받는 경우 @Builder public record FeedCommentLikedEvent( - String title, String content, - Long targetUserId, Long actorUserId, String actorUsername, - Long feedId) {} + String title, String content, Long notificationId, + Long targetUserId) {} } diff --git a/src/main/java/konkuk/thip/message/adapter/out/event/dto/RoomEvents.java b/src/main/java/konkuk/thip/message/adapter/out/event/dto/RoomEvents.java index e96c8c098..00880f84a 100644 --- a/src/main/java/konkuk/thip/message/adapter/out/event/dto/RoomEvents.java +++ b/src/main/java/konkuk/thip/message/adapter/out/event/dto/RoomEvents.java @@ -1,4 +1,3 @@ -// message/adapter/out/event/dto/RoomEvents.java package konkuk.thip.message.adapter.out.event.dto; import lombok.Builder; @@ -9,61 +8,54 @@ public class RoomEvents { // 내 모임방 기록/투표에 댓글이 달린 경우 @Builder public record RoomPostCommentedEvent( - String title, String content, - Long targetUserId, Long actorUserId, String actorUsername, - Long roomId, Integer page, Long postId, String postType) {} + String title, String content, Long notificationId, + Long targetUserId) {} // 내가 참여한 모임방에 새로운 투표가 시작된 경우 @Builder public record RoomVoteStartedEvent( - String title, String content, - Long targetUserId, Long roomId, String roomTitle, - Integer page, Long postId) {} + String title, String content, Long notificationId, + Long targetUserId) {} // 내가 참여한 모임방에 새로운 기록이 작성된 경우 @Builder public record RoomRecordCreatedEvent( - String title, String content, - Long targetUserId, Long actorUserId, String actorUsername, - Long roomId, String roomTitle, Integer page, Long postId) {} + String title, String content, Long notificationId, + Long targetUserId) {} // 내가 참여한 모임방이 조기 종료된 경우 (호스트가 모집 마감 버튼 누른 경우) @Builder public record RoomRecruitClosedEarlyEvent( - String title, String content, - Long targetUserId, Long roomId, String roomTitle) {} + String title, String content, Long notificationId, + Long targetUserId) {} // 내가 참여한 모임방 활동이 시작된 경우 (방이 시작 기간이 되어 자동으로 시작된 경우) @Builder public record RoomActivityStartedEvent( - String title, String content, - Long targetUserId, Long roomId, String roomTitle) {} + String title, String content, Long notificationId, + Long targetUserId) {} // 내가 방장일 때, 새로운 사용자가 모임방 참여를 한 경우 @Builder public record RoomJoinRequestedToOwnerEvent( - String title, String content, - Long ownerUserId, Long roomId, String roomTitle, - Long applicantUserId, String applicantUsername) {} + String title, String content, Long notificationId, + Long targetUserId) {} // 내가 참여한 모임방의 나의 댓글이 좋아요를 받는 경우 @Builder public record RoomCommentLikedEvent( - String title, String content, - Long targetUserId, Long actorUserId, String actorUsername, - Long roomId, Integer page, Long postId, String postType) {} + String title, String content, Long notificationId, + Long targetUserId) {} // 내가 참여한 모임방의 나의 기록이 좋아요를 받는 경우 @Builder public record RoomPostLikedEvent( - String title, String content, - Long targetUserId, Long actorUserId, String actorUsername, - Long roomId, Integer page, Long postId, String postType) {} + String title, String content, Long notificationId, + Long targetUserId) {} // 내가 참여한 모임방의 나의 댓글에 대댓글이 달린 경우 @Builder public record RoomPostCommentRepliedEvent( - String title, String content, - Long targetUserId, Long actorUserId, String actorUsername, - Long roomId, Integer page, Long postId, String postType) {} + String title, String content, Long notificationId, + Long targetUserId) {} } diff --git a/src/main/java/konkuk/thip/message/application/port/in/FeedNotificationDispatchUseCase.java b/src/main/java/konkuk/thip/message/application/port/in/FeedNotificationDispatchUseCase.java index 66eaee3e8..9a0e294ce 100644 --- a/src/main/java/konkuk/thip/message/application/port/in/FeedNotificationDispatchUseCase.java +++ b/src/main/java/konkuk/thip/message/application/port/in/FeedNotificationDispatchUseCase.java @@ -9,7 +9,7 @@ public interface FeedNotificationDispatchUseCase { void handleFeedCommentReplied(FeedEvents.FeedCommentRepliedEvent e); - void handleFolloweeNewPost(FeedEvents.FolloweeNewPostEvent e); + void handleFolloweeNewFeed(FeedEvents.FolloweeNewFeedEvent e); void handleFeedLiked(FeedEvents.FeedLikedEvent e); diff --git a/src/main/java/konkuk/thip/message/application/port/out/FeedEventCommandPort.java b/src/main/java/konkuk/thip/message/application/port/out/FeedEventCommandPort.java index aa9b0433f..0fbe78c6b 100644 --- a/src/main/java/konkuk/thip/message/application/port/out/FeedEventCommandPort.java +++ b/src/main/java/konkuk/thip/message/application/port/out/FeedEventCommandPort.java @@ -4,36 +4,31 @@ public interface FeedEventCommandPort { // 누군가 나를 팔로우하는 경우 void publishFollowEvent( - String title, String content, - Long targetUserId, Long actorUserId, String actorUsername); + String title, String content, Long notificationId, + Long targetUserId); // 누군가 내 피드에 댓글을 다는 경우 void publishFeedCommentedEvent( - String title, String content, - Long targetUserId, Long actorUserId, String actorUsername, - Long feedId); + String title, String content, Long notificationId, + Long targetUserId); // 누군가 내 댓글에 대댓글을 다는 경우 void publishFeedRepliedEvent( - String title, String content, - Long targetUserId, Long actorUserId, String actorUsername, - Long feedId); + String title, String content, Long notificationId, + Long targetUserId); // 내가 팔로우하는 사람이 새 글을 올리는 경우 - void publishFolloweeNewPostEvent( - String title, String content, - Long targetUserId, Long actorUserId, String actorUsername, - Long feedId); + void publishFolloweeNewFeedEvent( + String title, String content, Long notificationId, + Long targetUserId); // 내 피드가 좋아요를 받는 경우 void publishFeedLikedEvent( - String title, String content, - Long targetUserId, Long actorUserId, String actorUsername, - Long feedId); + String title, String content, Long notificationId, + Long targetUserId); // 내 피드 댓글이 좋아요를 받는 경우 void publishFeedCommentLikedEvent( - String title, String content, - Long targetUserId, Long actorUserId, String actorUsername, - Long feedId); + String title, String content, Long notificationId, + Long targetUserId); } \ No newline at end of file diff --git a/src/main/java/konkuk/thip/message/application/port/out/RoomEventCommandPort.java b/src/main/java/konkuk/thip/message/application/port/out/RoomEventCommandPort.java index a52c7c990..c4d6d1527 100644 --- a/src/main/java/konkuk/thip/message/application/port/out/RoomEventCommandPort.java +++ b/src/main/java/konkuk/thip/message/application/port/out/RoomEventCommandPort.java @@ -4,53 +4,46 @@ public interface RoomEventCommandPort { // 내 모임방 기록/투표에 댓글이 달린 경우 void publishRoomPostCommentedEvent( - String title, String content, - Long targetUserId, Long actorUserId, String actorUsername, - Long roomId, Integer page, Long postId, String postType); + String title, String content, Long notificationId, + Long targetUserId); // 내가 참여한 모임방에 새로운 투표가 시작된 경우 void publishRoomVoteStartedEvent( - String title, String content, - Long targetUserId, Long roomId, String roomTitle, - Integer page, Long postId); + String title, String content, Long notificationId, + Long targetUserId); // 내가 참여한 모임방에 새로운 기록이 작성된 경우 void publishRoomRecordCreatedEvent( - String title, String content, - Long targetUserId, Long actorUserId, String actorUsername, - Long roomId, String roomTitle, Integer page, Long postId); + String title, String content, Long notificationId, + Long targetUserId); // 내가 참여한 모임방이 조기 종료된 경우 (호스트가 모집 마감 버튼 누른 경우) void publishRoomRecruitClosedEarlyEvent( - String title, String content, - Long targetUserId, Long roomId, String roomTitle); + String title, String content, Long notificationId, + Long targetUserId); // 내가 참여한 모임방 활동이 시작된 경우 (방이 시작 기간이 되어 자동으로 시작된 경우) void publishRoomActivityStartedEvent( - String title, String content, - Long targetUserId, Long roomId, String roomTitle); + String title, String content, Long notificationId, + Long targetUserId); // 내가 방장일 때, 새로운 사용자가 모임방 참여를 한 경우 void publishRoomJoinEventToHost( - String title, String content, - Long hostUserId, Long roomId, String roomTitle, - Long actorUserId, String actorUsername); + String title, String content, Long notificationId, + Long targetUserId); // 내가 참여한 모임방의 나의 댓글이 좋아요를 받는 경우 void publishRoomCommentLikedEvent( - String title, String content, - Long targetUserId, Long actorUserId, String actorUsername, - Long roomId, Integer page, Long postId, String postType); + String title, String content, Long notificationId, + Long targetUserId); // 내가 참여한 모임방 안의 나의 기록/투표가 좋아요를 받는 경우 void publishRoomPostLikedEvent( - String title, String content, - Long targetUserId, Long actorUserId, String actorUsername, - Long roomId, Integer page, Long postId, String postType); + String title, String content, Long notificationId, + Long targetUserId); // 내가 참여한 모임방의 나의 댓글에 대댓글이 달린 경우 void publishRoomPostCommentRepliedEvent( - String title, String content, - Long targetUserId, Long actorUserId, String actorUsername, - Long roomId, Integer page, Long postId, String postType); + String title, String content, Long notificationId, + Long targetUserId); } \ No newline at end of file diff --git a/src/main/java/konkuk/thip/message/application/service/FeedNotificationDispatchService.java b/src/main/java/konkuk/thip/message/application/service/FeedNotificationDispatchService.java index 1941205dd..a029d11b3 100644 --- a/src/main/java/konkuk/thip/message/application/service/FeedNotificationDispatchService.java +++ b/src/main/java/konkuk/thip/message/application/service/FeedNotificationDispatchService.java @@ -6,7 +6,6 @@ import konkuk.thip.message.application.port.out.FirebaseMessagingPort; import konkuk.thip.message.adapter.out.event.dto.FeedEvents; import konkuk.thip.notification.domain.value.NotificationCategory; -import konkuk.thip.message.domain.MessageRoute; import konkuk.thip.notification.application.port.out.FcmTokenPersistencePort; import konkuk.thip.notification.domain.FcmToken; import lombok.RequiredArgsConstructor; @@ -26,96 +25,66 @@ public class FeedNotificationDispatchService implements FeedNotificationDispatch @Override public void handleFollower(final FeedEvents.FollowerEvent event) { - Notification n = buildNotification(event.title(), event.content()); - - List tokens = fcmTokenPersistencePort.findEnabledByUserId(event.targetUserId()); - - if (tokens.isEmpty()) return; - - List msgs = new ArrayList<>(tokens.size()); - List tk = new ArrayList<>(tokens.size()); - List dev = new ArrayList<>(tokens.size()); - - for (FcmToken t : tokens) { - Message m = buildMessage(t.getFcmToken(), n, - MessageRoute.FEED_USER, - "userId", String.valueOf(event.actorUserId())); - - msgs.add(m); tk.add(t.getFcmToken()); dev.add(t.getDeviceId()); - } - firebasePort.sendBatch(msgs, tk, dev); + Notification n = buildFcmNotification(event.title(), event.content()); + push(event.targetUserId(), n, event.notificationId()); } @Override public void handleFeedCommented(final FeedEvents.FeedCommentedEvent event) { - Notification notification = buildNotification(event.title(), event.content()); - - pushFeedDetail(event.targetUserId(), notification, event.feedId()); + Notification n = buildFcmNotification(event.title(), event.content()); + push(event.targetUserId(), n, event.notificationId()); } @Override public void handleFeedCommentReplied(final FeedEvents.FeedCommentRepliedEvent event) { - Notification notification = buildNotification(event.title(), event.content()); - - pushFeedDetail(event.targetUserId(), notification, event.feedId()); + Notification n = buildFcmNotification(event.title(), event.content()); + push(event.targetUserId(), n, event.notificationId()); } @Override - public void handleFolloweeNewPost(final FeedEvents.FolloweeNewPostEvent event) { - Notification notification = buildNotification(event.title(), event.content()); - - pushFeedDetail(event.targetUserId(), notification, event.feedId()); + public void handleFolloweeNewFeed(final FeedEvents.FolloweeNewFeedEvent event) { + Notification n = buildFcmNotification(event.title(), event.content()); + push(event.targetUserId(), n, event.notificationId()); } @Override public void handleFeedLiked(final FeedEvents.FeedLikedEvent event) { - Notification notification = buildNotification(event.title(), event.content()); - - pushFeedDetail(event.targetUserId(), notification, event.feedId()); + Notification n = buildFcmNotification(event.title(), event.content()); + push(event.targetUserId(), n, event.notificationId()); } @Override public void handleFeedCommentLiked(final FeedEvents.FeedCommentLikedEvent event) { - Notification notification = buildNotification(event.title(), event.content()); - - pushFeedDetail(event.targetUserId(), notification, event.feedId()); + Notification n = buildFcmNotification(event.title(), event.content()); + push(event.targetUserId(), n, event.notificationId()); } - private void pushFeedDetail(Long userId, Notification notification, Long feedId) { + private void push(Long userId, Notification n, Long notificationId) { List tokens = fcmTokenPersistencePort.findEnabledByUserId(userId); - if (tokens.isEmpty()) return; List msgs = new ArrayList<>(tokens.size()); - List tk = new ArrayList<>(tokens.size()); + List tk = new ArrayList<>(tokens.size()); List dev = new ArrayList<>(tokens.size()); for (FcmToken t : tokens) { - Message m = buildMessage(t.getFcmToken(), notification, - MessageRoute.FEED_DETAIL, - "feedId", String.valueOf(feedId)); + Message m = Message.builder() + .setToken(t.getFcmToken()) + .setNotification(n) + .putData("category", NotificationCategory.FEED.getDisplay()) + .putData("action", "OPEN_NOTIFICATION") // FE는 이 액션으로 알림 상세/라우팅을 BE api 요청으로 처리 + .putData("notificationId", String.valueOf(notificationId)) + .build(); msgs.add(m); tk.add(t.getFcmToken()); dev.add(t.getDeviceId()); } + firebasePort.sendBatch(msgs, tk, dev); } - private Notification buildNotification(final String title, final String body) { + private Notification buildFcmNotification(final String title, final String body) { return Notification.builder().setTitle(title).setBody(body).build(); } - - private Message buildMessage(final String token, final Notification n, - final MessageRoute route, - final String... kv) { - Message.Builder b = Message.builder() - .setToken(token) - .setNotification(n) - .putData("category", NotificationCategory.FEED.getDisplay()) - .putData("action", "OPEN_ROUTE") - .putData("route", route.getCode()); - for (int i = 0; i + 1 < kv.length; i += 2) b.putData(kv[i], kv[i + 1]); - return b.build(); - } -} \ No newline at end of file +} diff --git a/src/main/java/konkuk/thip/message/application/service/RoomNotificationDispatchService.java b/src/main/java/konkuk/thip/message/application/service/RoomNotificationDispatchService.java index e705697a2..97abbdd4a 100644 --- a/src/main/java/konkuk/thip/message/application/service/RoomNotificationDispatchService.java +++ b/src/main/java/konkuk/thip/message/application/service/RoomNotificationDispatchService.java @@ -6,7 +6,6 @@ import konkuk.thip.message.application.port.out.FirebaseMessagingPort; import konkuk.thip.message.adapter.out.event.dto.RoomEvents; import konkuk.thip.notification.domain.value.NotificationCategory; -import konkuk.thip.message.domain.MessageRoute; import konkuk.thip.notification.application.port.out.FcmTokenPersistencePort; import konkuk.thip.notification.domain.FcmToken; import lombok.RequiredArgsConstructor; @@ -25,179 +24,55 @@ public class RoomNotificationDispatchService implements RoomNotificationDispatch private final FirebaseMessagingPort firebasePort; @Override - public void handleRoomPostCommented(final RoomEvents.RoomPostCommentedEvent event) { - Notification notification = buildNotification(event.title(), event.content()); - - List tokens = fcmTokenQueryPort.findEnabledByUserId(event.targetUserId()); - if (tokens.isEmpty()) return; - - List msgs = new ArrayList<>(tokens.size()); - List tk = new ArrayList<>(tokens.size()); - List dev = new ArrayList<>(tokens.size()); - - for (FcmToken t : tokens) { - Message m = buildMessage(t.getFcmToken(), notification, - MessageRoute.ROOM_POST_DETAIL, - "roomId", String.valueOf(event.roomId()), - "page", String.valueOf(event.page()), - "type", "group", - "postId", String.valueOf(event.postId()), - "postType", String.valueOf(event.postType())); - - msgs.add(m); tk.add(t.getFcmToken()); dev.add(t.getDeviceId()); - } - firebasePort.sendBatch(msgs, tk, dev); + public void handleRoomPostCommented(final RoomEvents.RoomPostCommentedEvent e) { + push(e.targetUserId(), e.title(), e.content(), e.notificationId()); } @Override - public void handleRoomVoteStarted(final RoomEvents.RoomVoteStartedEvent event) { - Notification notification = buildNotification(event.title(), event.content()); - - List tokens = fcmTokenQueryPort.findEnabledByUserId(event.targetUserId()); - if (tokens.isEmpty()) return; - - List msgs = new ArrayList<>(tokens.size()); - List tk = new ArrayList<>(tokens.size()); - List dev = new ArrayList<>(tokens.size()); - - for (FcmToken t : tokens) { - Message m = buildMessage(t.getFcmToken(), notification, - MessageRoute.ROOM_VOTE_DETAIL, - "roomId", String.valueOf(event.roomId()), - "page", String.valueOf(event.page()), - "type", "group", - "postId", String.valueOf(event.postId()), - "postType", "VOTE"); - - msgs.add(m); tk.add(t.getFcmToken()); dev.add(t.getDeviceId()); - } - firebasePort.sendBatch(msgs, tk, dev); + public void handleRoomVoteStarted(final RoomEvents.RoomVoteStartedEvent e) { + push(e.targetUserId(), e.title(), e.content(), e.notificationId()); } @Override - public void handleRoomRecordCreated(final RoomEvents.RoomRecordCreatedEvent event) { - Notification notification = buildNotification(event.title(), event.content()); - - List tokens = fcmTokenQueryPort.findEnabledByUserId(event.targetUserId()); - if (tokens.isEmpty()) return; - - List msgs = new ArrayList<>(tokens.size()); - List tk = new ArrayList<>(tokens.size()); - List dev = new ArrayList<>(tokens.size()); - - for (FcmToken t : tokens) { - Message m = buildMessage(t.getFcmToken(), notification, - MessageRoute.ROOM_RECORD_DETAIL, - "roomId", String.valueOf(event.roomId()), - "page", String.valueOf(event.page()), - "type", "group", - "postId", String.valueOf(event.postId()), - "postType", "RECORD"); - - msgs.add(m); tk.add(t.getFcmToken()); dev.add(t.getDeviceId()); - } - firebasePort.sendBatch(msgs, tk, dev); + public void handleRoomRecordCreated(final RoomEvents.RoomRecordCreatedEvent e) { + push(e.targetUserId(), e.title(), e.content(), e.notificationId()); } @Override - public void handleRoomRecruitClosedEarly(final RoomEvents.RoomRecruitClosedEarlyEvent event) { - Notification notification = buildNotification(event.title(), event.content()); - - pushRoomMain(event.targetUserId(), event.roomId(), notification); + public void handleRoomRecruitClosedEarly(final RoomEvents.RoomRecruitClosedEarlyEvent e) { + push(e.targetUserId(), e.title(), e.content(), e.notificationId()); } @Override - public void handleRoomActivityStarted(final RoomEvents.RoomActivityStartedEvent event) { - Notification notification = buildNotification(event.title(), event.content()); - - pushRoomMain(event.targetUserId(), event.roomId(), notification); + public void handleRoomActivityStarted(final RoomEvents.RoomActivityStartedEvent e) { + push(e.targetUserId(), e.title(), e.content(), e.notificationId()); } @Override - public void handleRoomJoinRequestedToOwner(final RoomEvents.RoomJoinRequestedToOwnerEvent event) { - Notification notification = buildNotification(event.title(), event.content()); - - pushRoomDetail(event.ownerUserId(), event.roomId(), notification); + public void handleRoomJoinRequestedToOwner(final RoomEvents.RoomJoinRequestedToOwnerEvent e) { + push(e.targetUserId(), e.title(), e.content(), e.notificationId()); } @Override - public void handleRoomCommentLiked(final RoomEvents.RoomCommentLikedEvent event) { - Notification notification = buildNotification(event.title(), event.content()); - - List tokens = fcmTokenQueryPort.findEnabledByUserId(event.targetUserId()); - if (tokens.isEmpty()) return; - - List msgs = new ArrayList<>(tokens.size()); - List tk = new ArrayList<>(tokens.size()); - List dev = new ArrayList<>(tokens.size()); - - for (FcmToken t : tokens) { - Message m = buildMessage(t.getFcmToken(), notification, - MessageRoute.ROOM_POST_DETAIL, - "roomId", String.valueOf(event.roomId()), - "page", String.valueOf(event.page()), - "type", "group", - "postId", String.valueOf(event.postId()), - "postType", String.valueOf(event.postType())); - msgs.add(m); tk.add(t.getFcmToken()); dev.add(t.getDeviceId()); - } - firebasePort.sendBatch(msgs, tk, dev); + public void handleRoomCommentLiked(final RoomEvents.RoomCommentLikedEvent e) { + push(e.targetUserId(), e.title(), e.content(), e.notificationId()); } @Override - public void handleRoomPostLiked(final RoomEvents.RoomPostLikedEvent event) { - Notification notification = buildNotification(event.title(), event.content()); - - List tokens = fcmTokenQueryPort.findEnabledByUserId(event.targetUserId()); - if (tokens.isEmpty()) return; - - List msgs = new ArrayList<>(tokens.size()); - List tk = new ArrayList<>(tokens.size()); - List dev = new ArrayList<>(tokens.size()); - - for (FcmToken t : tokens) { - Message m = buildMessage(t.getFcmToken(), notification, - MessageRoute.ROOM_POST_DETAIL, - "roomId", String.valueOf(event.roomId()), - "page", String.valueOf(event.page()), - "type", "group", - "postId", String.valueOf(event.postId()), - "postType", String.valueOf(event.postType())); - msgs.add(m); tk.add(t.getFcmToken()); dev.add(t.getDeviceId()); - } - firebasePort.sendBatch(msgs, tk, dev); + public void handleRoomPostLiked(final RoomEvents.RoomPostLikedEvent e) { + push(e.targetUserId(), e.title(), e.content(), e.notificationId()); } @Override - public void handleRoomPostCommentReplied(RoomEvents.RoomPostCommentRepliedEvent event) { - Notification notification = buildNotification(event.title(), event.content()); - - List tokens = fcmTokenQueryPort.findEnabledByUserId(event.targetUserId()); - if (tokens.isEmpty()) return; - - List msgs = new ArrayList<>(tokens.size()); - List tk = new ArrayList<>(tokens.size()); - List dev = new ArrayList<>(tokens.size()); - - for (FcmToken t : tokens) { - Message m = buildMessage(t.getFcmToken(), notification, - MessageRoute.ROOM_POST_DETAIL, - "roomId", String.valueOf(event.roomId()), - "page", String.valueOf(event.page()), - "type", "group", - "postId", String.valueOf(event.postId()), - "postType", String.valueOf(event.postType())); - - msgs.add(m); tk.add(t.getFcmToken()); dev.add(t.getDeviceId()); - } - firebasePort.sendBatch(msgs, tk, dev); + public void handleRoomPostCommentReplied(final RoomEvents.RoomPostCommentRepliedEvent e) { + push(e.targetUserId(), e.title(), e.content(), e.notificationId()); } // ===== helpers ===== + private void push(Long userId, String title, String content, Long notificationId) { + Notification notification = buildNotification(title, content); - private void pushRoomMain(Long targetUserId, Long roomId, Notification notification) { - List tokens = fcmTokenQueryPort.findEnabledByUserId(targetUserId); - + List tokens = fcmTokenQueryPort.findEnabledByUserId(userId); if (tokens.isEmpty()) return; List msgs = new ArrayList<>(tokens.size()); @@ -205,48 +80,23 @@ private void pushRoomMain(Long targetUserId, Long roomId, Notification notificat List dev = new ArrayList<>(tokens.size()); for (FcmToken t : tokens) { - Message m = buildMessage(t.getFcmToken(), notification, - MessageRoute.ROOM_MAIN, - "roomId", String.valueOf(roomId)); - + Message m = Message.builder() + .setToken(t.getFcmToken()) + .setNotification(notification) + .putData("category", NotificationCategory.ROOM.getDisplay()) + .putData("action", "OPEN_NOTIFICATION") // FE는 이 액션으로 알림 상세/라우팅을 BE api 요청으로 처리 + .putData("notificationId", String.valueOf(notificationId)) + .build(); msgs.add(m); tk.add(t.getFcmToken()); dev.add(t.getDeviceId()); } - firebasePort.sendBatch(msgs, tk, dev); - } - - private void pushRoomDetail(Long targetUserId, Long roomId, Notification notification) { - List tokens = fcmTokenQueryPort.findEnabledByUserId(targetUserId); - - if (tokens.isEmpty()) return; - - List msgs = new ArrayList<>(tokens.size()); - List tk = new ArrayList<>(tokens.size()); - List dev = new ArrayList<>(tokens.size()); - for (FcmToken t : tokens) { - Message m = buildMessage(t.getFcmToken(), notification, - MessageRoute.ROOM_DETAIL, - "roomId", String.valueOf(roomId)); - - msgs.add(m); tk.add(t.getFcmToken()); dev.add(t.getDeviceId()); - } firebasePort.sendBatch(msgs, tk, dev); } private Notification buildNotification(final String title, final String body) { - return Notification.builder().setTitle(title).setBody(body).build(); - } - - private Message buildMessage(final String token, final Notification n, - final MessageRoute route, - final String... kv) { - var b = Message.builder() - .setToken(token) - .setNotification(n) - .putData("category", NotificationCategory.ROOM.getDisplay()) - .putData("action", "OPEN_ROUTE") - .putData("route", route.getCode()); - for (int i = 0; i + 1 < kv.length; i += 2) b.putData(kv[i], kv[i + 1]); - return b.build(); + return Notification.builder() + .setTitle(title) + .setBody(body) + .build(); } -} \ No newline at end of file +} diff --git a/src/main/java/konkuk/thip/message/domain/MessageRoute.java b/src/main/java/konkuk/thip/message/domain/MessageRoute.java deleted file mode 100644 index d664ecc8f..000000000 --- a/src/main/java/konkuk/thip/message/domain/MessageRoute.java +++ /dev/null @@ -1,21 +0,0 @@ -package konkuk.thip.message.domain; - -import lombok.Getter; -import lombok.RequiredArgsConstructor; - -@Getter -@RequiredArgsConstructor -public enum MessageRoute { - // FEED - FEED_USER("FEED_USER"), // 자신을 팔로우한 사용자의 피드 목록으로 화면 이동 - FEED_DETAIL("FEED_DETAIL"), // 특정 피드 상세 화면으로 이동 - - // ROOM - ROOM_MAIN("ROOM_MAIN"), // 특정 모임방 메인 화면으로 이동 - ROOM_DETAIL("ROOM_DETAIL"), // 특정 모임 상세정보 화면으로 이동 - ROOM_POST_DETAIL("ROOM_POST_DETAIL"), // 특정 모임 게시글 상세 화면으로 이동 -> PostType으로 투표인지 기록인지 판단 - ROOM_RECORD_DETAIL("ROOM_RECORD_DETAIL"), // 특정 모임 기록 상세 화면으로 이동 (기록장 조회 - 페이지 필터 걸린채로) - ROOM_VOTE_DETAIL("ROOM_VOTE_DETAIL"); // 특정 모임 투표 상세 화면으로 이동 (투표 조회 - 페이지 필터 걸린채로) - - private final String code; -} \ No newline at end of file diff --git a/src/main/java/konkuk/thip/notification/adapter/in/web/NotificationCommandController.java b/src/main/java/konkuk/thip/notification/adapter/in/web/NotificationCommandController.java index f9f572237..a2a7ae231 100644 --- a/src/main/java/konkuk/thip/notification/adapter/in/web/NotificationCommandController.java +++ b/src/main/java/konkuk/thip/notification/adapter/in/web/NotificationCommandController.java @@ -10,7 +10,10 @@ import konkuk.thip.notification.adapter.in.web.request.FcmTokenDeleteRequest; import konkuk.thip.notification.adapter.in.web.request.FcmTokenEnableStateChangeRequest; import konkuk.thip.notification.adapter.in.web.request.FcmTokenRegisterRequest; +import konkuk.thip.notification.adapter.in.web.request.NotificationMarkToCheckedRequest; import konkuk.thip.notification.adapter.in.web.response.FcmTokenEnableStateChangeResponse; +import konkuk.thip.notification.adapter.in.web.response.NotificationMarkToCheckedResponse; +import konkuk.thip.notification.application.port.in.NotificationMarkUseCase; import konkuk.thip.notification.application.port.in.fcm.FcmDeleteUseCase; import konkuk.thip.notification.application.port.in.fcm.FcmEnableStateChangeUseCase; import konkuk.thip.notification.application.port.in.fcm.FcmRegisterUseCase; @@ -27,6 +30,7 @@ public class NotificationCommandController { private final FcmRegisterUseCase fcmRegisterUseCase; private final FcmEnableStateChangeUseCase fcmEnableStateChangeUseCase; private final FcmDeleteUseCase fcmDeleteUseCase; + private final NotificationMarkUseCase notificationMarkUseCase; @Operation(summary = "FCM 토큰 등록", description = "사용자의 FCM 토큰을 서버에 등록합니다. 기존 토큰이 있다면 deviceId 기준으로 토큰을 갱신합니다.") @PostMapping("/notifications/fcm-tokens") @@ -60,4 +64,16 @@ public BaseResponse deleteFcmToken( fcmDeleteUseCase.deleteToken(request.toCommand(userId)); return BaseResponse.ok(null); } + + @Operation( + summary = "유저의 특정 알림 읽음 처리", + description = "유저가 특정 알림을 읽음 처리합니다 (푸시알림, 알림센터의 알림 모두 포함). 읽음 처리 후, 해당 알림의 페이지로 리다이렉트를 위한 데이터를 응답합니다." + ) + @ExceptionDescription(NOTIFICATION_MARK_TO_CHECKED) + @PostMapping("/notifications/check") + public BaseResponse markNotificationToChecked( + @RequestBody @Valid NotificationMarkToCheckedRequest request, + @Parameter(hidden = true) @UserId final Long userId) { + return BaseResponse.ok(notificationMarkUseCase.markToChecked(request.notificationId(), userId)); + } } diff --git a/src/main/java/konkuk/thip/notification/adapter/in/web/request/FcmTokenDeleteRequest.java b/src/main/java/konkuk/thip/notification/adapter/in/web/request/FcmTokenDeleteRequest.java index a18419a16..f300b1e9f 100644 --- a/src/main/java/konkuk/thip/notification/adapter/in/web/request/FcmTokenDeleteRequest.java +++ b/src/main/java/konkuk/thip/notification/adapter/in/web/request/FcmTokenDeleteRequest.java @@ -6,7 +6,7 @@ @Schema(description = "푸시 알림 설정 삭제 요청 DTO") public record FcmTokenDeleteRequest( - @NotBlank + @NotBlank(message = "디바이스 ID는 필수입니다.") @Schema(description = "디바이스 고유 ID", example = "device12345") String deviceId ) { diff --git a/src/main/java/konkuk/thip/notification/adapter/in/web/request/FcmTokenEnableStateChangeRequest.java b/src/main/java/konkuk/thip/notification/adapter/in/web/request/FcmTokenEnableStateChangeRequest.java index 767032e35..975179820 100644 --- a/src/main/java/konkuk/thip/notification/adapter/in/web/request/FcmTokenEnableStateChangeRequest.java +++ b/src/main/java/konkuk/thip/notification/adapter/in/web/request/FcmTokenEnableStateChangeRequest.java @@ -7,11 +7,11 @@ @Schema(description = "푸시 알림 설정 변경 요청 DTO") public record FcmTokenEnableStateChangeRequest( - @NotNull + @NotNull(message = "푸시 알림 수신 여부는 필수입니다.") @Schema(description = "푸시 알림 수신 여부", example = "true") Boolean enable, - @NotBlank + @NotBlank(message = "디바이스 ID는 필수입니다.") @Schema(description = "디바이스 고유 ID", example = "device12345") String deviceId ) { diff --git a/src/main/java/konkuk/thip/notification/adapter/in/web/request/FcmTokenRegisterRequest.java b/src/main/java/konkuk/thip/notification/adapter/in/web/request/FcmTokenRegisterRequest.java index 69b516421..c59ee0f56 100644 --- a/src/main/java/konkuk/thip/notification/adapter/in/web/request/FcmTokenRegisterRequest.java +++ b/src/main/java/konkuk/thip/notification/adapter/in/web/request/FcmTokenRegisterRequest.java @@ -2,29 +2,28 @@ import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; -import jakarta.validation.constraints.NotNull; -import konkuk.thip.notification.domain.value.PlatformType; import konkuk.thip.notification.application.port.in.dto.FcmTokenRegisterCommand; +import konkuk.thip.notification.domain.value.PlatformType; @Schema(description = "FCM 토큰 등록 요청 DTO") public record FcmTokenRegisterRequest( - @NotBlank + @NotBlank(message = "디바이스 ID는 필수입니다.") @Schema(description = "디바이스 고유 ID", example = "device12345") String deviceId, - @NotBlank + @NotBlank(message = "FCM 토큰은 필수입니다.") @Schema(description = "FCM 토큰", example = "fcm_token_example_123456") String fcmToken, - @NotNull + @NotBlank(message = "플랫폼 타입은 필수입니다.") @Schema(description = "플랫폼 타입 (ANDROID 또는 WEB)", example = "ANDROID") - PlatformType platformType + String platformType ) { public FcmTokenRegisterCommand toCommand(Long userId) { return FcmTokenRegisterCommand.builder() .deviceId(this.deviceId) .fcmToken(this.fcmToken) - .platformType(this.platformType) + .platformType(PlatformType.from(this.platformType)) .userId(userId) .build(); } diff --git a/src/main/java/konkuk/thip/notification/adapter/in/web/request/NotificationMarkToCheckedRequest.java b/src/main/java/konkuk/thip/notification/adapter/in/web/request/NotificationMarkToCheckedRequest.java new file mode 100644 index 000000000..8b7ad1fe2 --- /dev/null +++ b/src/main/java/konkuk/thip/notification/adapter/in/web/request/NotificationMarkToCheckedRequest.java @@ -0,0 +1,12 @@ +package konkuk.thip.notification.adapter.in.web.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; + +@Schema(description = "알림 읽음 처리 요청 DTO") +public record NotificationMarkToCheckedRequest( + + @NotNull + @Schema(description = "읽음 처리할 알림 ID", example = "1") + Long notificationId +) { } diff --git a/src/main/java/konkuk/thip/notification/adapter/in/web/response/NotificationMarkToCheckedResponse.java b/src/main/java/konkuk/thip/notification/adapter/in/web/response/NotificationMarkToCheckedResponse.java new file mode 100644 index 000000000..571f4926e --- /dev/null +++ b/src/main/java/konkuk/thip/notification/adapter/in/web/response/NotificationMarkToCheckedResponse.java @@ -0,0 +1,16 @@ +package konkuk.thip.notification.adapter.in.web.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import konkuk.thip.notification.domain.value.MessageRoute; + +import java.util.Map; + +@Schema(description = "알림 읽음 처리 응답 DTO") +public record NotificationMarkToCheckedResponse( + @Schema(description = "'알림 리다이렉트 목적지' 에 해당하는 enum 값 입니다.", example = "POST_DETAIL -> 게시글 상세 페이지로 이동한다는 의미") + MessageRoute route, + + @Schema(description = "'알림 리다이렉트 목적지' 로 이동할 때 필요한 파라미터들 입니다.", example = "{\"postId\": 123}") + Map params +) { +} diff --git a/src/main/java/konkuk/thip/notification/adapter/out/jpa/NotificationJpaEntity.java b/src/main/java/konkuk/thip/notification/adapter/out/jpa/NotificationJpaEntity.java index 3e8a1f549..d46ca8c08 100644 --- a/src/main/java/konkuk/thip/notification/adapter/out/jpa/NotificationJpaEntity.java +++ b/src/main/java/konkuk/thip/notification/adapter/out/jpa/NotificationJpaEntity.java @@ -2,7 +2,10 @@ import jakarta.persistence.*; import konkuk.thip.common.entity.BaseJpaEntity; +import konkuk.thip.notification.domain.Notification; import konkuk.thip.notification.domain.value.NotificationCategory; +import konkuk.thip.notification.domain.value.NotificationRedirectSpecConverter; +import konkuk.thip.notification.domain.value.NotificationRedirectSpec; import konkuk.thip.user.adapter.out.jpa.UserJpaEntity; import lombok.*; @@ -34,4 +37,12 @@ public class NotificationJpaEntity extends BaseJpaEntity { @ManyToOne(fetch = FetchType.LAZY, optional = false) @JoinColumn(name = "user_id", nullable = false) private UserJpaEntity userJpaEntity; -} \ No newline at end of file + + @Convert(converter = NotificationRedirectSpecConverter.class) + @Column(name = "redirect_spec", columnDefinition = "TEXT") // nullable + private NotificationRedirectSpec redirectSpec; + + public void updateFrom(Notification notification) { + this.isChecked = notification.isChecked(); // 현재는 isChecked만 업데이트 가능 + } +} diff --git a/src/main/java/konkuk/thip/notification/adapter/out/mapper/NotificationMapper.java b/src/main/java/konkuk/thip/notification/adapter/out/mapper/NotificationMapper.java index dd65e5db3..69da81947 100644 --- a/src/main/java/konkuk/thip/notification/adapter/out/mapper/NotificationMapper.java +++ b/src/main/java/konkuk/thip/notification/adapter/out/mapper/NotificationMapper.java @@ -15,6 +15,7 @@ public NotificationJpaEntity toJpaEntity(Notification notification, UserJpaEntit .isChecked(notification.isChecked()) .notificationCategory(notification.getNotificationCategory()) .userJpaEntity(userJpaEntity) + .redirectSpec(notification.getRedirectSpec()) .build(); } @@ -26,6 +27,7 @@ public Notification toDomainEntity(NotificationJpaEntity notificationJpaEntity) .isChecked(notificationJpaEntity.isChecked()) .notificationCategory(notificationJpaEntity.getNotificationCategory()) .targetUserId(notificationJpaEntity.getUserJpaEntity().getUserId()) + .redirectSpec(notificationJpaEntity.getRedirectSpec()) .createdAt(notificationJpaEntity.getCreatedAt()) .modifiedAt(notificationJpaEntity.getModifiedAt()) .status(notificationJpaEntity.getStatus()) diff --git a/src/main/java/konkuk/thip/notification/adapter/out/persistence/NotificationCommandPersistenceAdapter.java b/src/main/java/konkuk/thip/notification/adapter/out/persistence/NotificationCommandPersistenceAdapter.java index 0227e28d9..5099912cd 100644 --- a/src/main/java/konkuk/thip/notification/adapter/out/persistence/NotificationCommandPersistenceAdapter.java +++ b/src/main/java/konkuk/thip/notification/adapter/out/persistence/NotificationCommandPersistenceAdapter.java @@ -2,6 +2,7 @@ import konkuk.thip.common.exception.EntityNotFoundException; import konkuk.thip.common.exception.code.ErrorCode; +import konkuk.thip.notification.adapter.out.jpa.NotificationJpaEntity; import konkuk.thip.notification.adapter.out.mapper.NotificationMapper; import konkuk.thip.notification.adapter.out.persistence.repository.NotificationJpaRepository; import konkuk.thip.notification.application.port.out.NotificationCommandPort; @@ -11,6 +12,8 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Repository; +import java.util.Optional; + @Repository @RequiredArgsConstructor public class NotificationCommandPersistenceAdapter implements NotificationCommandPort { @@ -21,11 +24,26 @@ public class NotificationCommandPersistenceAdapter implements NotificationComman private final NotificationMapper notificationMapper; @Override - public void save(Notification notification) { + public Long save(Notification notification) { UserJpaEntity userJpaEntity = userJpaRepository.findByUserId(notification.getTargetUserId()).orElseThrow( () -> new EntityNotFoundException(ErrorCode.USER_NOT_FOUND) ); - notificationJpaRepository.save(notificationMapper.toJpaEntity(notification, userJpaEntity)); + return notificationJpaRepository.save(notificationMapper.toJpaEntity(notification, userJpaEntity)).getNotificationId(); + } + + @Override + public Optional findById(Long id) { + return notificationJpaRepository.findById(id) + .map(notificationMapper::toDomainEntity); + } + + @Override + public void update(Notification notification) { + NotificationJpaEntity notificationJpaEntity = notificationJpaRepository.findById(notification.getId()).orElseThrow( + () -> new EntityNotFoundException(ErrorCode.NOTIFICATION_NOT_FOUND) + ); + + notificationJpaEntity.updateFrom(notification); } } diff --git a/src/main/java/konkuk/thip/notification/application/port/in/FeedNotificationOrchestrator.java b/src/main/java/konkuk/thip/notification/application/port/in/FeedNotificationOrchestrator.java index f58a8b845..222dfc34f 100644 --- a/src/main/java/konkuk/thip/notification/application/port/in/FeedNotificationOrchestrator.java +++ b/src/main/java/konkuk/thip/notification/application/port/in/FeedNotificationOrchestrator.java @@ -14,7 +14,7 @@ public interface FeedNotificationOrchestrator { void notifyFeedReplied(Long targetUserId, Long actorUserId, String actorUsername, Long feedId); - void notifyFolloweeNewPost(Long targetUserId, Long actorUserId, String actorUsername, Long feedId); + void notifyFolloweeNewFeed(Long targetUserId, Long actorUserId, String actorUsername, Long feedId); void notifyFeedLiked(Long targetUserId, Long actorUserId, String actorUsername, Long feedId); diff --git a/src/main/java/konkuk/thip/notification/application/port/in/NotificationMarkUseCase.java b/src/main/java/konkuk/thip/notification/application/port/in/NotificationMarkUseCase.java new file mode 100644 index 000000000..62abd35d1 --- /dev/null +++ b/src/main/java/konkuk/thip/notification/application/port/in/NotificationMarkUseCase.java @@ -0,0 +1,8 @@ +package konkuk.thip.notification.application.port.in; + +import konkuk.thip.notification.adapter.in.web.response.NotificationMarkToCheckedResponse; + +public interface NotificationMarkUseCase { + + NotificationMarkToCheckedResponse markToChecked(Long notificationId, Long userId); +} diff --git a/src/main/java/konkuk/thip/notification/application/port/out/NotificationCommandPort.java b/src/main/java/konkuk/thip/notification/application/port/out/NotificationCommandPort.java index e9a122db3..f256b3329 100644 --- a/src/main/java/konkuk/thip/notification/application/port/out/NotificationCommandPort.java +++ b/src/main/java/konkuk/thip/notification/application/port/out/NotificationCommandPort.java @@ -1,9 +1,22 @@ package konkuk.thip.notification.application.port.out; - +import konkuk.thip.common.exception.EntityNotFoundException; import konkuk.thip.notification.domain.Notification; +import java.util.Optional; + +import static konkuk.thip.common.exception.code.ErrorCode.NOTIFICATION_NOT_FOUND; + public interface NotificationCommandPort { - void save(Notification notification); + Long save(Notification notification); + + Optional findById(Long id); + + default Notification getByIdOrThrow(Long id) { + return findById(id) + .orElseThrow(() -> new EntityNotFoundException(NOTIFICATION_NOT_FOUND)); + } + + void update(Notification notification); } diff --git a/src/main/java/konkuk/thip/notification/application/service/EventCommandInvoker.java b/src/main/java/konkuk/thip/notification/application/service/EventCommandInvoker.java index c104814a3..07f1b3603 100644 --- a/src/main/java/konkuk/thip/notification/application/service/EventCommandInvoker.java +++ b/src/main/java/konkuk/thip/notification/application/service/EventCommandInvoker.java @@ -3,5 +3,5 @@ @FunctionalInterface public interface EventCommandInvoker { - void publish(String title, String content); + void publish(String title, String content, Long notificationId); } diff --git a/src/main/java/konkuk/thip/notification/application/service/FeedNotificationOrchestratorSyncImpl.java b/src/main/java/konkuk/thip/notification/application/service/FeedNotificationOrchestratorSyncImpl.java index 1e3db8503..561340273 100644 --- a/src/main/java/konkuk/thip/notification/application/service/FeedNotificationOrchestratorSyncImpl.java +++ b/src/main/java/konkuk/thip/notification/application/service/FeedNotificationOrchestratorSyncImpl.java @@ -4,10 +4,14 @@ import konkuk.thip.message.application.port.out.FeedEventCommandPort; import konkuk.thip.notification.application.port.in.FeedNotificationOrchestrator; import konkuk.thip.notification.application.service.template.feed.*; +import konkuk.thip.notification.domain.value.MessageRoute; +import konkuk.thip.notification.domain.value.NotificationRedirectSpec; import lombok.RequiredArgsConstructor; import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; +import java.util.Map; + @HelperService @RequiredArgsConstructor public class FeedNotificationOrchestratorSyncImpl implements FeedNotificationOrchestrator { @@ -27,12 +31,19 @@ public class FeedNotificationOrchestratorSyncImpl implements FeedNotificationOrc @Transactional(propagation = Propagation.MANDATORY) public void notifyFollowed(Long targetUserId, Long actorUserId, String actorUsername) { var args = new FollowedTemplate.Args(actorUsername); + + NotificationRedirectSpec redirectSpec = new NotificationRedirectSpec( + MessageRoute.FEED_USER, + Map.of("userId", actorUserId) + ); + notificationSyncExecutor.execute( FollowedTemplate.INSTANCE, args, targetUserId, - (title, content) -> feedEventCommandPort.publishFollowEvent( - title, content, targetUserId, actorUserId, actorUsername + redirectSpec, + (title, content, notificationId) -> feedEventCommandPort.publishFollowEvent( + title, content, notificationId, targetUserId ) ); } @@ -41,12 +52,19 @@ public void notifyFollowed(Long targetUserId, Long actorUserId, String actorUser @Transactional(propagation = Propagation.MANDATORY) public void notifyFeedCommented(Long targetUserId, Long actorUserId, String actorUsername, Long feedId) { var args = new FeedCommentedTemplate.Args(actorUsername); + + NotificationRedirectSpec redirectSpec = new NotificationRedirectSpec( + MessageRoute.FEED_DETAIL, + Map.of("feedId", feedId) + ); + notificationSyncExecutor.execute( FeedCommentedTemplate.INSTANCE, args, targetUserId, - (title, content) -> feedEventCommandPort.publishFeedCommentedEvent( - title, content, targetUserId, actorUserId, actorUsername, feedId + redirectSpec, + (title, content, notificationId) -> feedEventCommandPort.publishFeedCommentedEvent( + title, content, notificationId, targetUserId ) ); } @@ -55,26 +73,40 @@ public void notifyFeedCommented(Long targetUserId, Long actorUserId, String acto @Transactional(propagation = Propagation.MANDATORY) public void notifyFeedReplied(Long targetUserId, Long actorUserId, String actorUsername, Long feedId) { var args = new FeedRepliedTemplate.Args(actorUsername); + + NotificationRedirectSpec redirectSpec = new NotificationRedirectSpec( + MessageRoute.FEED_DETAIL, + Map.of("feedId", feedId) + ); + notificationSyncExecutor.execute( FeedRepliedTemplate.INSTANCE, args, targetUserId, - (title, content) -> feedEventCommandPort.publishFeedRepliedEvent( - title, content, targetUserId, actorUserId, actorUsername, feedId + redirectSpec, + (title, content, notificationId) -> feedEventCommandPort.publishFeedRepliedEvent( + title, content, notificationId, targetUserId ) ); } @Override @Transactional(propagation = Propagation.MANDATORY) - public void notifyFolloweeNewPost(Long targetUserId, Long actorUserId, String actorUsername, Long feedId) { - var args = new FolloweeNewPostTemplate.Args(actorUsername); + public void notifyFolloweeNewFeed(Long targetUserId, Long actorUserId, String actorUsername, Long feedId) { + var args = new FolloweeNewFeedTemplate.Args(actorUsername); + + NotificationRedirectSpec redirectSpec = new NotificationRedirectSpec( + MessageRoute.FEED_DETAIL, + Map.of("feedId", feedId) + ); + notificationSyncExecutor.execute( - FolloweeNewPostTemplate.INSTANCE, + FolloweeNewFeedTemplate.INSTANCE, args, targetUserId, - (title, content) -> feedEventCommandPort.publishFolloweeNewPostEvent( - title, content, targetUserId, actorUserId, actorUsername, feedId + redirectSpec, + (title, content, notificationId) -> feedEventCommandPort.publishFolloweeNewFeedEvent( + title, content, notificationId, targetUserId ) ); } @@ -83,12 +115,19 @@ public void notifyFolloweeNewPost(Long targetUserId, Long actorUserId, String ac @Transactional(propagation = Propagation.MANDATORY) public void notifyFeedLiked(Long targetUserId, Long actorUserId, String actorUsername, Long feedId) { var args = new FeedLikedTemplate.Args(actorUsername); + + NotificationRedirectSpec redirectSpec = new NotificationRedirectSpec( + MessageRoute.FEED_DETAIL, + Map.of("feedId", feedId) + ); + notificationSyncExecutor.execute( FeedLikedTemplate.INSTANCE, args, targetUserId, - (title, content) -> feedEventCommandPort.publishFeedLikedEvent( - title, content, targetUserId, actorUserId, actorUsername, feedId + redirectSpec, + (title, content, notificationId) -> feedEventCommandPort.publishFeedLikedEvent( + title, content, notificationId, targetUserId ) ); } @@ -97,12 +136,19 @@ public void notifyFeedLiked(Long targetUserId, Long actorUserId, String actorUse @Transactional(propagation = Propagation.MANDATORY) public void notifyFeedCommentLiked(Long targetUserId, Long actorUserId, String actorUsername, Long feedId) { var args = new FeedCommentLikedTemplate.Args(actorUsername); + + NotificationRedirectSpec redirectSpec = new NotificationRedirectSpec( + MessageRoute.FEED_DETAIL, + Map.of("feedId", feedId) + ); + notificationSyncExecutor.execute( FeedCommentLikedTemplate.INSTANCE, args, targetUserId, - (title, content) -> feedEventCommandPort.publishFeedCommentLikedEvent( - title, content, targetUserId, actorUserId, actorUsername, feedId + redirectSpec, + (title, content, notificationId) -> feedEventCommandPort.publishFeedCommentLikedEvent( + title, content, notificationId, targetUserId ) ); } diff --git a/src/main/java/konkuk/thip/notification/application/service/NotificationMarkService.java b/src/main/java/konkuk/thip/notification/application/service/NotificationMarkService.java new file mode 100644 index 000000000..e4d523861 --- /dev/null +++ b/src/main/java/konkuk/thip/notification/application/service/NotificationMarkService.java @@ -0,0 +1,34 @@ +package konkuk.thip.notification.application.service; + +import konkuk.thip.notification.adapter.in.web.response.NotificationMarkToCheckedResponse; +import konkuk.thip.notification.application.port.in.NotificationMarkUseCase; +import konkuk.thip.notification.application.port.out.NotificationCommandPort; +import konkuk.thip.notification.domain.Notification; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class NotificationMarkService implements NotificationMarkUseCase { + + private final NotificationCommandPort notificationCommandPort; + + @Override + @Transactional + public NotificationMarkToCheckedResponse markToChecked(Long notificationId, Long userId) { + // 1. 알림 존재 여부 확인 + Notification notification = notificationCommandPort.getByIdOrThrow(notificationId); + notification.validateOwner(userId); + + // 2. 알림 읽음 처리 + notification.markToChecked(); + notificationCommandPort.update(notification); + + // 3. 읽음 처리된 알림의 redirectSpec 반환 (for FE 알림 리다이렉트 동작) + return new NotificationMarkToCheckedResponse( + notification.getRedirectSpec().route(), + notification.getRedirectSpec().params() + ); + } +} diff --git a/src/main/java/konkuk/thip/notification/application/service/NotificationSyncExecutor.java b/src/main/java/konkuk/thip/notification/application/service/NotificationSyncExecutor.java index e5bf17417..61ea31ae2 100644 --- a/src/main/java/konkuk/thip/notification/application/service/NotificationSyncExecutor.java +++ b/src/main/java/konkuk/thip/notification/application/service/NotificationSyncExecutor.java @@ -5,6 +5,7 @@ import konkuk.thip.notification.application.service.template.NotificationTemplate; import konkuk.thip.notification.domain.Notification; import konkuk.thip.notification.domain.value.NotificationCategory; +import konkuk.thip.notification.domain.value.NotificationRedirectSpec; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -15,32 +16,45 @@ public class NotificationSyncExecutor { private final NotificationCommandPort notificationCommandPort; + /** + * NotificationRedirectSpec 미지정 시 + */ public void execute( NotificationTemplate template, T args, Long targetUserId, EventCommandInvoker invoker + ) { + execute(template, args, targetUserId, NotificationRedirectSpec.none(), invoker); + } + + public void execute( + NotificationTemplate template, + T args, + Long targetUserId, + NotificationRedirectSpec notificationRedirectSpec, + EventCommandInvoker invoker ) { String title = template.title(args); String content = template.content(args); NotificationCategory notificationCategory = template.notificationCategory(args); // 1. DB 저장 - saveNotification(title, content, notificationCategory, targetUserId); + Long notificationId = saveNotification(title, content, notificationCategory, targetUserId, notificationRedirectSpec); // 2. 이벤트 퍼블리시 try { - invoker.publish(title, content); + invoker.publish(title, content, notificationId); } catch (Exception e) { // 이벤트 발행 실패 시, DB에 저장된 알림을 롤백하지는 않음 // -> 알림 저장은 비즈니스 트랜잭션과 동일한 경계 내에서 수행되므로, 알림 저장은 유지 // -> 푸시 알림 이벤트 발행이 실패한 경우, 일단 로깅만 추가 - log.error("푸시 알림 이벤트 퍼블리시 실패 targetUserId = {}, title = {}", targetUserId, title, e); + log.error("푸시 알림 이벤트 퍼블리시 실패 targetUserId = {}, title = {}, notificationId = {}", targetUserId, title, notificationId, e); } } - private void saveNotification(String title, String content, NotificationCategory category, Long targetUserId) { - Notification notification = Notification.withoutId(title, content, category, targetUserId); - notificationCommandPort.save(notification); + private Long saveNotification(String title, String content, NotificationCategory category, Long targetUserId, NotificationRedirectSpec redirectSpec) { + Notification notification = Notification.withoutId(title, content, category, targetUserId, redirectSpec); + return notificationCommandPort.save(notification); } } diff --git a/src/main/java/konkuk/thip/notification/application/service/RoomNotificationOrchestratorSyncImpl.java b/src/main/java/konkuk/thip/notification/application/service/RoomNotificationOrchestratorSyncImpl.java index 9162c65d8..6792f5ba6 100644 --- a/src/main/java/konkuk/thip/notification/application/service/RoomNotificationOrchestratorSyncImpl.java +++ b/src/main/java/konkuk/thip/notification/application/service/RoomNotificationOrchestratorSyncImpl.java @@ -4,10 +4,14 @@ import konkuk.thip.message.application.port.out.RoomEventCommandPort; import konkuk.thip.notification.application.port.in.RoomNotificationOrchestrator; import konkuk.thip.notification.application.service.template.room.*; +import konkuk.thip.notification.domain.value.MessageRoute; +import konkuk.thip.notification.domain.value.NotificationRedirectSpec; import lombok.RequiredArgsConstructor; import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; +import java.util.Map; + @HelperService @RequiredArgsConstructor public class RoomNotificationOrchestratorSyncImpl implements RoomNotificationOrchestrator { @@ -28,12 +32,24 @@ public class RoomNotificationOrchestratorSyncImpl implements RoomNotificationOrc public void notifyRoomPostCommented(Long targetUserId, Long actorUserId, String actorUsername, Long roomId, Integer page, Long postId, String postType) { var args = new RoomPostCommentedTemplate.Args(actorUsername); + + NotificationRedirectSpec redirectSpec = new NotificationRedirectSpec( + MessageRoute.ROOM_POST_DETAIL, + Map.of( + "roomId", roomId, + "page", page, + "postId", postId, + "postType", postType + ) + ); + notificationSyncExecutor.execute( RoomPostCommentedTemplate.INSTANCE, args, targetUserId, - (title, content) -> roomEventCommandPort.publishRoomPostCommentedEvent( - title, content, targetUserId, actorUserId, actorUsername, roomId, page, postId, postType + redirectSpec, + (title, content, notificationId) -> roomEventCommandPort.publishRoomPostCommentedEvent( + title, content, notificationId, targetUserId ) ); } @@ -42,12 +58,24 @@ public void notifyRoomPostCommented(Long targetUserId, Long actorUserId, String @Transactional(propagation = Propagation.MANDATORY) public void notifyRoomVoteStarted(Long targetUserId, Long roomId, String roomTitle, Integer page, Long postId) { var args = new RoomVoteStartedTemplate.Args(roomTitle); + + NotificationRedirectSpec redirectSpec = new NotificationRedirectSpec( + MessageRoute.ROOM_VOTE_DETAIL, + Map.of( + "roomId", roomId, + "page", page, + "postId", postId, + "postType", "VOTE" + ) + ); + notificationSyncExecutor.execute( RoomVoteStartedTemplate.INSTANCE, args, targetUserId, - (title, content) -> roomEventCommandPort.publishRoomVoteStartedEvent( - title, content, targetUserId, roomId, roomTitle, page, postId + redirectSpec, + (title, content, notificationId) -> roomEventCommandPort.publishRoomVoteStartedEvent( + title, content, notificationId, targetUserId ) ); } @@ -57,12 +85,24 @@ public void notifyRoomVoteStarted(Long targetUserId, Long roomId, String roomTit public void notifyRoomRecordCreated(Long targetUserId, Long actorUserId, String actorUsername, Long roomId, String roomTitle, Integer page, Long postId) { var args = new RoomRecordCreatedTemplate.Args(roomTitle, actorUsername); + + NotificationRedirectSpec redirectSpec = new NotificationRedirectSpec( + MessageRoute.ROOM_RECORD_DETAIL, + Map.of( + "roomId", roomId, + "page", page, + "postId", postId, + "postType", "RECORD" + ) + ); + notificationSyncExecutor.execute( RoomRecordCreatedTemplate.INSTANCE, args, targetUserId, - (title, content) -> roomEventCommandPort.publishRoomRecordCreatedEvent( - title, content, targetUserId, actorUserId, actorUsername, roomId, roomTitle, page, postId + redirectSpec, + (title, content, notificationId) -> roomEventCommandPort.publishRoomRecordCreatedEvent( + title, content, notificationId, targetUserId ) ); } @@ -71,12 +111,19 @@ public void notifyRoomRecordCreated(Long targetUserId, Long actorUserId, String @Transactional(propagation = Propagation.MANDATORY) public void notifyRoomRecruitClosedEarly(Long targetUserId, Long roomId, String roomTitle) { var args = new RoomRecruitClosedEarlyTemplate.Args(roomTitle); + + NotificationRedirectSpec redirectSpec = new NotificationRedirectSpec( + MessageRoute.ROOM_MAIN, + Map.of("roomId", roomId) + ); + notificationSyncExecutor.execute( RoomRecruitClosedEarlyTemplate.INSTANCE, args, targetUserId, - (title, content) -> roomEventCommandPort.publishRoomRecruitClosedEarlyEvent( - title, content, targetUserId, roomId, roomTitle + redirectSpec, + (title, content, notificationId) -> roomEventCommandPort.publishRoomRecruitClosedEarlyEvent( + title, content, notificationId, targetUserId ) ); } @@ -85,12 +132,19 @@ public void notifyRoomRecruitClosedEarly(Long targetUserId, Long roomId, String @Transactional(propagation = Propagation.MANDATORY) public void notifyRoomActivityStarted(Long targetUserId, Long roomId, String roomTitle) { var args = new RoomActivityStartedTemplate.Args(roomTitle); + + NotificationRedirectSpec redirectSpec = new NotificationRedirectSpec( + MessageRoute.ROOM_MAIN, + Map.of("roomId", roomId) + ); + notificationSyncExecutor.execute( RoomActivityStartedTemplate.INSTANCE, args, targetUserId, - (title, content) -> roomEventCommandPort.publishRoomActivityStartedEvent( - title, content, targetUserId, roomId, roomTitle + redirectSpec, + (title, content, notificationId) -> roomEventCommandPort.publishRoomActivityStartedEvent( + title, content, notificationId, targetUserId ) ); } @@ -99,12 +153,19 @@ public void notifyRoomActivityStarted(Long targetUserId, Long roomId, String roo @Transactional(propagation = Propagation.MANDATORY) public void notifyRoomJoinToHost(Long hostUserId, Long roomId, String roomTitle, Long actorUserId, String actorUsername) { var args = new RoomJoinToHostTemplate.Args(roomTitle, actorUsername); + + NotificationRedirectSpec redirectSpec = new NotificationRedirectSpec( + MessageRoute.ROOM_DETAIL, + Map.of("roomId", roomId) + ); + notificationSyncExecutor.execute( RoomJoinToHostTemplate.INSTANCE, args, hostUserId, - (title, content) -> roomEventCommandPort.publishRoomJoinEventToHost( - title, content, hostUserId, roomId, roomTitle, actorUserId, actorUsername + redirectSpec, + (title, content, notificationId) -> roomEventCommandPort.publishRoomJoinEventToHost( + title, content, notificationId, hostUserId ) ); } @@ -114,12 +175,24 @@ public void notifyRoomJoinToHost(Long hostUserId, Long roomId, String roomTitle, public void notifyRoomCommentLiked(Long targetUserId, Long actorUserId, String actorUsername, Long roomId, Integer page, Long postId, String postType) { var args = new RoomCommentLikedTemplate.Args(actorUsername); + + NotificationRedirectSpec redirectSpec = new NotificationRedirectSpec( + MessageRoute.ROOM_POST_DETAIL, + Map.of( + "roomId", roomId, + "page", page, + "postId", postId, + "postType", postType + ) + ); + notificationSyncExecutor.execute( RoomCommentLikedTemplate.INSTANCE, args, targetUserId, - (title, content) -> roomEventCommandPort.publishRoomCommentLikedEvent( - title, content, targetUserId, actorUserId, actorUsername, roomId, page, postId, postType + redirectSpec, + (title, content, notificationId) -> roomEventCommandPort.publishRoomCommentLikedEvent( + title, content, notificationId, targetUserId ) ); } @@ -129,12 +202,24 @@ public void notifyRoomCommentLiked(Long targetUserId, Long actorUserId, String a public void notifyRoomPostLiked(Long targetUserId, Long actorUserId, String actorUsername, Long roomId, Integer page, Long postId, String postType) { var args = new RoomPostLikedTemplate.Args(actorUsername); + + NotificationRedirectSpec redirectSpec = new NotificationRedirectSpec( + MessageRoute.ROOM_POST_DETAIL, + Map.of( + "roomId", roomId, + "page", page, + "postId", postId, + "postType", postType + ) + ); + notificationSyncExecutor.execute( RoomPostLikedTemplate.INSTANCE, args, targetUserId, - (title, content) -> roomEventCommandPort.publishRoomPostLikedEvent( - title, content, targetUserId, actorUserId, actorUsername, roomId, page, postId, postType + redirectSpec, + (title, content, notificationId) -> roomEventCommandPort.publishRoomPostLikedEvent( + title, content, notificationId, targetUserId ) ); } @@ -144,12 +229,24 @@ public void notifyRoomPostLiked(Long targetUserId, Long actorUserId, String acto public void notifyRoomPostCommentReplied(Long targetUserId, Long actorUserId, String actorUsername, Long roomId, Integer page, Long postId, String postType) { var args = new RoomPostCommentRepliedTemplate.Args(actorUsername); + + NotificationRedirectSpec redirectSpec = new NotificationRedirectSpec( + MessageRoute.ROOM_POST_DETAIL, + Map.of( + "roomId", roomId, + "page", page, + "postId", postId, + "postType", postType + ) + ); + notificationSyncExecutor.execute( RoomPostCommentRepliedTemplate.INSTANCE, args, targetUserId, - (title, content) -> roomEventCommandPort.publishRoomPostCommentRepliedEvent( - title, content, targetUserId, actorUserId, actorUsername, roomId, page, postId, postType + redirectSpec, + (title, content, notificationId) -> roomEventCommandPort.publishRoomPostCommentRepliedEvent( + title, content, notificationId, targetUserId ) ); } diff --git a/src/main/java/konkuk/thip/notification/application/service/template/feed/FolloweeNewPostTemplate.java b/src/main/java/konkuk/thip/notification/application/service/template/feed/FolloweeNewFeedTemplate.java similarity index 87% rename from src/main/java/konkuk/thip/notification/application/service/template/feed/FolloweeNewPostTemplate.java rename to src/main/java/konkuk/thip/notification/application/service/template/feed/FolloweeNewFeedTemplate.java index 61c4130d1..730138de1 100644 --- a/src/main/java/konkuk/thip/notification/application/service/template/feed/FolloweeNewPostTemplate.java +++ b/src/main/java/konkuk/thip/notification/application/service/template/feed/FolloweeNewFeedTemplate.java @@ -3,7 +3,7 @@ import konkuk.thip.notification.application.service.template.NotificationTemplate; import konkuk.thip.notification.domain.value.NotificationCategory; -public enum FolloweeNewPostTemplate implements NotificationTemplate { +public enum FolloweeNewFeedTemplate implements NotificationTemplate { INSTANCE; @Override diff --git a/src/main/java/konkuk/thip/notification/domain/Notification.java b/src/main/java/konkuk/thip/notification/domain/Notification.java index 412342eaf..2622abb84 100644 --- a/src/main/java/konkuk/thip/notification/domain/Notification.java +++ b/src/main/java/konkuk/thip/notification/domain/Notification.java @@ -1,10 +1,15 @@ package konkuk.thip.notification.domain; import konkuk.thip.common.entity.BaseDomainEntity; +import konkuk.thip.common.exception.InvalidStateException; import konkuk.thip.notification.domain.value.NotificationCategory; +import konkuk.thip.notification.domain.value.NotificationRedirectSpec; import lombok.Getter; import lombok.experimental.SuperBuilder; +import static konkuk.thip.common.exception.code.ErrorCode.NOTIFICATION_ACCESS_FORBIDDEN; +import static konkuk.thip.common.exception.code.ErrorCode.NOTIFICATION_ALREADY_CHECKED; + @Getter @SuperBuilder public class Notification extends BaseDomainEntity { @@ -21,13 +26,31 @@ public class Notification extends BaseDomainEntity { private Long targetUserId; - public static Notification withoutId (String title, String content, NotificationCategory notificationCategory, Long targetUserId) { + private NotificationRedirectSpec redirectSpec; + + public static Notification withoutId(String title, String content, NotificationCategory notificationCategory, Long targetUserId, + NotificationRedirectSpec redirectSpec) { return Notification.builder() .title(title) .content(content) .isChecked(false) .notificationCategory(notificationCategory) .targetUserId(targetUserId) + .redirectSpec(redirectSpec) .build(); } + + public void validateOwner(Long userId) { + if (!targetUserId.equals(userId)) { + throw new InvalidStateException(NOTIFICATION_ACCESS_FORBIDDEN); + } + } + + public void markToChecked() { + if (isChecked) { + throw new InvalidStateException(NOTIFICATION_ALREADY_CHECKED); + } + + this.isChecked = true; + } } diff --git a/src/main/java/konkuk/thip/notification/domain/value/MessageRoute.java b/src/main/java/konkuk/thip/notification/domain/value/MessageRoute.java new file mode 100644 index 000000000..e2ec24ce7 --- /dev/null +++ b/src/main/java/konkuk/thip/notification/domain/value/MessageRoute.java @@ -0,0 +1,22 @@ +package konkuk.thip.notification.domain.value; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum MessageRoute { + // NONE + NONE, // 알림 클릭 시 이동하지 않음 + + // FEED + FEED_USER, // 자신을 팔로우한 사용자의 피드 목록으로 화면 이동 + FEED_DETAIL, // 특정 피드 상세 화면으로 이동 + + // ROOM + ROOM_MAIN, // 특정 모임방 메인 화면으로 이동 + ROOM_DETAIL, // 특정 모임 상세정보 화면으로 이동 + ROOM_POST_DETAIL, // 특정 모임 게시글 상세 화면으로 이동 -> PostType으로 투표인지 기록인지 판단 + ROOM_RECORD_DETAIL, // 특정 모임 기록 상세 화면으로 이동 (기록장 조회 - 페이지 필터 걸린채로) + ROOM_VOTE_DETAIL; // 특정 모임 투표 상세 화면으로 이동 (투표 조회 - 페이지 필터 걸린채로) +} diff --git a/src/main/java/konkuk/thip/notification/domain/value/NotificationRedirectSpec.java b/src/main/java/konkuk/thip/notification/domain/value/NotificationRedirectSpec.java new file mode 100644 index 000000000..0c899719a --- /dev/null +++ b/src/main/java/konkuk/thip/notification/domain/value/NotificationRedirectSpec.java @@ -0,0 +1,12 @@ +package konkuk.thip.notification.domain.value; + +import java.util.Map; + +public record NotificationRedirectSpec( + MessageRoute route, // FE 이동 목적지 + Map params // 목적지로 이동 시 필요한 파라미터들 +) { + public static NotificationRedirectSpec none() { + return new NotificationRedirectSpec(MessageRoute.NONE, Map.of()); + } +} diff --git a/src/main/java/konkuk/thip/notification/domain/value/NotificationRedirectSpecConverter.java b/src/main/java/konkuk/thip/notification/domain/value/NotificationRedirectSpecConverter.java new file mode 100644 index 000000000..59f203628 --- /dev/null +++ b/src/main/java/konkuk/thip/notification/domain/value/NotificationRedirectSpecConverter.java @@ -0,0 +1,38 @@ +package konkuk.thip.notification.domain.value; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.persistence.AttributeConverter; +import jakarta.persistence.Converter; +import konkuk.thip.common.exception.InvalidStateException; + +import java.io.IOException; + +import static konkuk.thip.common.exception.code.ErrorCode.NOTIFICATION_REDIRECT_DATA_DESERIALIZE_FAILED; +import static konkuk.thip.common.exception.code.ErrorCode.NOTIFICATION_REDIRECT_DATA_SERIALIZE_FAILED; + +@Converter +public class NotificationRedirectSpecConverter implements AttributeConverter { + + private static final ObjectMapper objectMapper = new ObjectMapper(); + + @Override + public String convertToDatabaseColumn(NotificationRedirectSpec attribute) { + if (attribute == null) return null; + try { + return objectMapper.writeValueAsString(attribute); + } catch (JsonProcessingException e) { + throw new InvalidStateException(NOTIFICATION_REDIRECT_DATA_SERIALIZE_FAILED); + } + } + + @Override + public NotificationRedirectSpec convertToEntityAttribute(String dbData) { + if (dbData == null || dbData.isBlank()) return NotificationRedirectSpec.none(); + try { + return objectMapper.readValue(dbData, NotificationRedirectSpec.class); + } catch (IOException e) { + throw new InvalidStateException(NOTIFICATION_REDIRECT_DATA_DESERIALIZE_FAILED); + } + } +} diff --git a/src/main/java/konkuk/thip/notification/domain/value/PlatformType.java b/src/main/java/konkuk/thip/notification/domain/value/PlatformType.java index 5ae99334f..c97995ab0 100644 --- a/src/main/java/konkuk/thip/notification/domain/value/PlatformType.java +++ b/src/main/java/konkuk/thip/notification/domain/value/PlatformType.java @@ -1,5 +1,25 @@ package konkuk.thip.notification.domain.value; +import konkuk.thip.common.exception.InvalidStateException; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +import static konkuk.thip.common.exception.code.ErrorCode.INVALID_FE_PLATFORM; + +@Getter +@RequiredArgsConstructor public enum PlatformType { - ANDROID, WEB + ANDROID("ANDROID"), + WEB("WEB"); + + private final String value; + + public static PlatformType from(String value) { + for (PlatformType type : PlatformType.values()) { + if (type.getValue().equals(value)) { + return type; + } + } + throw new InvalidStateException(INVALID_FE_PLATFORM); + } } diff --git a/src/main/resources/db/migration/V250921__Add_notification_redirect_spec.sql b/src/main/resources/db/migration/V250921__Add_notification_redirect_spec.sql new file mode 100644 index 000000000..5a4d469ea --- /dev/null +++ b/src/main/resources/db/migration/V250921__Add_notification_redirect_spec.sql @@ -0,0 +1,3 @@ +ALTER TABLE `notifications` + ADD COLUMN `redirect_spec` TEXT NULL + COMMENT 'NotificationRedirectSpec을 json 형식의 TEXT 로 저장'; \ No newline at end of file diff --git a/src/test/java/konkuk/thip/common/util/TestEntityFactory.java b/src/test/java/konkuk/thip/common/util/TestEntityFactory.java index 86a2f75f1..4490cf101 100644 --- a/src/test/java/konkuk/thip/common/util/TestEntityFactory.java +++ b/src/test/java/konkuk/thip/common/util/TestEntityFactory.java @@ -10,7 +10,9 @@ import konkuk.thip.feed.domain.value.TagList; import konkuk.thip.feed.domain.value.ContentList; import konkuk.thip.notification.adapter.out.jpa.NotificationJpaEntity; +import konkuk.thip.notification.domain.value.MessageRoute; import konkuk.thip.notification.domain.value.NotificationCategory; +import konkuk.thip.notification.domain.value.NotificationRedirectSpec; import konkuk.thip.post.adapter.out.jpa.PostJpaEntity; import konkuk.thip.post.adapter.out.jpa.PostLikeJpaEntity; import konkuk.thip.post.domain.PostType; @@ -28,9 +30,7 @@ import konkuk.thip.user.domain.value.Alias; import java.time.LocalDate; -import java.util.Collections; -import java.util.List; -import java.util.UUID; +import java.util.*; public class TestEntityFactory { @@ -382,4 +382,22 @@ public static NotificationJpaEntity createNotification(UserJpaEntity user, Strin .userJpaEntity(user) .build(); } + + /** + * redirectSpec 데이터도 함께 저장하는 팩토리 메서드 + */ + public static NotificationJpaEntity createNotification(UserJpaEntity user, String title, NotificationCategory category, NotificationRedirectSpec redirectSpec) { + return NotificationJpaEntity.builder() + .title(title) + .content("알림 내용") + .isChecked(false) + .notificationCategory(category) + .userJpaEntity(user) + .redirectSpec(redirectSpec) + .build(); + } + + public static NotificationRedirectSpec createNotificationRedirectSpec(MessageRoute route, Map params) { + return new NotificationRedirectSpec(route, params); + } } diff --git a/src/test/java/konkuk/thip/message/adapter/in/event/MessageFeedEventListenerTest.java b/src/test/java/konkuk/thip/message/adapter/in/event/MessageFeedEventListenerTest.java index bbd7ee37e..f9bd8e039 100644 --- a/src/test/java/konkuk/thip/message/adapter/in/event/MessageFeedEventListenerTest.java +++ b/src/test/java/konkuk/thip/message/adapter/in/event/MessageFeedEventListenerTest.java @@ -33,7 +33,11 @@ class MessageFeedEventListenerTest { void follower_isHandled_afterCommit() { // given var e = FeedEvents.FollowerEvent.builder() - .targetUserId(1L).actorUserId(2L).actorUsername("bob").build(); + .title("title") + .content("content") + .notificationId(1L) + .targetUserId(1L) + .build(); // when: 트랜잭션 안에서 이벤트 발행 publisher.publishEvent(e); @@ -45,4 +49,4 @@ void follower_isHandled_afterCommit() { // then verify(feedUseCase, times(1)).handleFollower(Mockito.eq(e)); } -} \ No newline at end of file +} diff --git a/src/test/java/konkuk/thip/message/adapter/in/event/MessageRoomEventListenerTest.java b/src/test/java/konkuk/thip/message/adapter/in/event/MessageRoomEventListenerTest.java index 25d89a434..b980fe5ec 100644 --- a/src/test/java/konkuk/thip/message/adapter/in/event/MessageRoomEventListenerTest.java +++ b/src/test/java/konkuk/thip/message/adapter/in/event/MessageRoomEventListenerTest.java @@ -31,8 +31,10 @@ class MessageRoomEventListenerTest { @DisplayName("RoomPostCommentedEvent 발행 → 커밋 시 이벤트 리스너가 useCase.handleRoomPostCommented 호출") void roomPostCommented_isHandled_afterCommit() { var e = RoomEvents.RoomPostCommentedEvent.builder() - .targetUserId(10L).actorUserId(20L).actorUsername("alice") - .roomId(100L).page(12).postId(999L).postType("RECORD") + .title("title") + .content("content") + .notificationId(1L) + .targetUserId(10L) .build(); publisher.publishEvent(e); @@ -42,4 +44,4 @@ void roomPostCommented_isHandled_afterCommit() { verify(roomUseCase, times(1)).handleRoomPostCommented(Mockito.eq(e)); } -} \ No newline at end of file +} diff --git a/src/test/java/konkuk/thip/notification/adapter/in/web/NotificationMarkToCheckedApiTest.java b/src/test/java/konkuk/thip/notification/adapter/in/web/NotificationMarkToCheckedApiTest.java new file mode 100644 index 000000000..65ddfcb83 --- /dev/null +++ b/src/test/java/konkuk/thip/notification/adapter/in/web/NotificationMarkToCheckedApiTest.java @@ -0,0 +1,123 @@ +package konkuk.thip.notification.adapter.in.web; + +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.persistence.EntityManager; +import konkuk.thip.common.util.TestEntityFactory; +import konkuk.thip.notification.adapter.out.jpa.NotificationJpaEntity; +import konkuk.thip.notification.adapter.out.persistence.repository.NotificationJpaRepository; +import konkuk.thip.notification.domain.value.MessageRoute; +import konkuk.thip.notification.domain.value.NotificationCategory; +import konkuk.thip.notification.domain.value.NotificationRedirectSpec; +import konkuk.thip.user.adapter.out.jpa.UserJpaEntity; +import konkuk.thip.user.adapter.out.persistence.repository.UserJpaRepository; +import konkuk.thip.user.domain.value.Alias; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Map; + +import static konkuk.thip.common.exception.code.ErrorCode.NOTIFICATION_ALREADY_CHECKED; +import static org.assertj.core.api.Assertions.assertThat; +import static org.hamcrest.Matchers.containsString; +import static org.springframework.http.MediaType.APPLICATION_JSON; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; + +@SpringBootTest +@ActiveProfiles("test") +@Transactional +@AutoConfigureMockMvc(addFilters = false) +@DisplayName("[통합] 알림 읽음 처리 api 통합 테스트") +class NotificationMarkToCheckedApiTest { + + @Autowired private MockMvc mockMvc; + @Autowired private ObjectMapper objectMapper; + + @Autowired private UserJpaRepository userJpaRepository; + @Autowired private NotificationJpaRepository notificationJpaRepository; + @Autowired private JdbcTemplate jdbcTemplate; + @Autowired private EntityManager em; + + @Test + @DisplayName("본인의 알림을 읽음 처리할 경우, 해당 알림의 isChecked가 true로 변경되고, 알림의 리다이렉트를 위한 데이터가 반환된다.") + void mark_notification_to_checked_success() throws Exception { + //given + UserJpaEntity user = userJpaRepository.save(TestEntityFactory.createUser(Alias.WRITER)); + + NotificationRedirectSpec redirectSpec = TestEntityFactory.createNotificationRedirectSpec( + MessageRoute.FEED_USER, Map.of("userId", 123L) // 특정 유저의 피드 페이지로 이동 + ); + + NotificationJpaEntity notificationJpaEntity = notificationJpaRepository.save( + TestEntityFactory.createNotification(user, "피드알림", NotificationCategory.FEED, redirectSpec)); + + // when & then + String body = objectMapper.writeValueAsString(Map.of("notificationId", notificationJpaEntity.getNotificationId())); + mockMvc.perform(post("/notifications/check") + .contentType(APPLICATION_JSON) + .content(body) + .requestAttr("userId", user.getUserId())) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.route").value(MessageRoute.FEED_USER.name())) + .andExpect(jsonPath("$.data.params.userId").value(123)); + + // DB 반영 확인 + NotificationJpaEntity reloaded = notificationJpaRepository.findById(notificationJpaEntity.getNotificationId()).orElseThrow(); + assertThat(reloaded.isChecked()).isTrue(); + } + + @Test + @DisplayName("다른 사용자의 알림을 읽음 처리하려고 하면, 403(FORBIDDEN) 에러를 반환한다") + void mark_notification_to_checked_forbidden_when_not_owner() throws Exception { + // given: 알림 주인(owner)과 다른 사용자(stranger) + UserJpaEntity owner = userJpaRepository.save(TestEntityFactory.createUser(Alias.WRITER)); + UserJpaEntity stranger = userJpaRepository.save(TestEntityFactory.createUser(Alias.WRITER)); + + NotificationJpaEntity notification = notificationJpaRepository.save( + TestEntityFactory.createNotification(owner, "남의 알림", NotificationCategory.FEED)); + + // when & then: 남의 알림을 stranger가 읽음 처리 시도 → 403 + String body = objectMapper.writeValueAsString(Map.of("notificationId", notification.getNotificationId())); + mockMvc.perform(post("/notifications/check") + .contentType(APPLICATION_JSON) + .content(body) + .requestAttr("userId", stranger.getUserId())) + .andExpect(status().isForbidden()); + } + + @Test + @DisplayName("이미 읽음 처리된 알림을 다시 읽음 처리하면, 400 에러를 반환한다.") + void mark_notification_to_checked_already_checked() throws Exception { + // given: owner의 알림을 미리 is_checked=true 상태로 만들어 둔다 + UserJpaEntity owner = userJpaRepository.save(TestEntityFactory.createUser(Alias.WRITER)); + + NotificationJpaEntity notification = notificationJpaRepository.save( + TestEntityFactory.createNotification(owner, "이미 읽은 알림", NotificationCategory.FEED)); + + // is_checked=true 로 강제 세팅 + jdbcTemplate.update( + "UPDATE notifications SET is_checked = TRUE WHERE notification_id = ?", + notification.getNotificationId() + ); + em.flush(); + em.clear(); // 영속성 컨텍스트 초기화(DB에 직접 반영한 엔티티 변경사항을 반영하기 위해) + + // when & then: 다시 읽음 처리 시도 → 400 + String body = objectMapper.writeValueAsString(Map.of("notificationId", notification.getNotificationId())); + mockMvc.perform(post("/notifications/check") + .contentType(APPLICATION_JSON) + .content(body) + .requestAttr("userId", owner.getUserId())) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(NOTIFICATION_ALREADY_CHECKED.getCode())) + .andExpect(jsonPath("$.message", containsString(NOTIFICATION_ALREADY_CHECKED.getMessage()))); + } +} diff --git a/src/test/java/konkuk/thip/notification/application/service/FeedNotificationOrchestratorSyncImplTest.java b/src/test/java/konkuk/thip/notification/application/service/FeedNotificationOrchestratorSyncImplTest.java index 6bfa02b5b..36c728f49 100644 --- a/src/test/java/konkuk/thip/notification/application/service/FeedNotificationOrchestratorSyncImplTest.java +++ b/src/test/java/konkuk/thip/notification/application/service/FeedNotificationOrchestratorSyncImplTest.java @@ -106,14 +106,14 @@ void notifyFeedCommented_afterCommit_listenerInvoked_andNotificationPersisted() ArgumentCaptor.forClass(FeedEvents.FeedCommentedEvent.class); verify(feedNotificationDispatchUseCase).handleFeedCommented(captor.capture()); + NotificationJpaEntity saved = notificationJpaRepository.findAll().get(0); + FeedEvents.FeedCommentedEvent event = captor.getValue(); assertThat(event).isNotNull(); assertThat(event.title()).isNotBlank(); assertThat(event.content()).contains(actorUsername); assertThat(event.targetUserId()).isEqualTo(targetUserId); - assertThat(event.actorUserId()).isEqualTo(actorUserId); - assertThat(event.actorUsername()).isEqualTo(actorUsername); - assertThat(event.feedId()).isEqualTo(feedId); + assertThat(event.notificationId()).isEqualTo(saved.getNotificationId()); // 퍼블리시되는 이벤트에는 저장된 notificationId 값이 포함됨 } @Test diff --git a/src/test/java/konkuk/thip/notification/application/service/FeedNotificationOrchestratorSyncImplUnitTest.java b/src/test/java/konkuk/thip/notification/application/service/FeedNotificationOrchestratorSyncImplUnitTest.java index 3972f9803..636df7797 100644 --- a/src/test/java/konkuk/thip/notification/application/service/FeedNotificationOrchestratorSyncImplUnitTest.java +++ b/src/test/java/konkuk/thip/notification/application/service/FeedNotificationOrchestratorSyncImplUnitTest.java @@ -9,6 +9,8 @@ import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; import static org.mockito.Mockito.verify; @ExtendWith(MockitoExtension.class) @@ -35,17 +37,18 @@ void notify_feed_commented_test() { // then: NotificationSyncExecutor 가 올바르게 호출되었는지 검증 ArgumentCaptor invokerCaptor = ArgumentCaptor.forClass(EventCommandInvoker.class); verify(notificationSyncExecutor).execute( - org.mockito.ArgumentMatchers.any(), - org.mockito.ArgumentMatchers.any(), - org.mockito.ArgumentMatchers.eq(targetUserId), - invokerCaptor.capture() + any(), // template + any(), // args + eq(targetUserId), // targetUserId + any(), // redirectSpec + invokerCaptor.capture() // invoker ); // then: invoker 가 EventCommandPort 메서드를 올바르게 호출하는지 검증 EventCommandInvoker invoker = invokerCaptor.getValue(); - invoker.publish("title", "content"); + invoker.publish("title", "content", 123L); verify(feedEventCommandPort).publishFeedCommentedEvent( - "title", "content", targetUserId, actorUserId, actorUsername, feedId + "title", "content", 123L, targetUserId ); } } diff --git a/src/test/java/konkuk/thip/notification/application/service/NotificationSyncExecutorTest.java b/src/test/java/konkuk/thip/notification/application/service/NotificationSyncExecutorTest.java index 8db11367a..4f312bce6 100644 --- a/src/test/java/konkuk/thip/notification/application/service/NotificationSyncExecutorTest.java +++ b/src/test/java/konkuk/thip/notification/application/service/NotificationSyncExecutorTest.java @@ -20,6 +20,8 @@ class NotificationSyncExecutorTest { void execute_publish_failure_does_not_throw() { // given NotificationCommandPort commandPort = mock(NotificationCommandPort.class); + when(commandPort.save(any(Notification.class))).thenReturn(42L); // save 시 생성된 notificationId 를 리턴하도록 스텁 + NotificationSyncExecutor executor = new NotificationSyncExecutor(commandPort); // 간단한 템플릿 스텁 (title/content 고정) @@ -33,7 +35,7 @@ void execute_publish_failure_does_not_throw() { }; // publish 호출 시 강제로 예외를 던지는 invoker - EventCommandInvoker invoker = (title, content) -> { + EventCommandInvoker invoker = (title, content, notificationId) -> { throw new RuntimeException("강제 퍼블리시 실패"); }; @@ -51,5 +53,6 @@ void execute_publish_failure_does_not_throw() { assertThat(saved.getTitle()).isEqualTo("테스트제목"); assertThat(saved.getContent()).isEqualTo("테스트내용"); assertThat(saved.getTargetUserId()).isEqualTo(123L); + assertThat(saved.getNotificationCategory()).isEqualTo(NotificationCategory.FEED); } } diff --git a/src/test/java/konkuk/thip/notification/application/service/RoomNotificationOrchestratorSyncImplTest.java b/src/test/java/konkuk/thip/notification/application/service/RoomNotificationOrchestratorSyncImplTest.java index 65cd80564..bcdcd80e4 100644 --- a/src/test/java/konkuk/thip/notification/application/service/RoomNotificationOrchestratorSyncImplTest.java +++ b/src/test/java/konkuk/thip/notification/application/service/RoomNotificationOrchestratorSyncImplTest.java @@ -126,17 +126,14 @@ void roomPostCommented_afterCommit_listenerInvoked_andNotificationCommitted() { ArgumentCaptor.forClass(RoomEvents.RoomPostCommentedEvent.class); verify(roomNotificationDispatchUseCase).handleRoomPostCommented(captor.capture()); + NotificationJpaEntity saved = notificationJpaRepository.findAll().get(0); + RoomEvents.RoomPostCommentedEvent event = captor.getValue(); assertThat(event).isNotNull(); assertThat(event.title()).isNotBlank(); assertThat(event.content()).contains(actorUsername); assertThat(event.targetUserId()).isEqualTo(targetUserId); - assertThat(event.actorUserId()).isEqualTo(actorUserId); - assertThat(event.actorUsername()).isEqualTo(actorUsername); - assertThat(event.roomId()).isEqualTo(roomId); - assertThat(event.page()).isEqualTo(page); - assertThat(event.postId()).isEqualTo(postId); - assertThat(event.postType()).isEqualTo(postType); + assertThat(event.notificationId()).isEqualTo(saved.getNotificationId()); } @Test diff --git a/src/test/java/konkuk/thip/notification/application/service/RoomNotificationOrchestratorSyncImplUnitTest.java b/src/test/java/konkuk/thip/notification/application/service/RoomNotificationOrchestratorSyncImplUnitTest.java index 93e15df72..c5fd1eec5 100644 --- a/src/test/java/konkuk/thip/notification/application/service/RoomNotificationOrchestratorSyncImplUnitTest.java +++ b/src/test/java/konkuk/thip/notification/application/service/RoomNotificationOrchestratorSyncImplUnitTest.java @@ -37,14 +37,18 @@ void notify_room_post_commented() { // then: NotificationSyncExecutor 가 올바르게 호출되었는지 검증 ArgumentCaptor invokerCaptor = ArgumentCaptor.forClass(EventCommandInvoker.class); verify(notificationSyncExecutor).execute( - any(), any(), eq(targetUserId), invokerCaptor.capture() + any(), // template + any(), // args + eq(targetUserId), // targetUserId + any(), // redirectSpec + invokerCaptor.capture() // invoker ); // then: invoker 가 EventCommandPort 메서드를 올바르게 호출하는지 검증 EventCommandInvoker invoker = invokerCaptor.getValue(); - invoker.publish("title", "content"); + invoker.publish("title", "content", 123L); verify(roomEventCommandPort).publishRoomPostCommentedEvent( - "title", "content", targetUserId, actorUserId, actorUsername, roomId, page, postId, postType + "title", "content", 123L, targetUserId ); } } diff --git a/src/test/java/konkuk/thip/notification/domain/NotificationTest.java b/src/test/java/konkuk/thip/notification/domain/NotificationTest.java new file mode 100644 index 000000000..2a9c84044 --- /dev/null +++ b/src/test/java/konkuk/thip/notification/domain/NotificationTest.java @@ -0,0 +1,53 @@ +package konkuk.thip.notification.domain; + +import konkuk.thip.common.exception.InvalidStateException; +import konkuk.thip.notification.domain.value.NotificationCategory; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static konkuk.thip.common.exception.code.ErrorCode.NOTIFICATION_ACCESS_FORBIDDEN; +import static konkuk.thip.common.exception.code.ErrorCode.NOTIFICATION_ALREADY_CHECKED; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +@DisplayName("[단위] Notification 단위 테스트") +class NotificationTest { + + @Test + @DisplayName("validateOwner(): 알림의 소유자가 아니면 InvalidStateException 을 던진다.") + void validate_owner_other_user() throws Exception { + //given + Long ownerId = 1L; + Long otherId = 2L; + Notification notification = createNotification(ownerId); + + //when //then + assertThatThrownBy(() -> notification.validateOwner(otherId)) + .isInstanceOf(InvalidStateException.class) + .hasMessage(NOTIFICATION_ACCESS_FORBIDDEN.getMessage()); + + } + + @Test + @DisplayName("markToChecked(): 이미 읽음 처리된 알림에 대해 다시 읽음 처리하려고 하면, InvalidStateException 을 던진다.") + void mark_to_checked_already_checked() throws Exception { + //given + Notification notification = createNotification(1L); + notification.markToChecked(); // 이미 읽음 처리 + + //when //then + assertThatThrownBy(notification::markToChecked) + .isInstanceOf(InvalidStateException.class) + .hasMessage(NOTIFICATION_ALREADY_CHECKED.getMessage()); + } + + private Notification createNotification(Long targetUserId) { + return Notification.builder() + .title("title") + .content("content") + .isChecked(false) + .notificationCategory(NotificationCategory.FEED) + .targetUserId(targetUserId) + .redirectSpec(null) + .build(); + } +}