From 6efef06c4efd3f86e46b0a5011f8e3175625c68d Mon Sep 17 00:00:00 2001 From: pizzazoa Date: Wed, 31 Dec 2025 13:21:12 +0900 Subject: [PATCH 01/17] =?UTF-8?q?feat(coll):=20=EB=8C=80=EB=8C=93=EA=B8=80?= =?UTF-8?q?=EC=9D=84=20=EC=9C=84=ED=95=9C=20=EC=B9=BC=EB=9F=BC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit soft delete와 관리자에 의해 삭제됨을 표현하기 위한 status 필드, depth 1의 대댓글을 표현하기 위한 parent_id 필드를 만들었습니다 --- .../domain/collection/core/entity/CommentStatus.java | 7 +++++++ .../migration/V85__add_reply_support_to_comment.sql | 12 ++++++++++++ 2 files changed, 19 insertions(+) create mode 100644 src/main/java/org/devkor/apu/saerok_server/domain/collection/core/entity/CommentStatus.java create mode 100644 src/main/resources/db/migration/V85__add_reply_support_to_comment.sql diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/collection/core/entity/CommentStatus.java b/src/main/java/org/devkor/apu/saerok_server/domain/collection/core/entity/CommentStatus.java new file mode 100644 index 00000000..1363d518 --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/collection/core/entity/CommentStatus.java @@ -0,0 +1,7 @@ +package org.devkor.apu.saerok_server.domain.collection.core.entity; + +public enum CommentStatus { + ACTIVE, + DELETED, + BANNED +} diff --git a/src/main/resources/db/migration/V85__add_reply_support_to_comment.sql b/src/main/resources/db/migration/V85__add_reply_support_to_comment.sql new file mode 100644 index 00000000..09077398 --- /dev/null +++ b/src/main/resources/db/migration/V85__add_reply_support_to_comment.sql @@ -0,0 +1,12 @@ +-- 댓글 테이블에 status, parent_id 컬럼 추가 +ALTER TABLE user_bird_collection_comment + ADD COLUMN status VARCHAR(32) NOT NULL DEFAULT 'ACTIVE', + ADD COLUMN parent_id BIGINT; + +-- 외래키 제약조건 추가 +ALTER TABLE user_bird_collection_comment + ADD CONSTRAINT fk_user_bird_collection_comment_parent FOREIGN KEY (parent_id) REFERENCES user_bird_collection_comment(id) ON DELETE CASCADE; + +-- 인덱스 생성 +-- 특정 댓글의 대댓글 목록 조회에 이용 +CREATE INDEX idx_user_bird_collection_comment_parent ON user_bird_collection_comment(parent_id); From f5d65b0249e523558165b47c502618c5e3f00017 Mon Sep 17 00:00:00 2001 From: pizzazoa Date: Wed, 31 Dec 2025 13:42:33 +0900 Subject: [PATCH 02/17] =?UTF-8?q?feat(coll):=20=EB=8C=93=EA=B8=80=20?= =?UTF-8?q?=EC=97=94=ED=8B=B0=ED=8B=B0=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../entity/UserBirdCollectionComment.java | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/collection/core/entity/UserBirdCollectionComment.java b/src/main/java/org/devkor/apu/saerok_server/domain/collection/core/entity/UserBirdCollectionComment.java index d1aebc39..9a87c6c7 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/collection/core/entity/UserBirdCollectionComment.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/collection/core/entity/UserBirdCollectionComment.java @@ -28,12 +28,37 @@ 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; + } + } From 50d6b81781049f664c40f7821e9550495249fc0d Mon Sep 17 00:00:00 2001 From: pizzazoa Date: Thu, 1 Jan 2026 01:52:32 +0900 Subject: [PATCH 03/17] =?UTF-8?q?feat(coll):=20=EB=8C=80=EB=8C=93=EA=B8=80?= =?UTF-8?q?=20=EA=B8=B0=EB=8A=A5=EC=9D=84=20=EC=9C=84=ED=95=B4=20=EB=8C=93?= =?UTF-8?q?=EA=B8=80=20=EC=9E=91=EC=84=B1=20=EC=9A=94=EC=B2=AD=20body?= =?UTF-8?q?=EC=97=90=20comment=5Fid=20=ED=95=84=EB=93=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/dto/request/CreateCollectionCommentRequest.java | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/request/CreateCollectionCommentRequest.java b/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/request/CreateCollectionCommentRequest.java index 395f66ba..4473320a 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/request/CreateCollectionCommentRequest.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/request/CreateCollectionCommentRequest.java @@ -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 ) { } From 5d583c37b54d7e798706f28e72ef51605f3ab501 Mon Sep 17 00:00:00 2001 From: pizzazoa Date: Thu, 1 Jan 2026 15:32:16 +0900 Subject: [PATCH 04/17] =?UTF-8?q?feat(entity):=20=EC=95=8C=EB=A6=BC?= =?UTF-8?q?=EC=97=90=20'=EB=8C=93=EA=B8=80=EC=97=90=20=EB=8B=B5=EA=B8=80'?= =?UTF-8?q?=20=EC=95=A1=EC=85=98=20=EB=B0=8F=20=EC=A3=BC=EC=A0=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/notification/core/entity/NotificationAction.java | 1 + .../notification/core/entity/NotificationSubject.java | 1 + .../domain/notification/core/entity/NotificationType.java | 1 + .../notification/core/service/NotificationTypeResolver.java | 6 +++++- src/main/resources/config/notification-messages.yml | 5 +++++ 5 files changed, 13 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/core/entity/NotificationAction.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/core/entity/NotificationAction.java index b1d8e884..e2fddf09 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/notification/core/entity/NotificationAction.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/core/entity/NotificationAction.java @@ -3,5 +3,6 @@ public enum NotificationAction { LIKE, COMMENT, + REPLY, SUGGEST_BIRD_ID } diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/core/entity/NotificationSubject.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/core/entity/NotificationSubject.java index 3550ef0a..e3486dd1 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/notification/core/entity/NotificationSubject.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/core/entity/NotificationSubject.java @@ -2,4 +2,5 @@ public enum NotificationSubject { COLLECTION, // "내 컬렉션에 대한 활동" + COMMENT, // "내 댓글에 대한 활동" } diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/core/entity/NotificationType.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/core/entity/NotificationType.java index d0e29506..e8bda22b 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/notification/core/entity/NotificationType.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/core/entity/NotificationType.java @@ -13,6 +13,7 @@ public enum NotificationType { // ---- Action Notification Types ---- LIKED_ON_COLLECTION, COMMENTED_ON_COLLECTION, + REPLIED_TO_COMMENT, SUGGESTED_BIRD_ID_ON_COLLECTION, // ---- System Notification Types ---- diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/core/service/NotificationTypeResolver.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/core/service/NotificationTypeResolver.java index 7bfa1fbd..56fd0ba3 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/notification/core/service/NotificationTypeResolver.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/core/service/NotificationTypeResolver.java @@ -13,12 +13,16 @@ public static NotificationType from(NotificationSubject subject, NotificationAct // subject/action 기반이 아닌 요청은 허용하지 않는다(그룹 토글 폐기). throw new IllegalArgumentException("subject/action must be non-null to resolve NotificationType"); } - // 현재 지원 케이스: subject == COLLECTION return switch (subject) { case COLLECTION -> switch (action) { case LIKE -> NotificationType.LIKED_ON_COLLECTION; case COMMENT -> NotificationType.COMMENTED_ON_COLLECTION; case SUGGEST_BIRD_ID -> NotificationType.SUGGESTED_BIRD_ID_ON_COLLECTION; + case REPLY -> throw new IllegalArgumentException("REPLY action is not supported for COLLECTION subject"); + }; + case COMMENT -> switch (action) { + case REPLY -> NotificationType.REPLIED_TO_COMMENT; + case LIKE, COMMENT, SUGGEST_BIRD_ID -> throw new IllegalArgumentException(action + " action is not supported for COMMENT subject"); }; }; } diff --git a/src/main/resources/config/notification-messages.yml b/src/main/resources/config/notification-messages.yml index 0868e1b9..445394cd 100644 --- a/src/main/resources/config/notification-messages.yml +++ b/src/main/resources/config/notification-messages.yml @@ -10,6 +10,11 @@ notification-messages: push-body: "나의 새록에 댓글을 남겼어요. \"{comment}\"" batch-push-title: "{actorName} 외 {othersCount}명" batch-push-body: "나의 새록에 댓글을 남겼어요." + REPLIED_TO_COMMENT: + push-title: "{actorName}" + push-body: "나의 댓글에 답글을 남겼어요. \"{comment}\"" + batch-push-title: "{actorName} 외 {othersCount}명" + batch-push-body: "나의 댓글에 답글을 남겼어요." SUGGESTED_BIRD_ID_ON_COLLECTION: push-title: "동정 의견 공유" push-body: "두근두근! 새로운 의견이 공유되었어요. 확인해볼까요?" From d63210d30f53e71186d3532b499a9ac0673928ba Mon Sep 17 00:00:00 2001 From: pizzazoa Date: Thu, 1 Jan 2026 15:35:33 +0900 Subject: [PATCH 05/17] =?UTF-8?q?feat:=20dsl=20=EB=AA=A8=EB=8D=B8=EC=97=90?= =?UTF-8?q?=EB=8F=84=20=EB=8B=B5=EA=B8=80=20=EA=B4=80=EB=A0=A8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/notification/application/facade/NotifyActionDsl.java | 2 ++ .../domain/notification/application/model/dsl/ActionKind.java | 2 +- .../domain/notification/application/model/dsl/Target.java | 1 + .../domain/notification/application/model/dsl/TargetType.java | 1 + 4 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/facade/NotifyActionDsl.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/facade/NotifyActionDsl.java index df7dbf03..e97a282a 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/facade/NotifyActionDsl.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/facade/NotifyActionDsl.java @@ -101,11 +101,13 @@ public StepTo suggestedName(String name) { public void to(Long recipientId) { var notificationSubject = switch (target.type()) { case COLLECTION -> NotificationSubject.COLLECTION; + case COMMENT -> NotificationSubject.COMMENT; }; var notificationAction = switch (action) { case LIKE -> NotificationAction.LIKE; case COMMENT -> NotificationAction.COMMENT; + case REPLY -> NotificationAction.REPLY; case SUGGEST_BIRD_ID -> NotificationAction.SUGGEST_BIRD_ID; }; diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/model/dsl/ActionKind.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/model/dsl/ActionKind.java index 7e2a2fcd..03b3915d 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/model/dsl/ActionKind.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/model/dsl/ActionKind.java @@ -1,5 +1,5 @@ package org.devkor.apu.saerok_server.domain.notification.application.model.dsl; public enum ActionKind { - LIKE, COMMENT, SUGGEST_BIRD_ID + LIKE, COMMENT, REPLY, SUGGEST_BIRD_ID } diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/model/dsl/Target.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/model/dsl/Target.java index 744152ee..29de4cb7 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/model/dsl/Target.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/model/dsl/Target.java @@ -2,4 +2,5 @@ public record Target(TargetType type, Long id) { public static Target collection(Long id) { return new Target(TargetType.COLLECTION, id); } + public static Target comment(Long id) { return new Target(TargetType.COMMENT, id); } } diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/model/dsl/TargetType.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/model/dsl/TargetType.java index 5a872574..d52012cc 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/model/dsl/TargetType.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/model/dsl/TargetType.java @@ -2,4 +2,5 @@ public enum TargetType { COLLECTION, + COMMENT, } From bea044b327140b063278abc062213030d502df5c Mon Sep 17 00:00:00 2001 From: pizzazoa Date: Thu, 1 Jan 2026 15:50:03 +0900 Subject: [PATCH 06/17] =?UTF-8?q?feat:=20=ED=83=80=EA=B2=9F=EC=9D=B4=20COM?= =?UTF-8?q?MENT=EC=9D=BC=20=EB=95=8C=20=EB=A9=94=ED=83=80=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=ED=8F=AC=ED=8C=85=EC=9D=84=20=EC=9C=84?= =?UTF-8?q?=ED=95=9C=20=EC=96=B4=EB=8C=91=ED=84=B0=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../adapter/CommentTargetMetadataAdapter.java | 49 +++++++++++++++++++ .../DelegatingTargetMetadataAdapter.java | 29 +++++++++++ 2 files changed, 78 insertions(+) create mode 100644 src/main/java/org/devkor/apu/saerok_server/domain/notification/application/adapter/CommentTargetMetadataAdapter.java create mode 100644 src/main/java/org/devkor/apu/saerok_server/domain/notification/application/adapter/DelegatingTargetMetadataAdapter.java diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/adapter/CommentTargetMetadataAdapter.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/adapter/CommentTargetMetadataAdapter.java new file mode 100644 index 00000000..a6c7f201 --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/adapter/CommentTargetMetadataAdapter.java @@ -0,0 +1,49 @@ +package org.devkor.apu.saerok_server.domain.notification.application.adapter; + +import lombok.RequiredArgsConstructor; +import org.devkor.apu.saerok_server.domain.collection.application.helper.CollectionImageUrlService; +import org.devkor.apu.saerok_server.domain.collection.core.repository.CollectionCommentRepository; +import org.devkor.apu.saerok_server.domain.notification.application.model.dsl.Target; +import org.devkor.apu.saerok_server.domain.notification.application.model.dsl.TargetType; +import org.devkor.apu.saerok_server.domain.notification.application.port.TargetMetadataPort; +import org.springframework.stereotype.Component; + +import java.util.HashMap; +import java.util.Map; + +/** + * TargetType.COMMENT 전용 메타데이터 어댑터.
+ * - extras.commentId
+ * - extras.collectionId (댓글이 속한 컬렉션)
+ * - extras.collectionImageUrl (없으면 null 넣어 키 유지) + */ +@Component +@RequiredArgsConstructor +public class CommentTargetMetadataAdapter implements TargetMetadataPort { + + private final CollectionCommentRepository commentRepository; + private final CollectionImageUrlService collectionImageUrlService; + + @Override + public Map enrich(Target target, Map baseExtras) { + if (target.type() != TargetType.COMMENT) { + return baseExtras != null ? baseExtras : Map.of(); + } + + Map extras = baseExtras != null ? new HashMap<>(baseExtras) : new HashMap<>(); + extras.put("commentId", target.id()); + + // 댓글의 컬렉션 정보 추가 + commentRepository.findById(target.id()).ifPresent(comment -> { + Long collectionId = comment.getCollection().getId(); + extras.put("collectionId", collectionId); + + String imageUrl = collectionImageUrlService + .getPrimaryImageThumbnailUrlFor(comment.getCollection()) + .orElse(null); + extras.put("collectionImageUrl", imageUrl); + }); + + return extras; + } +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/adapter/DelegatingTargetMetadataAdapter.java b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/adapter/DelegatingTargetMetadataAdapter.java new file mode 100644 index 00000000..741ac715 --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/notification/application/adapter/DelegatingTargetMetadataAdapter.java @@ -0,0 +1,29 @@ +package org.devkor.apu.saerok_server.domain.notification.application.adapter; + +import lombok.RequiredArgsConstructor; +import org.devkor.apu.saerok_server.domain.notification.application.model.dsl.Target; +import org.devkor.apu.saerok_server.domain.notification.application.port.TargetMetadataPort; +import org.springframework.context.annotation.Primary; +import org.springframework.stereotype.Component; + +import java.util.Map; + +/** + * Target 타입에 따라 적절한 어댑터에게 위임하는 Delegating 어댑터. + */ +@Primary +@Component +@RequiredArgsConstructor +public class DelegatingTargetMetadataAdapter implements TargetMetadataPort { + + private final CollectionTargetMetadataAdapter collectionAdapter; + private final CommentTargetMetadataAdapter commentAdapter; + + @Override + public Map enrich(Target target, Map baseExtras) { + return switch (target.type()) { + case COLLECTION -> collectionAdapter.enrich(target, baseExtras); + case COMMENT -> commentAdapter.enrich(target, baseExtras); + }; + } +} From 070566d13a620e0ee6f9244dcdb110412c5a486c Mon Sep 17 00:00:00 2001 From: pizzazoa Date: Thu, 1 Jan 2026 15:54:25 +0900 Subject: [PATCH 07/17] =?UTF-8?q?feat(coll):=20=EB=8C=93=EA=B8=80=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EB=B9=84=EC=A6=88=EB=8B=88=EC=8A=A4=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 대댓글 생성 분기 추가 --- .../CollectionCommentCommandService.java | 67 ++++++++++++++++--- .../entity/UserBirdCollectionComment.java | 8 +++ 2 files changed, 65 insertions(+), 10 deletions(-) diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionCommentCommandService.java b/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionCommentCommandService.java index e119b2db..0b7c376b 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionCommentCommandService.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionCommentCommandService.java @@ -41,18 +41,65 @@ 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)) { + 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()); diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/collection/core/entity/UserBirdCollectionComment.java b/src/main/java/org/devkor/apu/saerok_server/domain/collection/core/entity/UserBirdCollectionComment.java index 9a87c6c7..24546335 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/collection/core/entity/UserBirdCollectionComment.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/collection/core/entity/UserBirdCollectionComment.java @@ -61,4 +61,12 @@ public void ban() { this.status = CommentStatus.BANNED; } + public boolean isActive() { + return this.status == CommentStatus.ACTIVE; + } + + public boolean isReply() { + return this.parent != null; + } + } From 808a00bffed9fc1ca6bcd634664859310b649604 Mon Sep 17 00:00:00 2001 From: pizzazoa Date: Thu, 1 Jan 2026 15:56:32 +0900 Subject: [PATCH 08/17] =?UTF-8?q?feat(coll):=20=EB=8C=93=EA=B8=80=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20=EB=A1=9C=EC=A7=81=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 대댓글이 없을 경우(즉, 원댓글인데 대댓글이 없거나 대댓글일 경우 - depth 1 제한이라 대댓글엔 대댓글이 없음)에는 hard delete, 그 외에는 soft delete --- .../application/CollectionCommentCommandService.java | 7 ++++++- .../core/repository/CollectionCommentRepository.java | 9 +++++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionCommentCommandService.java b/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionCommentCommandService.java index 0b7c376b..db504400 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionCommentCommandService.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionCommentCommandService.java @@ -147,6 +147,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); + } } } \ No newline at end of file diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/collection/core/repository/CollectionCommentRepository.java b/src/main/java/org/devkor/apu/saerok_server/domain/collection/core/repository/CollectionCommentRepository.java index ec4fba23..beb99318 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/collection/core/repository/CollectionCommentRepository.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/collection/core/repository/CollectionCommentRepository.java @@ -42,6 +42,15 @@ public long countByCollectionId(Long 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; + } + /* ────────────────────────────── 성능 최적화: 배치 메서드 ────────────────────────────── */ /** From 5a89b789e0d0c66334b6cad133ee4f105dbd68af Mon Sep 17 00:00:00 2001 From: pizzazoa Date: Thu, 1 Jan 2026 16:08:08 +0900 Subject: [PATCH 09/17] =?UTF-8?q?refactor(coll):=20=EB=8C=93=EA=B8=80=20?= =?UTF-8?q?=EC=9E=91=EC=84=B1=20=EC=8B=9C=20=EC=95=8C=EB=A6=BC=20=EB=B0=9C?= =?UTF-8?q?=EC=86=A1=20=EB=A1=9C=EC=A7=81=EC=97=90=EC=84=9C=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=20=EC=A4=91=EB=B3=B5=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CollectionCommentCommandService.java | 46 +++++++------------ 1 file changed, 17 insertions(+), 29 deletions(-) diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionCommentCommandService.java b/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionCommentCommandService.java index db504400..017e7c8c 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionCommentCommandService.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionCommentCommandService.java @@ -70,36 +70,24 @@ public CreateCollectionCommentResponse createComment(Long userId, commentRepository.save(comment); // 알림 전송 - if (parentComment != null) { - // 1. 원댓글 작성자에게 REPLY 알림 (자신의 댓글이 아닌 경우) - if (!parentComment.getUser().getId().equals(userId)) { - notifyAction - .by(Actor.of(userId, user.getNickname())) - .on(Target.comment(parentComment.getId())) - .did(ActionKind.REPLY) - .comment(req.content()) - .to(parentComment.getUser().getId()); - } + // 대댓글인 경우: 원댓글 작성자에게 REPLY 알림 + if (parentComment != null && !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)) { - 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()); - } + // 컬렉션 작성자에게 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()); } return new CreateCollectionCommentResponse(comment.getId()); From fe726936412c3f7b238495ea2d5af5e5cf6846c1 Mon Sep 17 00:00:00 2001 From: pizzazoa Date: Thu, 1 Jan 2026 17:05:49 +0900 Subject: [PATCH 10/17] =?UTF-8?q?feat(coll):=20=EB=8C=93=EA=B8=80=20?= =?UTF-8?q?=EA=B0=9C=EC=88=98=20=EC=A1=B0=ED=9A=8C=20=EC=8B=9C=20status=20?= =?UTF-8?q?=3D=3D=20ACTIVE=EB=A7=8C=20=EC=A1=B0=ED=9A=8C=EB=90=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 삭제나 밴 당한 댓글은 셀 필요 없으므로 --- .../core/repository/CollectionCommentRepository.java | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/collection/core/repository/CollectionCommentRepository.java b/src/main/java/org/devkor/apu/saerok_server/domain/collection/core/repository/CollectionCommentRepository.java index beb99318..307da50c 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/collection/core/repository/CollectionCommentRepository.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/collection/core/repository/CollectionCommentRepository.java @@ -36,7 +36,9 @@ public List 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(); @@ -54,7 +56,7 @@ public boolean hasReplies(Long commentId) { /* ────────────────────────────── 성능 최적화: 배치 메서드 ────────────────────────────── */ /** - * 여러 컬렉션의 댓글 수를 한 번에 조회 + * 여러 컬렉션의 ACTIVE 댓글 수를 한 번에 조회 * 반환 맵은 요청한 ID를 모두 포함하며, 없으면 0으로 채운다. */ public Map countByCollectionIds(List collectionIds) { @@ -66,6 +68,7 @@ public Map countByCollectionIds(List 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) From 096c1a8c5c09b1bf2121017865b598289b1adc88 Mon Sep 17 00:00:00 2001 From: pizzazoa Date: Thu, 1 Jan 2026 17:41:17 +0900 Subject: [PATCH 11/17] =?UTF-8?q?feat(coll):=20=EB=8C=93=EA=B8=80=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EC=8B=9C=20=EB=8C=80=EB=8C=93=EA=B8=80?= =?UTF-8?q?=EC=9D=84=20=ED=8A=B8=EB=A6=AC=20=EA=B5=AC=EC=A1=B0=EB=A1=9C=20?= =?UTF-8?q?=ED=91=9C=ED=98=84=ED=95=98=EB=8F=84=EB=A1=9D=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../GetCollectionCommentsResponse.java | 8 ++- .../CollectionCommentRepository.java | 4 +- .../mapper/CollectionCommentWebMapper.java | 62 ++++++++++++++----- 3 files changed, 55 insertions(+), 19 deletions(-) diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/response/GetCollectionCommentsResponse.java b/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/response/GetCollectionCommentsResponse.java index bf94222d..847f8854 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/response/GetCollectionCommentsResponse.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/collection/api/dto/response/GetCollectionCommentsResponse.java @@ -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") @@ -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 replies ) { } diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/collection/core/repository/CollectionCommentRepository.java b/src/main/java/org/devkor/apu/saerok_server/domain/collection/core/repository/CollectionCommentRepository.java index 307da50c..86266255 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/collection/core/repository/CollectionCommentRepository.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/collection/core/repository/CollectionCommentRepository.java @@ -26,7 +26,9 @@ public Optional findById(Long id) { public List 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) diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/collection/mapper/CollectionCommentWebMapper.java b/src/main/java/org/devkor/apu/saerok_server/domain/collection/mapper/CollectionCommentWebMapper.java index a4a8456c..7b4028c5 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/collection/mapper/CollectionCommentWebMapper.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/collection/mapper/CollectionCommentWebMapper.java @@ -12,9 +12,9 @@ @Mapper(componentModel = MappingConstants.ComponentModel.SPRING) public interface CollectionCommentWebMapper { - /* 엔티티 목록 → 래핑된 응답 */ + /* 엔티티 목록 → 래핑된 응답 (원댓글 + 대댓글 트리 구조) */ default GetCollectionCommentsResponse toGetCollectionCommentsResponse( - List entities, + List entities, Map likeCounts, Map likeStatuses, Map mineStatuses, @@ -47,36 +47,64 @@ default GetCollectionCommentsResponse toGetCollectionCommentsResponse( throw new IllegalStateException("thumbnailProfileImageUrls에 사용자 ID " + userId + "가 없습니다."); } } - - List items = entities.stream() + + // 1. 원댓글과 대댓글 분리 + List rootComments = entities.stream() + .filter(c -> c.getParent() == null) + .toList(); + + Map> repliesByParentId = entities.stream() + .filter(c -> c.getParent() != null) + .collect(java.util.stream.Collectors.groupingBy(c -> c.getParent().getId())); + + // 2. 원댓글을 Item으로 변환하고 대댓글 추가 + List items = rootComments.stream() .map(comment -> { - Long commentId = comment.getId(); - Long userId = comment.getUser().getId(); - int likeCount = likeCounts.get(commentId).intValue(); - Boolean isLiked = likeStatuses.get(commentId); - Boolean isMine = mineStatuses.get(commentId); - String profileImageUrl = profileImageUrls.get(userId); - String thumbnailProfileImageUrl = thumbnailProfileImageUrls.get(userId); - return toCommentItem(comment, likeCount, isLiked, isMine, profileImageUrl, thumbnailProfileImageUrl); + // 대댓글 목록 생성 + List replies = repliesByParentId.getOrDefault(comment.getId(), List.of()) + .stream() + .sorted(java.util.Comparator.comparing(UserBirdCollectionComment::getCreatedAt)) + .map(reply -> buildCommentItem(reply, likeCounts, likeStatuses, mineStatuses, profileImageUrls, thumbnailProfileImageUrls, List.of())) + .toList(); + + return buildCommentItem(comment, likeCounts, likeStatuses, mineStatuses, profileImageUrls, thumbnailProfileImageUrls, replies); }) .toList(); return new GetCollectionCommentsResponse(items, isMyCollection); } - /* 단일 엔티티 → Item DTO */ - default GetCollectionCommentsResponse.Item toCommentItem(UserBirdCollectionComment c, int likeCount, Boolean isLiked, Boolean isMine, String profileImageUrl, String thumbnailProfileImageUrl) { + /* 댓글 엔티티 → Item DTO (공통 매핑 로직) */ + private GetCollectionCommentsResponse.Item buildCommentItem( + UserBirdCollectionComment c, + Map likeCounts, + Map likeStatuses, + Map mineStatuses, + Map profileImageUrls, + Map thumbnailProfileImageUrls, + List replies) { + Long commentId = c.getId(); + Long userId = c.getUser().getId(); + int likeCount = likeCounts.get(commentId).intValue(); + Boolean isLiked = likeStatuses.get(commentId); + Boolean isMine = mineStatuses.get(commentId); + String profileImageUrl = profileImageUrls.get(userId); + String thumbnailProfileImageUrl = thumbnailProfileImageUrls.get(userId); + return new GetCollectionCommentsResponse.Item( - c.getId(), - c.getUser().getId(), + commentId, + userId, c.getUser().getNickname(), profileImageUrl, thumbnailProfileImageUrl, c.getContent(), + c.getStatus().name(), + c.getParent() != null ? c.getParent().getId() : null, likeCount, isLiked, isMine, OffsetDateTimeLocalizer.toSeoulLocalDateTime(c.getCreatedAt()), - OffsetDateTimeLocalizer.toSeoulLocalDateTime(c.getUpdatedAt()) + OffsetDateTimeLocalizer.toSeoulLocalDateTime(c.getUpdatedAt()), + replies ); } } \ No newline at end of file From 1ebccb8780c120bcffe48694d15b1a8744f8eea0 Mon Sep 17 00:00:00 2001 From: pizzazoa Date: Thu, 1 Jan 2026 17:42:10 +0900 Subject: [PATCH 12/17] =?UTF-8?q?feat:=20admin=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EB=8C=93=EA=B8=80=20=EC=82=AD=EC=A0=9C=EC=97=90=EB=8F=84=20sof?= =?UTF-8?q?t=20delete=20=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 추후 삭제한 것과 신고로 밴된 것의 차별을 두기 위해 status = BANNED 적용 --- .../report/application/AdminReportCommandService.java | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/admin/report/application/AdminReportCommandService.java b/src/main/java/org/devkor/apu/saerok_server/domain/admin/report/application/AdminReportCommandService.java index 3391bd8b..a93b10aa 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/admin/report/application/AdminReportCommandService.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/admin/report/application/AdminReportCommandService.java @@ -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) From 4e6bb85d2b19fa55de397887420f8168816ecef3 Mon Sep 17 00:00:00 2001 From: pizzazoa Date: Thu, 1 Jan 2026 19:18:10 +0900 Subject: [PATCH 13/17] =?UTF-8?q?test:=20=EB=8C=80=EB=8C=93=EA=B8=80=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80=EC=97=90=20=EB=94=B0?= =?UTF-8?q?=EB=A5=B8=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=88=98=EC=A0=95=20?= =?UTF-8?q?=EB=B0=8F=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AdminReportCommandServiceTest.java | 34 ++- .../CollectionCommentCommandServiceTest.java | 209 +++++++++++++++++- .../CollectionCommentRepositoryTest.java | 85 +++++++ 3 files changed, 321 insertions(+), 7 deletions(-) diff --git a/src/test/java/org/devkor/apu/saerok_server/domain/admin/application/AdminReportCommandServiceTest.java b/src/test/java/org/devkor/apu/saerok_server/domain/admin/application/AdminReportCommandServiceTest.java index 37a6cf17..2f57a1ef 100644 --- a/src/test/java/org/devkor/apu/saerok_server/domain/admin/application/AdminReportCommandServiceTest.java +++ b/src/test/java/org/devkor/apu/saerok_server/domain/admin/application/AdminReportCommandServiceTest.java @@ -192,14 +192,15 @@ void deleteCollectionByReport_notFound() { } @Test - @DisplayName("deleteCommentByReport: 삭제 + 신고 정리 + 감사 로그(reason 포함)") - void deleteCommentByReport_success() { + @DisplayName("deleteCommentByReport: 대댓글 없는 경우 → hard delete + 신고 정리 + 감사 로그(reason 포함)") + void deleteCommentByReport_noReplies_hardDelete() { long reportId = 70L; long cmId = 900L; long colId = 10L; var rep = commentReport(reportId, cmId, colId, 11L, 21L, "bye"); when(commentReportRepository.findById(reportId)).thenReturn(Optional.of(rep)); UserBirdCollectionComment cm = UserBirdCollectionComment.of(user(21L), collection(colId), "bye"); ReflectionTestUtils.setField(cm, "id", cmId); when(commentRepository.findById(cmId)).thenReturn(Optional.of(cm)); + when(commentRepository.hasReplies(cmId)).thenReturn(false); stubAdminUser(); ArgumentCaptor cap = ArgumentCaptor.forClass(AdminAuditLog.class); @@ -208,6 +209,7 @@ void deleteCommentByReport_success() { verify(commentReportRepository).deleteByCommentId(cmId); verify(commentRepository).remove(cm); + assertThat(cm.getStatus()).isEqualTo(org.devkor.apu.saerok_server.domain.collection.core.entity.CommentStatus.ACTIVE); verify(adminAuditLogRepository).save(cap.capture()); AdminAuditLog log = cap.getValue(); @@ -217,6 +219,34 @@ void deleteCommentByReport_success() { assertThat(md.get("commentContentSnapshot")).isEqualTo("bye"); } + @Test + @DisplayName("deleteCommentByReport: 대댓글 있는 경우 → soft delete(ban) + 신고 정리 + 감사 로그") + void deleteCommentByReport_hasReplies_softDelete() { + long reportId = 71L; long cmId = 901L; long colId = 11L; + var rep = commentReport(reportId, cmId, colId, 12L, 22L, "parent comment"); + when(commentReportRepository.findById(reportId)).thenReturn(Optional.of(rep)); + UserBirdCollectionComment cm = UserBirdCollectionComment.of(user(22L), collection(colId), "parent comment"); + ReflectionTestUtils.setField(cm, "id", cmId); + when(commentRepository.findById(cmId)).thenReturn(Optional.of(cm)); + when(commentRepository.hasReplies(cmId)).thenReturn(true); + stubAdminUser(); + + ArgumentCaptor cap = ArgumentCaptor.forClass(AdminAuditLog.class); + + sut.deleteCommentByReport(ADMIN_ID, reportId, REASON); + + verify(commentReportRepository).deleteByCommentId(cmId); + verify(commentRepository, never()).remove(any()); // remove 호출 안 함 + assertThat(cm.getStatus()).isEqualTo(org.devkor.apu.saerok_server.domain.collection.core.entity.CommentStatus.BANNED); + verify(adminAuditLogRepository).save(cap.capture()); + + AdminAuditLog log = cap.getValue(); + assertThat(log.getAction()).isEqualTo(AdminAuditAction.COMMENT_DELETED); + Map md = log.getMetadata(); + assertThat(md.get("reason")).isEqualTo(REASON); + assertThat(md.get("commentContentSnapshot")).isEqualTo("parent comment"); + } + @Test @DisplayName("deleteCommentByReport: 신고 없음 → 404") void deleteCommentByReport_notFound() { diff --git a/src/test/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionCommentCommandServiceTest.java b/src/test/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionCommentCommandServiceTest.java index a0937e0c..86491c3e 100644 --- a/src/test/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionCommentCommandServiceTest.java +++ b/src/test/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionCommentCommandServiceTest.java @@ -84,6 +84,10 @@ void init() { if (target.type() == TargetType.COLLECTION) { extras.put("collectionId", target.id()); extras.put("collectionImageUrl", "dummy"); + } else if (target.type() == TargetType.COMMENT) { + extras.put("commentId", target.id()); + extras.put("collectionId", 999L); // dummy collection id + extras.put("collectionImageUrl", "dummy"); } return extras; } @@ -112,7 +116,7 @@ void success() { doAnswer(inv -> { setField((Object) inv.getArgument(0), "id", COMMENT_ID); return null; }) .when(commentRepo).save(any()); - var res = sut.createComment(OWNER_ID, COLL_ID, new CreateCollectionCommentRequest("Nice")); + var res = sut.createComment(OWNER_ID, COLL_ID, new CreateCollectionCommentRequest("Nice", null)); assertThat(res.commentId()).isEqualTo(COMMENT_ID); verify(commentRepo).save(any()); @@ -135,7 +139,7 @@ void success() { void userNotFound() { when(userRepo.findById(OWNER_ID)).thenReturn(Optional.empty()); assertThatThrownBy(() -> - sut.createComment(OWNER_ID, COLL_ID, new CreateCollectionCommentRequest("x"))) + sut.createComment(OWNER_ID, COLL_ID, new CreateCollectionCommentRequest("x", null))) .isExactlyInstanceOf(NotFoundException.class); } @@ -150,12 +154,158 @@ void ownCollectionComment_noPush() { doAnswer(inv -> { setField((Object) inv.getArgument(0), "id", COMMENT_ID); return null; }) .when(commentRepo).save(any()); - var res = sut.createComment(OWNER_ID, COLL_ID, new CreateCollectionCommentRequest("Self comment")); + var res = sut.createComment(OWNER_ID, COLL_ID, new CreateCollectionCommentRequest("Self comment", null)); assertThat(res.commentId()).isEqualTo(COMMENT_ID); verify(commentRepo).save(any()); verifyNoInteractions(publisher); } + + @Test @DisplayName("대댓글 작성 성공 - 원댓글 작성자와 컬렉션 소유자 모두 다른 경우 (2개 알림)") + void createReply_success_twoNotifications() { + long commenterId = 1L; + long parentCommentOwnerId = 2L; + long collectionOwnerId = 3L; + long parentCommentId = 200L; + long replyId = 300L; + + User commenter = user(commenterId); + User parentCommentOwner = user(parentCommentOwnerId); + User collectionOwner = user(collectionOwnerId); + UserBirdCollection coll = collection(COLL_ID, collectionOwner); + UserBirdCollectionComment parentComment = comment(parentCommentId, parentCommentOwner, coll, "parent"); + + setField(commenter, "nickname", "replier"); + + when(userRepo.findById(commenterId)).thenReturn(Optional.of(commenter)); + when(collectionRepo.findById(COLL_ID)).thenReturn(Optional.of(coll)); + when(commentRepo.findById(parentCommentId)).thenReturn(Optional.of(parentComment)); + + doAnswer(inv -> { setField((Object) inv.getArgument(0), "id", replyId); return null; }) + .when(commentRepo).save(any()); + + var res = sut.createComment(commenterId, COLL_ID, new CreateCollectionCommentRequest("reply content", parentCommentId)); + + assertThat(res.commentId()).isEqualTo(replyId); + verify(commentRepo).save(any()); + + ArgumentCaptor payloadCap = ArgumentCaptor.forClass(NotificationPayload.class); + verify(publisher, times(2)).push(payloadCap.capture()); + + var notifications = payloadCap.getAllValues(); + + // 첫 번째 알림: 원댓글 작성자에게 REPLY 알림 + ActionNotificationPayload replyNotif = (ActionNotificationPayload) notifications.get(0); + assertThat(replyNotif.subject()).isEqualTo(NotificationSubject.COMMENT); + assertThat(replyNotif.action()).isEqualTo(NotificationAction.REPLY); + assertThat(replyNotif.recipientId()).isEqualTo(parentCommentOwnerId); + assertThat(replyNotif.actorId()).isEqualTo(commenterId); + + // 두 번째 알림: 컬렉션 소유자에게 COMMENT 알림 + ActionNotificationPayload commentNotif = (ActionNotificationPayload) notifications.get(1); + assertThat(commentNotif.subject()).isEqualTo(NotificationSubject.COLLECTION); + assertThat(commentNotif.action()).isEqualTo(NotificationAction.COMMENT); + assertThat(commentNotif.recipientId()).isEqualTo(collectionOwnerId); + assertThat(commentNotif.actorId()).isEqualTo(commenterId); + } + + @Test @DisplayName("대댓글 작성 성공 - 원댓글 작성자 = 컬렉션 소유자인 경우 (1개 알림)") + void createReply_success_oneNotification() { + long commenterId = 1L; + long parentAndCollectionOwnerId = 2L; + long parentCommentId = 200L; + long replyId = 300L; + + User commenter = user(commenterId); + User owner = user(parentAndCollectionOwnerId); + UserBirdCollection coll = collection(COLL_ID, owner); + UserBirdCollectionComment parentComment = comment(parentCommentId, owner, coll, "parent"); + + setField(commenter, "nickname", "replier"); + + when(userRepo.findById(commenterId)).thenReturn(Optional.of(commenter)); + when(collectionRepo.findById(COLL_ID)).thenReturn(Optional.of(coll)); + when(commentRepo.findById(parentCommentId)).thenReturn(Optional.of(parentComment)); + + doAnswer(inv -> { setField((Object) inv.getArgument(0), "id", replyId); return null; }) + .when(commentRepo).save(any()); + + var res = sut.createComment(commenterId, COLL_ID, new CreateCollectionCommentRequest("reply", parentCommentId)); + + assertThat(res.commentId()).isEqualTo(replyId); + + ArgumentCaptor payloadCap = ArgumentCaptor.forClass(NotificationPayload.class); + verify(publisher, times(1)).push(payloadCap.capture()); + + // 원댓글 작성자에게만 REPLY 알림 (컬렉션 소유자와 동일인이므로 중복 제거됨) + ActionNotificationPayload notif = (ActionNotificationPayload) payloadCap.getValue(); + assertThat(notif.subject()).isEqualTo(NotificationSubject.COMMENT); + assertThat(notif.action()).isEqualTo(NotificationAction.REPLY); + assertThat(notif.recipientId()).isEqualTo(parentAndCollectionOwnerId); + } + + @Test @DisplayName("삭제된 댓글에 대댓글 작성 → ForbiddenException") + void createReply_deletedParent_forbidden() { + long parentCommentId = 200L; + User commenter = user(OWNER_ID); + User owner = user(OTHER_ID); + UserBirdCollection coll = collection(COLL_ID, owner); + UserBirdCollectionComment parentComment = comment(parentCommentId, owner, coll, "deleted"); + parentComment.softDelete(); + + when(userRepo.findById(OWNER_ID)).thenReturn(Optional.of(commenter)); + when(collectionRepo.findById(COLL_ID)).thenReturn(Optional.of(coll)); + when(commentRepo.findById(parentCommentId)).thenReturn(Optional.of(parentComment)); + + assertThatThrownBy(() -> + sut.createComment(OWNER_ID, COLL_ID, new CreateCollectionCommentRequest("reply", parentCommentId))) + .isExactlyInstanceOf(ForbiddenException.class) + .hasMessageContaining("삭제된 댓글"); + } + + @Test @DisplayName("대댓글에 대댓글 작성 (depth 제한) → ForbiddenException") + void createReply_depthLimit_forbidden() { + long rootCommentId = 100L; + long replyCommentId = 200L; + + User commenter = user(OWNER_ID); + User owner = user(OTHER_ID); + UserBirdCollection coll = collection(COLL_ID, owner); + + UserBirdCollectionComment rootComment = comment(rootCommentId, owner, coll, "root"); + UserBirdCollectionComment replyComment = UserBirdCollectionComment.of(owner, coll, "reply", rootComment); + setField(replyComment, "id", replyCommentId); + + when(userRepo.findById(OWNER_ID)).thenReturn(Optional.of(commenter)); + when(collectionRepo.findById(COLL_ID)).thenReturn(Optional.of(coll)); + when(commentRepo.findById(replyCommentId)).thenReturn(Optional.of(replyComment)); + + assertThatThrownBy(() -> + sut.createComment(OWNER_ID, COLL_ID, new CreateCollectionCommentRequest("nested reply", replyCommentId))) + .isExactlyInstanceOf(ForbiddenException.class) + .hasMessageContaining("대댓글"); + } + + @Test @DisplayName("다른 컬렉션의 댓글에 대댓글 작성 → NotFoundException") + void createReply_differentCollection_notFound() { + long parentCommentId = 200L; + long otherCollectionId = 999L; + + User commenter = user(OWNER_ID); + User owner = user(OTHER_ID); + UserBirdCollection coll = collection(COLL_ID, owner); + UserBirdCollection otherColl = collection(otherCollectionId, owner); + UserBirdCollectionComment parentComment = comment(parentCommentId, owner, otherColl, "parent"); + + when(userRepo.findById(OWNER_ID)).thenReturn(Optional.of(commenter)); + when(collectionRepo.findById(COLL_ID)).thenReturn(Optional.of(coll)); + when(commentRepo.findById(parentCommentId)).thenReturn(Optional.of(parentComment)); + + assertThatThrownBy(() -> + sut.createComment(OWNER_ID, COLL_ID, new CreateCollectionCommentRequest("reply", parentCommentId))) + .isExactlyInstanceOf(NotFoundException.class) + .hasMessageContaining("컬렉션"); + } } @Nested @DisplayName("댓글 수정") @@ -191,15 +341,64 @@ void forbidden() { @Nested @DisplayName("댓글 삭제") class Delete { - @Test @DisplayName("본인 댓글 삭제 성공") - void successByCommentOwner() { + @Test @DisplayName("대댓글 없는 댓글 삭제 → hard delete") + void delete_noReplies_hardDelete() { User owner = user(OWNER_ID); UserBirdCollection coll = collection(COLL_ID, owner); UserBirdCollectionComment cm = comment(COMMENT_ID, owner, coll, "bye"); when(commentRepo.findById(COMMENT_ID)).thenReturn(Optional.of(cm)); + when(commentRepo.hasReplies(COMMENT_ID)).thenReturn(false); + + sut.deleteComment(OWNER_ID, COLL_ID, COMMENT_ID); + + verify(commentRepo).remove(cm); + assertThat(cm.getStatus()).isNotEqualTo(org.devkor.apu.saerok_server.domain.collection.core.entity.CommentStatus.DELETED); + } + + @Test @DisplayName("대댓글 있는 댓글 삭제 → soft delete") + void delete_hasReplies_softDelete() { + User owner = user(OWNER_ID); + UserBirdCollection coll = collection(COLL_ID, owner); + UserBirdCollectionComment cm = comment(COMMENT_ID, owner, coll, "parent with replies"); + + when(commentRepo.findById(COMMENT_ID)).thenReturn(Optional.of(cm)); + when(commentRepo.hasReplies(COMMENT_ID)).thenReturn(true); sut.deleteComment(OWNER_ID, COLL_ID, COMMENT_ID); + + verify(commentRepo, never()).remove(any()); + assertThat(cm.getStatus()).isEqualTo(org.devkor.apu.saerok_server.domain.collection.core.entity.CommentStatus.DELETED); + } + + @Test @DisplayName("컬렉션 소유자가 남의 댓글 삭제 성공") + void delete_byCollectionOwner() { + User collectionOwner = user(OWNER_ID); + User commentOwner = user(OTHER_ID); + UserBirdCollection coll = collection(COLL_ID, collectionOwner); + UserBirdCollectionComment cm = comment(COMMENT_ID, commentOwner, coll, "others comment"); + + when(commentRepo.findById(COMMENT_ID)).thenReturn(Optional.of(cm)); + when(commentRepo.hasReplies(COMMENT_ID)).thenReturn(false); + + sut.deleteComment(OWNER_ID, COLL_ID, COMMENT_ID); + + verify(commentRepo).remove(cm); + } + + @Test @DisplayName("댓글 작성자도 컬렉션 소유자도 아닌 경우 → ForbiddenException") + void delete_forbidden() { + long thirdPartyId = 999L; + User collectionOwner = user(OWNER_ID); + User commentOwner = user(OTHER_ID); + UserBirdCollection coll = collection(COLL_ID, collectionOwner); + UserBirdCollectionComment cm = comment(COMMENT_ID, commentOwner, coll, "not mine"); + + when(commentRepo.findById(COMMENT_ID)).thenReturn(Optional.of(cm)); + + assertThatThrownBy(() -> + sut.deleteComment(thirdPartyId, COLL_ID, COMMENT_ID)) + .isExactlyInstanceOf(ForbiddenException.class); } } diff --git a/src/test/java/org/devkor/apu/saerok_server/domain/collection/core/repository/CollectionCommentRepositoryTest.java b/src/test/java/org/devkor/apu/saerok_server/domain/collection/core/repository/CollectionCommentRepositoryTest.java index 1f71a884..0da8098f 100644 --- a/src/test/java/org/devkor/apu/saerok_server/domain/collection/core/repository/CollectionCommentRepositoryTest.java +++ b/src/test/java/org/devkor/apu/saerok_server/domain/collection/core/repository/CollectionCommentRepositoryTest.java @@ -94,4 +94,89 @@ void list_and_count() { assertThat(repo.countByCollectionId(col.getId())).isEqualTo(2L); } + + @Test @DisplayName("countByCollectionId는 ACTIVE 댓글만 카운트") + void count_onlyActiveComments() { + User u = user(); + UserBirdCollection col = collection(u); + + UserBirdCollectionComment active = UserBirdCollectionComment.of(u, col, "active"); + UserBirdCollectionComment deleted = UserBirdCollectionComment.of(u, col, "deleted"); + UserBirdCollectionComment banned = UserBirdCollectionComment.of(u, col, "banned"); + + repo.save(active); + repo.save(deleted); + repo.save(banned); + em.flush(); + + deleted.softDelete(); + banned.ban(); + em.flush(); em.clear(); + + assertThat(repo.countByCollectionId(col.getId())).isEqualTo(1L); + } + + @Test @DisplayName("hasReplies - 대댓글이 있는 경우") + void hasReplies_true() { + User u = user(); + UserBirdCollection col = collection(u); + + UserBirdCollectionComment parent = UserBirdCollectionComment.of(u, col, "parent"); + repo.save(parent); + em.flush(); + + UserBirdCollectionComment reply = UserBirdCollectionComment.of(u, col, "reply", parent); + repo.save(reply); + em.flush(); em.clear(); + + assertThat(repo.hasReplies(parent.getId())).isTrue(); + } + + @Test @DisplayName("hasReplies - 대댓글이 없는 경우") + void hasReplies_false() { + User u = user(); + UserBirdCollection col = collection(u); + + UserBirdCollectionComment comment = UserBirdCollectionComment.of(u, col, "no replies"); + repo.save(comment); + em.flush(); em.clear(); + + assertThat(repo.hasReplies(comment.getId())).isFalse(); + } + + @Test @DisplayName("부모-자식 관계 조회") + void parentChildRelationship() { + User u = user(); + UserBirdCollection col = collection(u); + + UserBirdCollectionComment parent = UserBirdCollectionComment.of(u, col, "parent"); + repo.save(parent); + em.flush(); + + UserBirdCollectionComment reply1 = UserBirdCollectionComment.of(u, col, "reply1", parent); + UserBirdCollectionComment reply2 = UserBirdCollectionComment.of(u, col, "reply2", parent); + repo.save(reply1); + repo.save(reply2); + em.flush(); em.clear(); + + var comments = repo.findByCollectionId(col.getId()); + + assertThat(comments).hasSize(3); + assertThat(comments) + .extracting(UserBirdCollectionComment::getContent) + .containsExactly("parent", "reply1", "reply2"); + + var loadedParent = comments.stream() + .filter(c -> c.getContent().equals("parent")) + .findFirst().get(); + var loadedReply1 = comments.stream() + .filter(c -> c.getContent().equals("reply1")) + .findFirst().get(); + + assertThat(loadedParent.getParent()).isNull(); + assertThat(loadedReply1.getParent()).isNotNull(); + assertThat(loadedReply1.getParent().getId()).isEqualTo(loadedParent.getId()); + assertThat(loadedReply1.isReply()).isTrue(); + assertThat(loadedParent.isReply()).isFalse(); + } } From f72b4da6144f1f959cbe58621b5dc7661c8c6382 Mon Sep 17 00:00:00 2001 From: pizzazoa Date: Thu, 1 Jan 2026 19:18:55 +0900 Subject: [PATCH 14/17] =?UTF-8?q?fix(coll):=20=EB=8C=80=EB=8C=93=EA=B8=80?= =?UTF-8?q?=20=EC=9E=91=EC=84=B1=20=EC=8B=9C=20=EC=95=8C=EB=A6=BC=20?= =?UTF-8?q?=EB=B0=9C=EC=86=A1=20=EB=A1=9C=EC=A7=81=EC=9D=84=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 원댓글 작성자와 컬렉션 작성자가 같다면 하나만 발송돼야 함 --- .../CollectionCommentCommandService.java | 50 ++++++++++++------- 1 file changed, 32 insertions(+), 18 deletions(-) diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionCommentCommandService.java b/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionCommentCommandService.java index 017e7c8c..2827d1c6 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionCommentCommandService.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionCommentCommandService.java @@ -70,26 +70,40 @@ public CreateCollectionCommentResponse createComment(Long userId, commentRepository.save(comment); // 알림 전송 - // 대댓글인 경우: 원댓글 작성자에게 REPLY 알림 - if (parentComment != null && !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()); - } + 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()); + } - // 컬렉션 작성자에게 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()); + // 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()); } From 1c8475e0c588ed5c0459b29127f8ecd579ce249c Mon Sep 17 00:00:00 2001 From: pizzazoa Date: Sat, 3 Jan 2026 16:10:16 +0900 Subject: [PATCH 15/17] =?UTF-8?q?feat(coll):=20=EB=8C=93=EA=B8=80=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=EC=97=90=20=EB=94=B0=EB=A5=B8=20=EB=8C=80?= =?UTF-8?q?=EC=B9=98=20=ED=85=8D=EC=8A=A4=ED=8A=B8=20=EC=84=A4=EC=A0=95=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../feature/CommentReplacementConfig.java | 22 +++++++++++++++++++ src/main/resources/application.yml | 1 + .../resources/config/comment-replacements.yml | 4 ++++ 3 files changed, 27 insertions(+) create mode 100644 src/main/java/org/devkor/apu/saerok_server/global/core/config/feature/CommentReplacementConfig.java create mode 100644 src/main/resources/config/comment-replacements.yml diff --git a/src/main/java/org/devkor/apu/saerok_server/global/core/config/feature/CommentReplacementConfig.java b/src/main/java/org/devkor/apu/saerok_server/global/core/config/feature/CommentReplacementConfig.java new file mode 100644 index 00000000..b899aa8f --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/global/core/config/feature/CommentReplacementConfig.java @@ -0,0 +1,22 @@ +package org.devkor.apu.saerok_server.global.core.config.feature; + +import lombok.Getter; +import lombok.Setter; +import org.devkor.apu.saerok_server.domain.collection.core.entity.CommentStatus; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +import java.util.EnumMap; +import java.util.Map; + +@Component +@ConfigurationProperties(prefix = "comment-replacements") +@Getter +@Setter +public class CommentReplacementConfig { + private Map contents = new EnumMap<>(CommentStatus.class); + + public String getReplacement(CommentStatus status) { + return contents.getOrDefault(status, null); + } +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 1af059ae..a3ea60ab 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -26,6 +26,7 @@ spring: - "classpath:config/user-profile-images-default.yml" - "classpath:config/notification-messages.yml" - "classpath:config/image-variants.yml" + - "classpath:config/comment-replacements.yml" api_prefix: /api/v1 diff --git a/src/main/resources/config/comment-replacements.yml b/src/main/resources/config/comment-replacements.yml new file mode 100644 index 00000000..db9b938b --- /dev/null +++ b/src/main/resources/config/comment-replacements.yml @@ -0,0 +1,4 @@ +comment-replacements: + contents: + DELETED: "삭제된 댓글이에요." + BANNED: "정책에 의해 차단된 댓글이에요." From ca3b9e8b331abad068350fca93a441091353a34b Mon Sep 17 00:00:00 2001 From: pizzazoa Date: Sat, 3 Jan 2026 16:15:37 +0900 Subject: [PATCH 16/17] =?UTF-8?q?feat(coll):=20=EB=8C=93=EA=B8=80=20status?= =?UTF-8?q?=EC=97=90=20=EB=94=B0=EB=A5=B8=20content=20=EB=8C=80=EC=B9=98?= =?UTF-8?q?=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CollectionCommentQueryService.java | 4 +++- .../core/service/CommentContentResolver.java | 23 +++++++++++++++++++ .../mapper/CollectionCommentWebMapper.java | 17 ++++++++++---- 3 files changed, 38 insertions(+), 6 deletions(-) create mode 100644 src/main/java/org/devkor/apu/saerok_server/domain/collection/core/service/CommentContentResolver.java diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionCommentQueryService.java b/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionCommentQueryService.java index aad606ef..03f214af 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionCommentQueryService.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionCommentQueryService.java @@ -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; @@ -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) { @@ -72,7 +74,7 @@ public GetCollectionCommentsResponse getComments(Long collectionId, Long userId) Map 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); } /* 댓글 개수 */ diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/collection/core/service/CommentContentResolver.java b/src/main/java/org/devkor/apu/saerok_server/domain/collection/core/service/CommentContentResolver.java new file mode 100644 index 00000000..c12580f6 --- /dev/null +++ b/src/main/java/org/devkor/apu/saerok_server/domain/collection/core/service/CommentContentResolver.java @@ -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; + } +} diff --git a/src/main/java/org/devkor/apu/saerok_server/domain/collection/mapper/CollectionCommentWebMapper.java b/src/main/java/org/devkor/apu/saerok_server/domain/collection/mapper/CollectionCommentWebMapper.java index 7b4028c5..06721827 100644 --- a/src/main/java/org/devkor/apu/saerok_server/domain/collection/mapper/CollectionCommentWebMapper.java +++ b/src/main/java/org/devkor/apu/saerok_server/domain/collection/mapper/CollectionCommentWebMapper.java @@ -2,7 +2,9 @@ import org.devkor.apu.saerok_server.domain.collection.api.dto.response.GetCollectionCommentsResponse; import org.devkor.apu.saerok_server.domain.collection.core.entity.UserBirdCollectionComment; +import org.devkor.apu.saerok_server.domain.collection.core.service.CommentContentResolver; import org.devkor.apu.saerok_server.global.shared.util.OffsetDateTimeLocalizer; +import org.mapstruct.Context; import org.mapstruct.Mapper; import org.mapstruct.MappingConstants; @@ -20,7 +22,8 @@ default GetCollectionCommentsResponse toGetCollectionCommentsResponse( Map mineStatuses, Map profileImageUrls, Map thumbnailProfileImageUrls, - Boolean isMyCollection) { + Boolean isMyCollection, + @Context CommentContentResolver commentContentResolver) { if (entities == null || entities.isEmpty()) { return new GetCollectionCommentsResponse(List.of(), isMyCollection); } @@ -64,10 +67,10 @@ default GetCollectionCommentsResponse toGetCollectionCommentsResponse( List replies = repliesByParentId.getOrDefault(comment.getId(), List.of()) .stream() .sorted(java.util.Comparator.comparing(UserBirdCollectionComment::getCreatedAt)) - .map(reply -> buildCommentItem(reply, likeCounts, likeStatuses, mineStatuses, profileImageUrls, thumbnailProfileImageUrls, List.of())) + .map(reply -> buildCommentItem(reply, likeCounts, likeStatuses, mineStatuses, profileImageUrls, thumbnailProfileImageUrls, List.of(), commentContentResolver)) .toList(); - return buildCommentItem(comment, likeCounts, likeStatuses, mineStatuses, profileImageUrls, thumbnailProfileImageUrls, replies); + return buildCommentItem(comment, likeCounts, likeStatuses, mineStatuses, profileImageUrls, thumbnailProfileImageUrls, replies, commentContentResolver); }) .toList(); return new GetCollectionCommentsResponse(items, isMyCollection); @@ -81,7 +84,8 @@ private GetCollectionCommentsResponse.Item buildCommentItem( Map mineStatuses, Map profileImageUrls, Map thumbnailProfileImageUrls, - List replies) { + List replies, + CommentContentResolver commentContentResolver) { Long commentId = c.getId(); Long userId = c.getUser().getId(); int likeCount = likeCounts.get(commentId).intValue(); @@ -90,13 +94,16 @@ private GetCollectionCommentsResponse.Item buildCommentItem( String profileImageUrl = profileImageUrls.get(userId); String thumbnailProfileImageUrl = thumbnailProfileImageUrls.get(userId); + // 댓글 상태에 따라 content 대체 + String content = commentContentResolver.resolveContent(c.getContent(), c.getStatus()); + return new GetCollectionCommentsResponse.Item( commentId, userId, c.getUser().getNickname(), profileImageUrl, thumbnailProfileImageUrl, - c.getContent(), + content, c.getStatus().name(), c.getParent() != null ? c.getParent().getId() : null, likeCount, From ca5f3d652ee1d21e0ca3ea69a1f90b441d16bd8a Mon Sep 17 00:00:00 2001 From: pizzazoa Date: Sat, 3 Jan 2026 16:19:33 +0900 Subject: [PATCH 17/17] =?UTF-8?q?test(coll):=20=EB=8C=93=EA=B8=80=20?= =?UTF-8?q?=ED=85=8D=EC=8A=A4=ED=8A=B8=20=EB=8C=80=EC=B9=98=20=EB=A1=9C?= =?UTF-8?q?=EC=A7=81=20=EC=B6=94=EA=B0=80=EC=97=90=20=EB=94=B0=EB=A5=B8=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CollectionCommentCommandServiceTest.java | 4 +++- .../CollectionCommentQueryServiceTest.java | 23 +++++++++++++------ 2 files changed, 19 insertions(+), 8 deletions(-) diff --git a/src/test/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionCommentCommandServiceTest.java b/src/test/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionCommentCommandServiceTest.java index 86491c3e..67b93fc8 100644 --- a/src/test/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionCommentCommandServiceTest.java +++ b/src/test/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionCommentCommandServiceTest.java @@ -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.notification.application.facade.NotificationPublisher; import org.devkor.apu.saerok_server.domain.notification.application.facade.NotifyActionDsl; @@ -55,6 +56,7 @@ class CollectionCommentCommandServiceTest { @Mock CollectionCommentLikeRepository commentLikeRepo; @Mock CollectionCommentWebMapper collectionCommentWebMapper; @Mock UserProfileImageUrlService userProfileImageUrlService; + @Mock CommentContentResolver commentContentResolver; private static User user(long id) { User u = new User(); @@ -95,7 +97,7 @@ void init() { sut = new CollectionCommentCommandService(commentRepo, collectionRepo, userRepo, notifyActionDsl); querySut = new CollectionCommentQueryService( - commentRepo, collectionRepo, commentLikeRepo, collectionCommentWebMapper, userProfileImageUrlService + commentRepo, collectionRepo, commentLikeRepo, collectionCommentWebMapper, userProfileImageUrlService, commentContentResolver ); } diff --git a/src/test/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionCommentQueryServiceTest.java b/src/test/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionCommentQueryServiceTest.java index b02a2352..24b6ad4c 100644 --- a/src/test/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionCommentQueryServiceTest.java +++ b/src/test/java/org/devkor/apu/saerok_server/domain/collection/application/CollectionCommentQueryServiceTest.java @@ -7,6 +7,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; @@ -40,6 +41,7 @@ class CollectionCommentQueryServiceTest { @Mock CollectionCommentLikeRepository commentLikeRepo; @Mock CollectionCommentWebMapper mapper; @Mock UserProfileImageUrlService userProfileImageUrlService; + @Mock CommentContentResolver commentContentResolver; private static UserBirdCollection collection(long id, Long userId) { UserBirdCollection c = new UserBirdCollection(); @@ -57,7 +59,8 @@ void init() { collectionRepo, commentLikeRepo, mapper, - userProfileImageUrlService + userProfileImageUrlService, + commentContentResolver ); } @@ -92,7 +95,8 @@ void success_guest() { when(mapper.toGetCollectionCommentsResponse( comments, likeCounts, Map.of(), Map.of(), - profileImageUrls, thumbnailProfileImageUrls, false + profileImageUrls, thumbnailProfileImageUrls, false, + commentContentResolver )) .thenReturn(expected); @@ -102,7 +106,8 @@ void success_guest() { verify(mapper).toGetCollectionCommentsResponse( comments, likeCounts, Map.of(), Map.of(), - profileImageUrls, thumbnailProfileImageUrls, false + profileImageUrls, thumbnailProfileImageUrls, false, + commentContentResolver ); } @@ -138,7 +143,8 @@ void success_loggedInUser_notMyCollection() { when(mapper.toGetCollectionCommentsResponse( comments, likeCounts, likeStatuses, Map.of(), - profileImageUrls, thumbnailProfileImageUrls, false + profileImageUrls, thumbnailProfileImageUrls, false, + commentContentResolver )) .thenReturn(expected); @@ -148,7 +154,8 @@ void success_loggedInUser_notMyCollection() { verify(mapper).toGetCollectionCommentsResponse( comments, likeCounts, likeStatuses, Map.of(), - profileImageUrls, thumbnailProfileImageUrls, false + profileImageUrls, thumbnailProfileImageUrls, false, + commentContentResolver ); } @@ -184,7 +191,8 @@ void success_loggedInUser_myCollection() { when(mapper.toGetCollectionCommentsResponse( comments, likeCounts, likeStatuses, Map.of(), - profileImageUrls, thumbnailProfileImageUrls, true + profileImageUrls, thumbnailProfileImageUrls, true, + commentContentResolver )) .thenReturn(expected); @@ -194,7 +202,8 @@ void success_loggedInUser_myCollection() { verify(mapper).toGetCollectionCommentsResponse( comments, likeCounts, likeStatuses, Map.of(), - profileImageUrls, thumbnailProfileImageUrls, true + profileImageUrls, thumbnailProfileImageUrls, true, + commentContentResolver ); }