Skip to content

Commit 89f701e

Browse files
authored
Merge pull request #128 from SEMOSAN/feat/#126-community-notifications
[Feat] 대댓글 및 좋아요 시 알림 기능 추가
2 parents d14fcdd + 1c59f53 commit 89f701e

10 files changed

Lines changed: 611 additions & 3 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
- Use Java 21 and Gradle.
2525
- Follow the existing package structure, naming, and style.
2626
- Prefer existing services, repositories, DTOs, and response conventions over introducing new abstractions.
27+
- When adding code, avoid duplicating business rules, payload construction, validation checks, formatting, or helper logic across services. Extract shared behavior into an appropriate existing service/helper or a narrowly scoped new component, and keep callers focused on orchestration.
2728

2829
## Commands
2930
- Use `rg` first for searching.

src/main/java/com/semosan/api/domain/community/comment/service/CommentService.java

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import com.semosan.api.domain.community.comment.dto.CommentResponse;
66
import com.semosan.api.domain.community.comment.entity.Comment;
77
import com.semosan.api.domain.community.comment.repository.CommentRepository;
8+
import com.semosan.api.domain.community.notification.service.CommunityNotificationService;
89
import com.semosan.api.domain.community.post.entity.Post;
910
import com.semosan.api.domain.community.post.repository.PostRepository;
1011
import com.semosan.api.domain.user.repository.UserBlockRepository;
@@ -29,14 +30,17 @@ public class CommentService {
2930
private final PostRepository postRepository;
3031
private final UserRepository userRepository;
3132
private final UserBlockRepository userBlockRepository;
33+
private final CommunityNotificationService communityNotificationService;
3234

3335
@Transactional
3436
public Comment create(Long postId, Long authorId, String content) {
3537
Post post = findPostOrThrow(postId);
3638
User author = findUserOrThrow(authorId);
3739

3840
Comment comment = Comment.create(post, author, content);
39-
return commentRepository.save(comment);
41+
Comment savedComment = commentRepository.save(comment);
42+
communityNotificationService.sendCommentNotification(post, author, savedComment);
43+
return savedComment;
4044
}
4145

4246
@Transactional
@@ -55,7 +59,9 @@ public Comment reply(Long postId, Long authorId, Long parentId, Long mentionedUs
5559
User mentionedUser = mentionedUserId != null ? findUserOrThrow(mentionedUserId) : null;
5660

5761
Comment reply = Comment.reply(post, author, actualParent, mentionedUser, content);
58-
return commentRepository.save(reply);
62+
Comment savedReply = commentRepository.save(reply);
63+
communityNotificationService.sendReplyNotification(post, author, actualParent, mentionedUser, savedReply);
64+
return savedReply;
5965
}
6066

6167
public Page<CommentResponse> getCommentsByPost(Long postId, Long viewerId, Pageable pageable) {

src/main/java/com/semosan/api/domain/community/like/service/PostLikeService.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
import com.semosan.api.domain.community.like.dto.PostLikeToggleResponse;
66
import com.semosan.api.domain.community.like.entity.PostLike;
77
import com.semosan.api.domain.community.like.repository.PostLikeRepository;
8+
import com.semosan.api.domain.community.notification.service.CommunityNotificationService;
89
import com.semosan.api.domain.community.post.entity.Post;
910
import com.semosan.api.domain.community.post.repository.PostRepository;
1011
import com.semosan.api.domain.user.entity.User;
@@ -26,6 +27,7 @@ public class PostLikeService {
2627
private final PostLikeRepository postLikeRepository;
2728
private final PostRepository postRepository;
2829
private final UserRepository userRepository;
30+
private final CommunityNotificationService communityNotificationService;
2931

3032
/**
3133
* @return true = 좋아요 누름 / false = 좋아요 취소
@@ -42,6 +44,7 @@ private boolean toggle(Long postId, Long userId) {
4244

4345
try {
4446
postLikeRepository.save(PostLike.create(post, user));
47+
communityNotificationService.sendPostLikeNotification(post, user);
4548
return true;
4649
} catch (DataIntegrityViolationException e) {
4750
log.warn("PostLike 동시 요청 감지: postId={}, userId={}", postId, userId);
Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,116 @@
1+
package com.semosan.api.domain.community.notification.service;
2+
3+
import com.semosan.api.domain.community.comment.entity.Comment;
4+
import com.semosan.api.domain.community.post.entity.Post;
5+
import com.semosan.api.domain.notification.enums.NotificationType;
6+
import com.semosan.api.domain.notification.service.NotificationService;
7+
import com.semosan.api.domain.user.entity.User;
8+
import com.semosan.api.domain.user.repository.UserRepository;
9+
import lombok.RequiredArgsConstructor;
10+
import org.springframework.stereotype.Service;
11+
12+
import java.util.HashSet;
13+
import java.util.Map;
14+
import java.util.Set;
15+
16+
@Service
17+
@RequiredArgsConstructor
18+
public class CommunityNotificationService {
19+
20+
private static final int COMMENT_PREVIEW_MAX_LENGTH = 50;
21+
22+
private final NotificationService notificationService;
23+
private final UserRepository userRepository;
24+
25+
public void sendCommentNotification(Post post, User author, Comment comment) {
26+
User receiver = post.getAuthor();
27+
sendIfEligible(
28+
receiver,
29+
author,
30+
NotificationType.COMMUNITY_COMMENT,
31+
Map.of(
32+
"actorId", author.getId(),
33+
"actorName", author.displayName(),
34+
"postId", post.getId(),
35+
"commentId", comment.getId(),
36+
"commentPreview", preview(comment.getContent())
37+
)
38+
);
39+
}
40+
41+
public void sendReplyNotification(Post post, User author, Comment parent, User mentionedUser, Comment reply) {
42+
Set<Long> sentReceiverIds = new HashSet<>();
43+
ReplyNotificationContext context = new ReplyNotificationContext(post, author, parent, reply);
44+
sendReplyNotificationToReceiver(context, parent.getAuthor(), sentReceiverIds);
45+
if (mentionedUser != null) {
46+
sendReplyNotificationToReceiver(context, mentionedUser, sentReceiverIds);
47+
}
48+
}
49+
50+
public void sendPostLikeNotification(Post post, User user) {
51+
User receiver = post.getAuthor();
52+
sendIfEligible(
53+
receiver,
54+
user,
55+
NotificationType.COMMUNITY_POST_LIKE,
56+
Map.of(
57+
"actorId", user.getId(),
58+
"actorName", user.displayName(),
59+
"postId", post.getId()
60+
)
61+
);
62+
}
63+
64+
private void sendReplyNotificationToReceiver(
65+
ReplyNotificationContext context,
66+
User receiver,
67+
Set<Long> sentReceiverIds
68+
) {
69+
if (!sentReceiverIds.add(receiver.getId())) {
70+
return;
71+
}
72+
73+
sendIfEligible(
74+
receiver,
75+
context.author(),
76+
NotificationType.COMMUNITY_REPLY,
77+
Map.of(
78+
"actorId", context.author().getId(),
79+
"actorName", context.author().displayName(),
80+
"postId", context.post().getId(),
81+
"parentCommentId", context.parent().getId(),
82+
"commentId", context.reply().getId(),
83+
"commentPreview", preview(context.reply().getContent())
84+
)
85+
);
86+
}
87+
88+
private void sendIfEligible(User receiver, User actor, NotificationType type, Map<String, Object> params) {
89+
if (receiver.getId().equals(actor.getId())) {
90+
return;
91+
}
92+
if (!userRepository.existsByIdAndDeletedFalse(receiver.getId())) {
93+
return;
94+
}
95+
96+
notificationService.send(receiver.getId(), type, params);
97+
}
98+
99+
private String preview(String content) {
100+
if (content == null) {
101+
return "";
102+
}
103+
if (content.length() <= COMMENT_PREVIEW_MAX_LENGTH) {
104+
return content;
105+
}
106+
return content.substring(0, COMMENT_PREVIEW_MAX_LENGTH) + "...";
107+
}
108+
109+
private record ReplyNotificationContext(
110+
Post post,
111+
User author,
112+
Comment parent,
113+
Comment reply
114+
) {
115+
}
116+
}

src/main/java/com/semosan/api/domain/notification/enums/NotificationType.java

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,27 @@
88

99
public enum NotificationType {
1010

11-
// 테스트 용
1211
COMMUNITY_COMMENT(
1312
"새 댓글이 달렸어요",
1413
"{actorName}: {commentPreview}",
1514
Set.of("actorName", "commentPreview"),
1615
false
1716
),
1817

18+
COMMUNITY_REPLY(
19+
"새 답글이 달렸어요",
20+
"{actorName}: {commentPreview}",
21+
Set.of("actorName", "commentPreview"),
22+
false
23+
),
24+
25+
COMMUNITY_POST_LIKE(
26+
"게시글에 좋아요가 눌렸어요",
27+
"{actorName}님이 게시글을 좋아합니다",
28+
Set.of("actorName"),
29+
false
30+
),
31+
1932
/**
2033
* 트래킹 중 거리 마일스톤 도달 시 사진 촬영 유도.
2134
* 포그라운드 즉시 표시를 위해 FCM data-only 로 발송하고, 앱에서 로컬 알림을 생성한다.

src/main/java/com/semosan/api/domain/user/entity/User.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,16 @@ public void updateNickname(String nickname) {
144144
this.nickname = nickname;
145145
}
146146

147+
public String displayName() {
148+
if (this.nickname != null && !this.nickname.isBlank()) {
149+
return this.nickname;
150+
}
151+
if (this.name != null && !this.name.isBlank()) {
152+
return this.name;
153+
}
154+
return "사용자";
155+
}
156+
147157
// 온보딩 완료 처리
148158
public void completeOnboarding(CompleteOnboardingCommand command) {
149159
this.nickname = command.nickname();
Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
-- =====================================================================
2+
-- V22__update_community_notification_types.sql
3+
-- 목적: notifications.type CHECK 제약에 커뮤니티 답글/좋아요 알림 타입을 포함시킨다.
4+
-- 배경: Hibernate enum 컬럼 CHECK 는 새 enum 추가 시 자동 갱신되지 않는다.
5+
-- =====================================================================
6+
7+
ALTER TABLE notifications DROP CONSTRAINT IF EXISTS notifications_type_check;
8+
9+
ALTER TABLE notifications ADD CONSTRAINT notifications_type_check
10+
CHECK (type IN (
11+
'COMMUNITY_COMMENT',
12+
'COMMUNITY_REPLY',
13+
'COMMUNITY_POST_LIKE',
14+
'TRACKING_PHOTO_MILESTONE',
15+
'TRACKING_SUMMIT_REACHED'
16+
));
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
package com.semosan.api.domain.community.comment.service;
2+
3+
import com.semosan.api.domain.community.comment.entity.Comment;
4+
import com.semosan.api.domain.community.comment.repository.CommentRepository;
5+
import com.semosan.api.domain.community.notification.service.CommunityNotificationService;
6+
import com.semosan.api.domain.community.post.entity.FreePost;
7+
import com.semosan.api.domain.community.post.repository.PostRepository;
8+
import com.semosan.api.domain.user.entity.User;
9+
import com.semosan.api.domain.user.enums.user.DeviceType;
10+
import com.semosan.api.domain.user.repository.UserBlockRepository;
11+
import com.semosan.api.domain.user.repository.UserRepository;
12+
import org.junit.jupiter.api.Test;
13+
import org.junit.jupiter.api.extension.ExtendWith;
14+
import org.mockito.InjectMocks;
15+
import org.mockito.Mock;
16+
import org.mockito.junit.jupiter.MockitoExtension;
17+
import org.springframework.test.util.ReflectionTestUtils;
18+
19+
import java.lang.reflect.Constructor;
20+
import java.util.Optional;
21+
22+
import static org.assertj.core.api.Assertions.assertThat;
23+
import static org.mockito.ArgumentMatchers.any;
24+
import static org.mockito.Mockito.verify;
25+
import static org.mockito.Mockito.when;
26+
27+
@ExtendWith(MockitoExtension.class)
28+
class CommentServiceTest {
29+
30+
@Mock
31+
private CommentRepository commentRepository;
32+
33+
@Mock
34+
private PostRepository postRepository;
35+
36+
@Mock
37+
private UserRepository userRepository;
38+
39+
@Mock
40+
private UserBlockRepository userBlockRepository;
41+
42+
@Mock
43+
private CommunityNotificationService communityNotificationService;
44+
45+
@InjectMocks
46+
private CommentService commentService;
47+
48+
@Test
49+
void createSavesCommentAndDelegatesNotification() throws Exception {
50+
User postAuthor = user(1L, "post-author");
51+
User commentAuthor = user(2L, "comment-author");
52+
FreePost post = freePost(10L, postAuthor, "제목", "본문");
53+
54+
when(postRepository.findById(10L)).thenReturn(Optional.of(post));
55+
when(userRepository.findById(2L)).thenReturn(Optional.of(commentAuthor));
56+
when(commentRepository.save(any(Comment.class))).thenAnswer(invocation -> {
57+
Comment comment = invocation.getArgument(0);
58+
ReflectionTestUtils.setField(comment, "id", 100L);
59+
return comment;
60+
});
61+
62+
Comment result = commentService.create(10L, 2L, "댓글입니다");
63+
64+
assertThat(result.getId()).isEqualTo(100L);
65+
verify(communityNotificationService).sendCommentNotification(post, commentAuthor, result);
66+
}
67+
68+
@Test
69+
void replySavesReplyAndDelegatesNotification() throws Exception {
70+
User postAuthor = user(1L, "post-author");
71+
User parentAuthor = user(2L, "parent-author");
72+
User replyAuthor = user(3L, "reply-author");
73+
User mentionedUser = user(4L, "mentioned-user");
74+
FreePost post = freePost(10L, postAuthor, "제목", "본문");
75+
Comment parent = comment(100L, post, parentAuthor, "부모 댓글");
76+
77+
when(postRepository.findById(10L)).thenReturn(Optional.of(post));
78+
when(userRepository.findById(3L)).thenReturn(Optional.of(replyAuthor));
79+
when(userRepository.findById(4L)).thenReturn(Optional.of(mentionedUser));
80+
when(commentRepository.findByIdAndDeletedFalse(100L)).thenReturn(Optional.of(parent));
81+
when(commentRepository.save(any(Comment.class))).thenAnswer(invocation -> {
82+
Comment comment = invocation.getArgument(0);
83+
ReflectionTestUtils.setField(comment, "id", 101L);
84+
return comment;
85+
});
86+
87+
Comment result = commentService.reply(10L, 3L, 100L, 4L, "대댓글입니다");
88+
89+
assertThat(result.getId()).isEqualTo(101L);
90+
verify(communityNotificationService)
91+
.sendReplyNotification(post, replyAuthor, parent, mentionedUser, result);
92+
}
93+
94+
private User user(Long id, String nickname) {
95+
User user = User.createTestUser(nickname, DeviceType.IOS);
96+
ReflectionTestUtils.setField(user, "id", id);
97+
ReflectionTestUtils.setField(user, "nickname", nickname);
98+
return user;
99+
}
100+
101+
private FreePost freePost(Long id, User author, String title, String content) throws Exception {
102+
Constructor<FreePost> constructor = FreePost.class.getDeclaredConstructor(User.class, String.class, String.class);
103+
constructor.setAccessible(true);
104+
FreePost post = constructor.newInstance(author, title, content);
105+
ReflectionTestUtils.setField(post, "id", id);
106+
return post;
107+
}
108+
109+
private Comment comment(Long id, FreePost post, User author, String content) {
110+
Comment comment = Comment.create(post, author, content);
111+
ReflectionTestUtils.setField(comment, "id", id);
112+
return comment;
113+
}
114+
}

0 commit comments

Comments
 (0)