Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
6efef06
feat(coll): 대댓글을 위한 칼럼 추가
pizzazoa Dec 31, 2025
f5d65b0
feat(coll): 댓글 엔티티 수정
pizzazoa Dec 31, 2025
50d6b81
feat(coll): 대댓글 기능을 위해 댓글 작성 요청 body에 comment_id 필드 추가
pizzazoa Dec 31, 2025
5d583c3
feat(entity): 알림에 '댓글에 답글' 액션 및 주제 추가
pizzazoa Jan 1, 2026
d63210d
feat: dsl 모델에도 답글 관련 추가
pizzazoa Jan 1, 2026
bea044b
feat: 타겟이 COMMENT일 때 메타데이터 포팅을 위한 어댑터 추가
pizzazoa Jan 1, 2026
070566d
feat(coll): 댓글 생성 비즈니스 로직 변경
pizzazoa Jan 1, 2026
808a00b
feat(coll): 댓글 삭제 로직 변경
pizzazoa Jan 1, 2026
5a89b78
refactor(coll): 댓글 작성 시 알림 발송 로직에서 코드 중복 제거
pizzazoa Jan 1, 2026
fe72693
feat(coll): 댓글 개수 조회 시 status == ACTIVE만 조회되도록 변경
pizzazoa Jan 1, 2026
096c1a8
feat(coll): 댓글 조회 시 대댓글을 트리 구조로 표현하도록 변경
pizzazoa Jan 1, 2026
1ebccb8
feat: admin에서 댓글 삭제에도 soft delete 적용
pizzazoa Jan 1, 2026
4e6bb85
test: 대댓글 기능 추가에 따른 테스트 수정 및 추가
pizzazoa Jan 1, 2026
f72b4da
fix(coll): 대댓글 작성 시 알림 발송 로직을 수정
pizzazoa Jan 1, 2026
1c8475e
feat(coll): 댓글 상태에 따른 대치 텍스트 설정 추가
pizzazoa Jan 3, 2026
ca3b9e8
feat(coll): 댓글 status에 따른 content 대치 로직 추가
pizzazoa Jan 3, 2026
ca5f3d6
test(coll): 댓글 텍스트 대치 로직 추가에 따른 테스트 수정
pizzazoa Jan 3, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -153,10 +153,15 @@ public void deleteCommentByReport(Long adminUserId, Long reportId, String reason
// 1) 관련 신고 정리
commentReportRepository.deleteByCommentId(commentId);

// 2) 댓글 삭제
// 2) 댓글 삭제 (대댓글이 있으면 soft delete, 없으면 hard delete)
UserBirdCollectionComment comment = commentRepository.findById(commentId)
.orElseThrow(() -> new NotFoundException("해당 댓글이 존재하지 않아요"));
commentRepository.remove(comment);

if (commentRepository.hasReplies(commentId)) {
comment.ban();
} else {
commentRepository.remove(comment);
}

// 3) 감사 기록
User admin = userRepository.findById(adminUserId)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@

public record CreateCollectionCommentRequest(
@Schema(description = "댓글 내용", example = "멋진 사진이네요!", requiredMode = Schema.RequiredMode.REQUIRED)
String content
String content,

@Schema(description = "부모 댓글 ID (대댓글 작성 시)", example = "123", requiredMode = Schema.RequiredMode.NOT_REQUIRED)
Long commentId
) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ public record Item(
String thumbnailProfileImageUrl,
@Schema(description = "댓글 내용", example = "멋진 관찰 기록이네요!", requiredMode = Schema.RequiredMode.REQUIRED)
String content,
@Schema(description = "댓글 상태 (ACTIVE, DELETED, BANNED)", example = "ACTIVE", requiredMode = Schema.RequiredMode.REQUIRED)
String status,
@Schema(description = "부모 댓글 ID (대댓글인 경우)", example = "5", requiredMode = Schema.RequiredMode.NOT_REQUIRED)
Long parentId,
@Schema(description = "좋아요 수", example = "5", requiredMode = Schema.RequiredMode.REQUIRED)
int likeCount,
@Schema(description = "좋아요 눌렀는지 여부", example = "true")
Expand All @@ -33,7 +37,9 @@ public record Item(
@Schema(description = "작성 시각", example = "2025-07-05T03:10:00", requiredMode = Schema.RequiredMode.REQUIRED)
LocalDateTime createdAt,
@Schema(description = "수정 시각", example = "2025-07-05T04:21:00", requiredMode = Schema.RequiredMode.REQUIRED)
LocalDateTime updatedAt
LocalDateTime updatedAt,
@Schema(description = "대댓글 목록", requiredMode = Schema.RequiredMode.REQUIRED)
List<Item> replies
) {

}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,20 +41,69 @@ public CreateCollectionCommentResponse createComment(Long userId,
UserBirdCollection collection = collectionRepository.findById(collectionId)
.orElseThrow(() -> new NotFoundException("해당 id의 컬렉션이 존재하지 않아요"));

UserBirdCollectionComment comment = UserBirdCollectionComment.of(user, collection, req.content());
UserBirdCollectionComment comment;
UserBirdCollectionComment parentComment = null;

if (req.commentId() != null) {
parentComment = commentRepository.findById(req.commentId())
.orElseThrow(() -> new NotFoundException("존재하지 않는 댓글 id예요"));

// 같은 컬렉션에 속하는지 확인
if (!parentComment.getCollection().getId().equals(collectionId)) {
throw new NotFoundException("해당 컬렉션에 속한 댓글이 아니에요");
}

if (!parentComment.isActive()) {
throw new ForbiddenException("삭제된 댓글에는 대댓글을 작성할 수 없어요");
}

// depth 1 제한
if (parentComment.isReply()) {
throw new ForbiddenException("대댓글에는 답글을 작성할 수 없어요");
}

comment = UserBirdCollectionComment.of(user, collection, req.content(), parentComment);
} else {
comment = UserBirdCollectionComment.of(user, collection, req.content());
}

commentRepository.save(comment);

// 자신의 컬렉션이 아닌 경우에만 푸시 알림 발송
if (!collection.getUser().getId().equals(userId)) {
notifyAction
.by(Actor.of(userId, user.getNickname()))
.on(Target.collection(collectionId))
.did(ActionKind.COMMENT)
.comment(req.content())
.to(collection.getUser().getId());

// 알림 전송
if (parentComment != null) {
// 대댓글인 경우
// 1) 원댓글 작성자에게 REPLY 알림
if (!parentComment.getUser().getId().equals(userId)) {
notifyAction
.by(Actor.of(userId, user.getNickname()))
.on(Target.comment(parentComment.getId()))
.did(ActionKind.REPLY)
.comment(req.content())
.to(parentComment.getUser().getId());
}

// 2) 컬렉션 소유자에게 COMMENT 알림 (원댓글 작성자와 다른 경우에만)
if (!collection.getUser().getId().equals(userId)
&& !collection.getUser().getId().equals(parentComment.getUser().getId())) {
notifyAction
.by(Actor.of(userId, user.getNickname()))
.on(Target.collection(collectionId))
.did(ActionKind.COMMENT)
.comment(req.content())
.to(collection.getUser().getId());
}
} else {
// 원댓글인 경우
if (!collection.getUser().getId().equals(userId)) {
notifyAction
.by(Actor.of(userId, user.getNickname()))
.on(Target.collection(collectionId))
.did(ActionKind.COMMENT)
.comment(req.content())
.to(collection.getUser().getId());
}
}

return new CreateCollectionCommentResponse(comment.getId());
}

Expand Down Expand Up @@ -100,6 +149,11 @@ public void deleteComment(Long userId, Long collectionId, Long commentId) {
throw new ForbiddenException("해당 댓글에 대한 삭제 권한이 없어요");
}

commentRepository.remove(comment);
// 대댓글이 있는 경우 soft delete, 없으면 hard delete
if (commentRepository.hasReplies(commentId)) {
comment.softDelete();
} else {
commentRepository.remove(comment);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
import org.devkor.apu.saerok_server.domain.collection.core.repository.CollectionCommentLikeRepository;
import org.devkor.apu.saerok_server.domain.collection.core.repository.CollectionCommentRepository;
import org.devkor.apu.saerok_server.domain.collection.core.repository.CollectionRepository;
import org.devkor.apu.saerok_server.domain.collection.core.service.CommentContentResolver;
import org.devkor.apu.saerok_server.domain.collection.mapper.CollectionCommentWebMapper;
import org.devkor.apu.saerok_server.domain.user.core.entity.User;
import org.devkor.apu.saerok_server.domain.user.core.service.UserProfileImageUrlService;
Expand All @@ -29,6 +30,7 @@ public class CollectionCommentQueryService {
private final CollectionCommentLikeRepository commentLikeRepository;
private final CollectionCommentWebMapper collectionCommentWebMapper;
private final UserProfileImageUrlService userProfileImageUrlService;
private final CommentContentResolver commentContentResolver;

/* 댓글 목록 (createdAt ASC) */
public GetCollectionCommentsResponse getComments(Long collectionId, Long userId) {
Expand Down Expand Up @@ -72,7 +74,7 @@ public GetCollectionCommentsResponse getComments(Long collectionId, Long userId)
Map<Long, String> thumbnailProfileImageUrls = userProfileImageUrlService.getProfileThumbnailImageUrlsFor(users);

// 7. 응답 생성
return collectionCommentWebMapper.toGetCollectionCommentsResponse(comments, likeCounts, likeStatuses, mineStatuses, profileImageUrls, thumbnailProfileImageUrls, isMyCollection);
return collectionCommentWebMapper.toGetCollectionCommentsResponse(comments, likeCounts, likeStatuses, mineStatuses, profileImageUrls, thumbnailProfileImageUrls, isMyCollection, commentContentResolver);
}

/* 댓글 개수 */
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package org.devkor.apu.saerok_server.domain.collection.core.entity;

public enum CommentStatus {
ACTIVE,
DELETED,
BANNED
}
Original file line number Diff line number Diff line change
Expand Up @@ -28,12 +28,45 @@ public class UserBirdCollectionComment extends Auditable {
@Column(name = "content", nullable = false)
private String content;

@Enumerated(EnumType.STRING)
@Column(name = "status", nullable = false, length = 32)
private CommentStatus status = CommentStatus.ACTIVE;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "parent_id")
private UserBirdCollectionComment parent;

public static UserBirdCollectionComment of(User user, UserBirdCollection collection, String content) {
UserBirdCollectionComment comment = new UserBirdCollectionComment();
comment.user = user;
comment.collection = collection;
comment.content = content;
comment.status = CommentStatus.ACTIVE;

return comment;
}

public static UserBirdCollectionComment of(User user, UserBirdCollection collection, String content, UserBirdCollectionComment parent) {
UserBirdCollectionComment comment = of(user, collection, content);
comment.parent = parent;

return comment;
}

public void softDelete() {
this.status = CommentStatus.DELETED;
}

public void ban() {
this.status = CommentStatus.BANNED;
}

public boolean isActive() {
return this.status == CommentStatus.ACTIVE;
}

public boolean isReply() {
return this.parent != null;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,9 @@ public Optional<UserBirdCollectionComment> findById(Long id) {

public List<UserBirdCollectionComment> findByCollectionId(Long collectionId) {
return em.createQuery(
"SELECT c FROM UserBirdCollectionComment c " +
"SELECT DISTINCT c FROM UserBirdCollectionComment c " +
"LEFT JOIN FETCH c.user " +
"LEFT JOIN FETCH c.parent " +
"WHERE c.collection.id = :collectionId " +
"ORDER BY c.createdAt ASC",
UserBirdCollectionComment.class)
Expand All @@ -36,16 +38,27 @@ public List<UserBirdCollectionComment> findByCollectionId(Long collectionId) {

public long countByCollectionId(Long collectionId) {
return em.createQuery(
"SELECT COUNT(c) FROM UserBirdCollectionComment c WHERE c.collection.id = :collectionId",
"SELECT COUNT(c) FROM UserBirdCollectionComment c " +
"WHERE c.collection.id = :collectionId " +
"AND c.status = org.devkor.apu.saerok_server.domain.collection.core.entity.CommentStatus.ACTIVE",
Long.class)
.setParameter("collectionId", collectionId)
.getSingleResult();
}

public boolean hasReplies(Long commentId) {
Long count = em.createQuery(
"SELECT COUNT(c) FROM UserBirdCollectionComment c WHERE c.parent.id = :commentId",
Long.class)
.setParameter("commentId", commentId)
.getSingleResult();
return count > 0;
}

/* ────────────────────────────── 성능 최적화: 배치 메서드 ────────────────────────────── */

/**
* 여러 컬렉션의 댓글 수를 한 번에 조회
* 여러 컬렉션의 ACTIVE 댓글 수를 한 번에 조회
* 반환 맵은 요청한 ID를 모두 포함하며, 없으면 0으로 채운다.
*/
public Map<Long, Long> countByCollectionIds(List<Long> collectionIds) {
Expand All @@ -57,6 +70,7 @@ public Map<Long, Long> countByCollectionIds(List<Long> collectionIds) {
"SELECT c.collection.id, COUNT(c) " +
"FROM UserBirdCollectionComment c " +
"WHERE c.collection.id IN :ids " +
"AND c.status = org.devkor.apu.saerok_server.domain.collection.core.entity.CommentStatus.ACTIVE " +
"GROUP BY c.collection.id",
Object[].class)
.setParameter("ids", collectionIds)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package org.devkor.apu.saerok_server.domain.collection.core.service;

import lombok.RequiredArgsConstructor;
import org.devkor.apu.saerok_server.domain.collection.core.entity.CommentStatus;
import org.devkor.apu.saerok_server.global.core.config.feature.CommentReplacementConfig;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class CommentContentResolver {

private final CommentReplacementConfig commentReplacementConfig;

/**
* 댓글 상태에 따라 적절한 content를 반환합니다.
* - ACTIVE: 원본 content 반환
* - DELETED, BANNED: 설정 파일에 정의된 대체 메시지 반환
*/
public String resolveContent(String originalContent, CommentStatus status) {
String replacement = commentReplacementConfig.getReplacement(status);
return replacement != null ? replacement : originalContent;
}
}
Loading