Skip to content

Commit f860c0d

Browse files
authored
feat(social): 댓글/대댓글, 좋아요 기능 및 신고, 소셜 정지 기능 구현 (#167)
* chore(infra): commitlint에 social scope 추가 * feat(social): comments 테이블 및 Comment 엔티티 추가 daily_report_id, author_id, parent_comment_id FK 구성, 댓글/대댓글 구조, 리포지토리 구현 * feat(social): 댓글/대댓글 API 구현 댓글/대댓글 CRUD API 구현, 비밀 댓글 열람 권한 및 마스킹 처리, 차단·탈퇴 유저 필터링, visibleSubCommentCount 계산 * feat(social): 피드 응답에 myReport 필드 추가 피드 응답에 내 공유 리포트(myReport) 필드 추가 * feat(notify): 댓글/대댓글 FCM 알림 추가 COMMENT_ON_MY_REPORT, REPLY_ON_MY_COMMENT, REPLY_ON_PARTICIPATED_COMMENT 알림 타입 추가, 알림 대상 탈퇴 유저 필터링, FriendNotificationEventListener 패키지 friend → social 이동 * feat(social): comments 소프트 딜리트 전환 삭제된 댓글도 유지하는 것이 추후 확장 시에 유리할 것이라 생각해 물리 삭제에서 소프트 딜리트로 전환 * fix(notify): 댓글 알림 내용 말미 공백 제거 * feat(social): 좋아요 기능 구현 및 피드 응답에 myReport 필드, isLiked·hasLikes 필드 추가 daily_report_likes, comment_likes 테이블 마이그레이션 추가, 게시글·댓글/대댓글 좋아요·취소·리스트 API 구현, CommentResponse, FeedResponse에 isLiked·hasLikes 필드 추가, FeedListResponse에 myReport 필드 추가 * feat(social): 댓글 신고 지원 및 소셜 정지 기능 구현 social_suspensions 테이블 추가 및 content_reports 테이블에 comment_id 컬럼 추가, daily_report_id FK를 ON DELETE SET NULL로 변경해 게시글 삭제 후에도 신고 이력 보존, 기존 SharingSuspensionService를 social_suspensions 기반으로 재구현, 신고 누적 조건 만족시 정지 자동 발동, 오늘 공유 게시글 일괄 비공개 처리, 정지 상태 조회 API 추가 * feat(social): 소셜 정지 중 댓글·좋아요 차단 및 정지 유저·신고 댓글 필터링 CommentCommandService·LikeCommandService의 write 메서드 전체에 소셜 정지 상태인지 체크하는 메서드 추가, CommentQueryService·LikeQueryService의 getExcludedUserIds에 정지 중인 유저 ID를 포함해 조회 시 자동 필터링, CommentRepository 댓글·대댓글·서브카운트 쿼리 3개에 NOT EXISTS 서브쿼리 추가하여 내가 신고한 댓글 미노출 * fix(social): 간단한 이슈 수정 countAllReports / countAllDistinctReporters 새 메서드 추가, @Modifying(clearAutomatically = true) 추가, deleteComment Swagger에 409 COMMENT_DELETED 추가
1 parent a9d6258 commit f860c0d

47 files changed

Lines changed: 2423 additions & 117 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

commitlint.config.js

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ module.exports = {
2222
"search",
2323
"stats",
2424
"friend",
25+
"social",
2526
"notify",
2627
"infra",
2728
"db",
@@ -123,6 +124,9 @@ module.exports = {
123124
friend: {
124125
description: '👥 친구 도메인 (예: 친구 신청, 수락, 목록 관리)'
125126
},
127+
social: {
128+
description: '💬 소셜 도메인 (예: 댓글, 좋아요, 피드 공유)'
129+
},
126130
notify: {
127131
description: '📧 알림/이메일 전송 (예: 질문 알림, 인증 이메일 전송)'
128132
},

src/main/java/com/devkor/ifive/nadab/domain/comment/api/CommentController.java

Lines changed: 294 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package com.devkor.ifive.nadab.domain.comment.api.dto.request;
2+
3+
import io.swagger.v3.oas.annotations.media.Schema;
4+
import jakarta.validation.constraints.NotBlank;
5+
import jakarta.validation.constraints.NotNull;
6+
import jakarta.validation.constraints.Size;
7+
8+
@Schema(description = "댓글 작성 요청")
9+
public record CreateCommentRequest(
10+
11+
@Schema(description = "리포트 ID")
12+
@NotNull(message = "리포트 ID를 입력해주세요")
13+
Long dailyReportId,
14+
15+
@Schema(description = "댓글 내용 (1~500자)", example = "공감해요!")
16+
@NotBlank(message = "댓글 내용을 입력해주세요")
17+
@Size(max = 500, message = "댓글은 500자 이하로 입력해주세요")
18+
String content,
19+
20+
@Schema(description = "비밀 댓글 여부", example = "false")
21+
boolean isSecret
22+
) {
23+
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
package com.devkor.ifive.nadab.domain.comment.api.dto.request;
2+
3+
import io.swagger.v3.oas.annotations.media.Schema;
4+
import jakarta.validation.constraints.NotBlank;
5+
import jakarta.validation.constraints.Size;
6+
7+
@Schema(description = "대댓글 작성 요청")
8+
public record CreateSubCommentRequest(
9+
10+
@Schema(description = "댓글 내용 (1~500자)", example = "공감해요!")
11+
@NotBlank(message = "댓글 내용을 입력해주세요")
12+
@Size(max = 500, message = "댓글은 500자 이하로 입력해주세요")
13+
String content,
14+
15+
@Schema(description = "비밀 댓글 여부", example = "false")
16+
boolean isSecret
17+
) {
18+
}
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
package com.devkor.ifive.nadab.domain.comment.api.dto.request;
2+
3+
import io.swagger.v3.oas.annotations.media.Schema;
4+
import jakarta.validation.constraints.NotBlank;
5+
import jakarta.validation.constraints.Size;
6+
7+
@Schema(description = "댓글 수정 요청")
8+
public record UpdateCommentRequest(
9+
10+
@Schema(description = "수정할 댓글 내용 (1~500자)", example = "수정된 내용이에요")
11+
@NotBlank(message = "댓글 내용을 입력해주세요")
12+
@Size(max = 500, message = "댓글은 500자 이하로 입력해주세요")
13+
String content
14+
) {
15+
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package com.devkor.ifive.nadab.domain.comment.api.dto.response;
2+
3+
import io.swagger.v3.oas.annotations.media.Schema;
4+
5+
import java.util.List;
6+
7+
@Schema(description = "댓글/대댓글 목록 응답")
8+
public record CommentListResponse(
9+
10+
@Schema(description = "댓글 목록")
11+
List<CommentResponse> comments,
12+
13+
@Schema(description = "다음 페이지 커서 (없으면 null)")
14+
Long nextCursor,
15+
16+
@Schema(description = "다음 페이지 존재 여부")
17+
boolean hasNext
18+
) {
19+
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
package com.devkor.ifive.nadab.domain.comment.api.dto.response;
2+
3+
import io.swagger.v3.oas.annotations.media.Schema;
4+
5+
import java.time.OffsetDateTime;
6+
7+
@Schema(description = "댓글/대댓글 응답")
8+
public record CommentResponse(
9+
10+
@Schema(description = "댓글 ID")
11+
Long commentId,
12+
13+
@Schema(description = "작성자 프로필 이미지 URL (canViewContent=false이면 null)")
14+
String authorProfileImageUrl,
15+
16+
@Schema(description = "작성자 닉네임 (canViewContent=false이면 null)")
17+
String authorNickname,
18+
19+
@Schema(description = "댓글 내용 (canViewContent=false이면 null)")
20+
String content,
21+
22+
@Schema(description = "작성 시각 (ISO 8601, 예: 2024-05-11T10:30:00+09:00) — 프론트에서 현재 시각 기준으로 '3분 전' 등으로 변환하여 표시")
23+
OffsetDateTime createdAt,
24+
25+
@Schema(description = "내가 좋아요 눌렀는지 여부")
26+
boolean isLiked,
27+
28+
@Schema(description = "좋아요가 1개 이상인지 여부")
29+
boolean hasLikes,
30+
31+
@Schema(description = "보이는 대댓글 수 (canViewContent=false이거나 대댓글에서는 null)")
32+
Integer visibleSubCommentCount,
33+
34+
@Schema(description = "비밀 댓글 여부")
35+
boolean isSecret,
36+
37+
@Schema(description = "비밀 댓글 열람 권한 여부 (false이면 authorProfileImageUrl·authorNickname·content가 null)")
38+
boolean canViewContent,
39+
40+
@Schema(description = "내 댓글 여부")
41+
boolean isMine,
42+
43+
@Schema(description = "삭제 가능 여부 (본인 또는 리포트 당사자)")
44+
boolean canDelete
45+
) {
46+
public static CommentResponse from(
47+
Long commentId,
48+
String authorProfileImageUrl,
49+
String authorNickname,
50+
String content,
51+
OffsetDateTime createdAt,
52+
boolean isLiked,
53+
boolean hasLikes,
54+
Integer visibleSubCommentCount,
55+
boolean isSecret,
56+
boolean canViewContent,
57+
boolean isMine,
58+
boolean canDelete
59+
) {
60+
return new CommentResponse(
61+
commentId,
62+
authorProfileImageUrl,
63+
authorNickname,
64+
content,
65+
createdAt,
66+
isLiked,
67+
hasLikes,
68+
visibleSubCommentCount,
69+
isSecret,
70+
canViewContent,
71+
isMine,
72+
canDelete
73+
);
74+
}
75+
}
Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
package com.devkor.ifive.nadab.domain.comment.application;
2+
3+
import com.devkor.ifive.nadab.domain.comment.application.event.CommentCreatedEvent;
4+
import com.devkor.ifive.nadab.domain.comment.application.event.SubCommentCreatedEvent;
5+
import com.devkor.ifive.nadab.domain.comment.core.entity.Comment;
6+
import com.devkor.ifive.nadab.domain.comment.core.repository.CommentRepository;
7+
import com.devkor.ifive.nadab.domain.dailyreport.core.entity.DailyReport;
8+
import com.devkor.ifive.nadab.domain.dailyreport.core.repository.DailyReportRepository;
9+
import com.devkor.ifive.nadab.domain.friend.core.repository.FriendshipRepository;
10+
import com.devkor.ifive.nadab.domain.moderation.application.SharingSuspensionService;
11+
import com.devkor.ifive.nadab.domain.user.core.entity.User;
12+
import com.devkor.ifive.nadab.domain.user.core.repository.UserRepository;
13+
import com.devkor.ifive.nadab.global.core.response.ErrorCode;
14+
import com.devkor.ifive.nadab.global.exception.BadRequestException;
15+
import com.devkor.ifive.nadab.global.exception.ConflictException;
16+
import com.devkor.ifive.nadab.global.exception.ForbiddenException;
17+
import com.devkor.ifive.nadab.global.exception.NotFoundException;
18+
import lombok.RequiredArgsConstructor;
19+
import org.springframework.context.ApplicationEventPublisher;
20+
import org.springframework.stereotype.Service;
21+
import org.springframework.transaction.annotation.Transactional;
22+
23+
import java.time.OffsetDateTime;
24+
25+
@Service
26+
@RequiredArgsConstructor
27+
@Transactional
28+
public class CommentCommandService {
29+
30+
private final CommentRepository commentRepository;
31+
private final DailyReportRepository dailyReportRepository;
32+
private final FriendshipRepository friendshipRepository;
33+
private final UserRepository userRepository;
34+
private final ApplicationEventPublisher eventPublisher;
35+
private final SharingSuspensionService sharingSuspensionService;
36+
37+
public Long createComment(Long dailyReportId, Long authorId, String content, boolean isSecret) {
38+
checkNotSuspended(authorId);
39+
Long reportOwnerId = dailyReportRepository.findReportOwnerIdById(dailyReportId)
40+
.orElseThrow(() -> new NotFoundException(ErrorCode.DAILY_REPORT_NOT_FOUND));
41+
checkCommentWriteAccess(dailyReportId, reportOwnerId, authorId);
42+
43+
DailyReport dailyReport = dailyReportRepository.getReferenceById(dailyReportId);
44+
User author = userRepository.getReferenceById(authorId);
45+
46+
Comment comment = Comment.createTopLevel(dailyReport, author, content, isSecret);
47+
commentRepository.save(comment);
48+
49+
eventPublisher.publishEvent(
50+
new CommentCreatedEvent(comment.getId(), dailyReportId, authorId, reportOwnerId, content));
51+
52+
return comment.getId();
53+
}
54+
55+
public Long createSubComment(Long parentCommentId, Long authorId, String content, boolean isSecret) {
56+
checkNotSuspended(authorId);
57+
Comment parentComment = findActiveCommentOrThrow(parentCommentId);
58+
59+
if (!parentComment.isTopLevel()) {
60+
throw new BadRequestException(ErrorCode.COMMENT_NOT_TOP_LEVEL);
61+
}
62+
63+
// 비밀 댓글의 하위 대댓글은 강제 비밀 처리
64+
boolean finalIsSecret = parentComment.isSecret() || isSecret;
65+
66+
Long dailyReportId = parentComment.getDailyReport().getId();
67+
Long reportOwnerId = dailyReportRepository.findReportOwnerIdById(dailyReportId)
68+
.orElseThrow(() -> new NotFoundException(ErrorCode.DAILY_REPORT_NOT_FOUND));
69+
checkCommentWriteAccess(dailyReportId, reportOwnerId, authorId);
70+
71+
if (parentComment.isSecret()) {
72+
boolean canViewParent = parentComment.getAuthor().getId().equals(authorId)
73+
|| reportOwnerId.equals(authorId);
74+
if (!canViewParent) {
75+
throw new ForbiddenException(ErrorCode.AUTH_ACCESS_DENIED);
76+
}
77+
}
78+
79+
User author = userRepository.getReferenceById(authorId);
80+
81+
Comment subComment = Comment.createSubComment(author, parentComment, content, finalIsSecret);
82+
commentRepository.save(subComment);
83+
eventPublisher.publishEvent(new SubCommentCreatedEvent(
84+
subComment.getId(),
85+
dailyReportId,
86+
authorId,
87+
parentCommentId,
88+
parentComment.getAuthor().getId(),
89+
reportOwnerId,
90+
content
91+
));
92+
93+
return subComment.getId();
94+
}
95+
96+
private void checkNotSuspended(Long userId) {
97+
if (sharingSuspensionService.isSharingSuspended(userId)) {
98+
throw new BadRequestException(ErrorCode.SOCIAL_SUSPENDED);
99+
}
100+
}
101+
102+
private void checkCommentWriteAccess(Long dailyReportId, Long reportOwnerId, Long currentUserId) {
103+
if (currentUserId.equals(reportOwnerId)) return;
104+
if (!dailyReportRepository.existsByIdAndIsSharedTrue(dailyReportId)) {
105+
throw new ForbiddenException(ErrorCode.AUTH_ACCESS_DENIED);
106+
}
107+
long smallerId = Math.min(currentUserId, reportOwnerId);
108+
long largerId = Math.max(currentUserId, reportOwnerId);
109+
if (!friendshipRepository.existsAcceptedByUserIds(smallerId, largerId)) {
110+
throw new ForbiddenException(ErrorCode.AUTH_ACCESS_DENIED);
111+
}
112+
}
113+
114+
public void updateComment(Long commentId, Long userId, String content) {
115+
checkNotSuspended(userId);
116+
Comment comment = findActiveCommentOrThrow(commentId);
117+
118+
if (!comment.getAuthor().getId().equals(userId)) {
119+
throw new ForbiddenException(ErrorCode.AUTH_ACCESS_DENIED);
120+
}
121+
122+
comment.updateContent(content);
123+
}
124+
125+
public void deleteComment(Long commentId, Long userId) {
126+
checkNotSuspended(userId);
127+
Comment comment = findActiveCommentOrThrow(commentId);
128+
129+
Long authorId = comment.getAuthor().getId();
130+
Long reportOwnerId = dailyReportRepository.findReportOwnerIdById(comment.getDailyReport().getId())
131+
.orElseThrow(() -> new NotFoundException(ErrorCode.DAILY_REPORT_NOT_FOUND));
132+
133+
if (!userId.equals(authorId) && !userId.equals(reportOwnerId)) {
134+
throw new ForbiddenException(ErrorCode.AUTH_ACCESS_DENIED);
135+
}
136+
137+
OffsetDateTime now = OffsetDateTime.now();
138+
if (comment.isTopLevel()) {
139+
commentRepository.softDeleteSubCommentsByParentId(commentId, now);
140+
}
141+
comment.softDelete();
142+
}
143+
144+
private Comment findActiveCommentOrThrow(Long commentId) {
145+
return commentRepository.findByIdWithAuthorAndDailyReport(commentId)
146+
.orElseThrow(() -> commentRepository.existsById(commentId)
147+
? new ConflictException(ErrorCode.COMMENT_DELETED)
148+
: new NotFoundException(ErrorCode.COMMENT_NOT_FOUND));
149+
}
150+
}

0 commit comments

Comments
 (0)