diff --git a/src/main/java/com/hrr/backend/domain/comment/controller/CommentController.java b/src/main/java/com/hrr/backend/domain/comment/controller/CommentController.java index 76706cae..eb752361 100644 --- a/src/main/java/com/hrr/backend/domain/comment/controller/CommentController.java +++ b/src/main/java/com/hrr/backend/domain/comment/controller/CommentController.java @@ -11,6 +11,7 @@ import com.hrr.backend.global.response.SuccessCode; import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; @@ -36,9 +37,11 @@ public class CommentController { @Operation(summary = "댓글 작성", description = "인증글에 댓글 또는 대댓글을 작성합니다.") @PostMapping("/{verificationId}") public ApiResponse createComment( - @AuthenticationPrincipal CustomUserDetails userDetails, - @PathVariable Long verificationId, - @Valid @RequestBody CommentCreateRequestDto requestDto + @Parameter(hidden = true) + @AuthenticationPrincipal CustomUserDetails userDetails, + + @PathVariable Long verificationId, + @Valid @RequestBody CommentCreateRequestDto requestDto ) { Long userId = userDetails.getUser().getId(); @@ -51,17 +54,21 @@ public ApiResponse createComment( @Operation(summary = "댓글/대댓글 조회", description = "특정 인증글의 모든 댓글 및 대댓글을 조회합니다.") @GetMapping("/{verificationId}") public ApiResponse getComments( - @PathVariable Long verificationId, + @Parameter(hidden = true) + @AuthenticationPrincipal CustomUserDetails userDetails, + + @PathVariable Long verificationId, - // 페이징 - @Min(1) - @RequestParam(name = "page", defaultValue = "1") int page, // 페이지 번호 (1-based) - @RequestParam(name = "size", defaultValue = "10") int size // 페이지 크기 + // 페이징 + @Min(1) + @RequestParam(name = "page", defaultValue = "1") int page, // 페이지 번호 (1-based) + @RequestParam(name = "size", defaultValue = "10") int size // 페이지 크기 ) { + Long userId = userDetails.getUser().getId(); Pageable pageable = PageRequest.of(page-1, size); - CommentListResponseDto response = commentService.getComments(verificationId, pageable); + CommentListResponseDto response = commentService.getComments(verificationId, userId, pageable); return ApiResponse.onSuccess(SuccessCode.OK, response); } @@ -69,9 +76,11 @@ public ApiResponse getComments( @Operation(summary = "댓글 수정", description = "본인이 작성한 댓글만 수정할 수 있습니다.") @PatchMapping("/{commentId}") public ApiResponse updateComment( - @AuthenticationPrincipal CustomUserDetails userDetails, - @PathVariable Long commentId, - @RequestBody CommentUpdateRequestDto requestDto + @Parameter(hidden = true) + @AuthenticationPrincipal CustomUserDetails userDetails, + + @PathVariable Long commentId, + @RequestBody CommentUpdateRequestDto requestDto ) { Long userId = userDetails.getUser().getId(); diff --git a/src/main/java/com/hrr/backend/domain/comment/converter/CommentConverter.java b/src/main/java/com/hrr/backend/domain/comment/converter/CommentConverter.java index 4c834173..6043253a 100644 --- a/src/main/java/com/hrr/backend/domain/comment/converter/CommentConverter.java +++ b/src/main/java/com/hrr/backend/domain/comment/converter/CommentConverter.java @@ -1,37 +1,110 @@ package com.hrr.backend.domain.comment.converter; +import java.time.LocalDateTime; + +import org.springframework.stereotype.Component; + import com.hrr.backend.domain.comment.dto.CommentResponseDto; import com.hrr.backend.domain.comment.entity.Comment; import com.hrr.backend.domain.user.entity.User; -import lombok.experimental.UtilityClass; +import com.hrr.backend.domain.user.entity.enums.UserStatus; +import com.hrr.backend.global.s3.S3UrlUtil; -@UtilityClass +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor public class CommentConverter { - public static CommentResponseDto toDto(Comment comment) { + private final S3UrlUtil s3UrlUtil; - User user = comment.getUser(); + /** + * Comment 객체 받아서 응답 형태로 변환 + * @param comment 변환할 댓글 객체 + * @param currentUserId 조회하는 사용자의 userId; 마스킹 처리 여부를 판단하는 데 사용 + * @return + */ + public CommentResponseDto toDto(Comment comment, Long currentUserId) { + /** + * reponse에 필요한 항목들 계산 - 1. comment에서 그대로 가져오는 값들 + */ + Long commentId = comment.getId(); + Long verificationId = comment.getVerification().getId(); // 어떤 인증글(게시글)에 달린 댓글인지 + User commentOwner = comment.getUser(); // 댓글 작성자 - return CommentResponseDto.builder() - .commentId(comment.getId()) - .parentId(comment.getParent() != null ? comment.getParent().getId() : null) - .verificationId(comment.getVerification().getId()) + boolean isAnonymous = comment.isAnonymous(); + int depth = comment.getDepth(); + boolean isAdopted = comment.isAdopted(); + int likesCount = comment.getLikesCount(); - // 작성자 ID - .userId(user.getId()) + LocalDateTime createdAt = comment.getCreatedAt(); + LocalDateTime updatedAt = comment.getUpdatedAt(); + + /** + * reponse에 필요한 항목들 계산 - 2. 추가 계산 필요한 값들 + */ + // 부모 댓글 id + Long parentId = comment.getParent() != null ? comment.getParent().getId() : null; + + // 프로필 이미지 - 소셜 플랫폼에서 그대로 가져오는 경우와 직접 업로드 한 이미지인 경우 분리 + // 소셜 플랫폼의 프로필: 이미 full URL이라 그대로 반환 + // 직접 업로드한 이미지: AWS S3의 image Key만 저장되기에 prefix 추가 필요 + // 익명이면 null 반환 + String userProfileUrl = !isAnonymous? s3UrlUtil.toFullUrl(commentOwner.getProfileImage()) : null ; //util에서 자동 처리 - // 익명이면 "익명", 아니면 nickname - .userName(comment.isAnonymous() ? "익명" : user.getNickname()) + // 닉네임 - 닉네임 or 마스킹 or (알 수 없음) + // 마스킹 조건 - 익명이면서, 조회하는 사람의 게시글이 아니면서, 조회하는 사람의 댓글이 아님 + boolean isMyVerificationPost = comment.getVerification().getUserChallenge().getUser().getId().equals(currentUserId); + boolean isMyComment = commentOwner.getId().equals(currentUserId); - // 익명이면 null, 아니면 profileImage (URL) - .userProfileUrl(comment.isAnonymous() ? null : user.getProfileImage()) + boolean maskingCondition = + isAnonymous && !isMyVerificationPost && !isMyComment; - .isAnonymous(comment.isAnonymous()) - .depth(comment.getDepth()) - .content(comment.isDeleted() ? "삭제된 댓글입니다." : comment.getContent()) - .likesCount(comment.getLikesCount()) - .isAdopted(Boolean.TRUE.equals(comment.getIsAdopted())) - .createdAt(comment.getCreatedAt()) + String userName = ""; + + // 우선순위: 삭제>탈퇴>익명>실명 + if (comment.isDeleted()) { + // 삭제된 댓글의 경우 (삭제) 처리 + userName = "삭제"; + } else if (commentOwner.getUserStatus() == UserStatus.DELETED) { + // 탈퇴 유저 + userName = "알 수 없음"; + } else if (maskingCondition) { + // 마스킹 처리 + userName = "익명" + comment.getAnonymousNumber(); + } else if (isMyVerificationPost) { + // 인증 게시글 작성자 + userName = commentOwner.getNickname() + "(글쓴이)"; + } else { + // 작성자 닉네임 그대로 + userName = commentOwner.getNickname(); + } + + // userId + Long userId = (maskingCondition)? null : commentOwner.getId(); // 마스킹 조건이면 null, 아니면 작성자의 userId + + // 내용 + String content = comment.isDeleted() ? "삭제된 댓글입니다." : comment.getContent(); + + + return CommentResponseDto.builder() + .commentId(commentId) + .parentId(parentId) + .verificationId(verificationId) + // 작성자 ID + .userId(userId) + // 닉네임 반환 + .userName(userName) + // 프로필 이미지 URL + .userProfileUrl(userProfileUrl) + .isMyComment(isMyComment) + .isAnonymous(isAnonymous) + .depth(depth) + .content(content) + .likesCount(likesCount) + .isAdopted(isAdopted) + .createdAt(createdAt) + .updatedAt(updatedAt) .build(); } } diff --git a/src/main/java/com/hrr/backend/domain/comment/dto/CommentResponseDto.java b/src/main/java/com/hrr/backend/domain/comment/dto/CommentResponseDto.java index 9685a026..b173c472 100644 --- a/src/main/java/com/hrr/backend/domain/comment/dto/CommentResponseDto.java +++ b/src/main/java/com/hrr/backend/domain/comment/dto/CommentResponseDto.java @@ -27,6 +27,8 @@ public class CommentResponseDto { private String content; private int likesCount; + private boolean isMyComment; // 조회하는 사람이 쓴 댓글인지(프론트 분기 처리용) + private LocalDateTime createdAt; private LocalDateTime updatedAt; } diff --git a/src/main/java/com/hrr/backend/domain/comment/entity/Comment.java b/src/main/java/com/hrr/backend/domain/comment/entity/Comment.java index 156c56a4..e979dcb9 100644 --- a/src/main/java/com/hrr/backend/domain/comment/entity/Comment.java +++ b/src/main/java/com/hrr/backend/domain/comment/entity/Comment.java @@ -56,6 +56,10 @@ public class Comment extends BaseEntity { @Column(nullable = false) private boolean isAnonymous; + /** 익명 번호 */ + @Column + private Integer anonymousNumber; // 익명1, 익명2 등에 사용 + /** 좋아요 수 (develop 추가) */ @Column(nullable = false) private int likesCount; @@ -67,9 +71,8 @@ public class Comment extends BaseEntity { /** 채택 여부 (feat/90 추가) */ @Column(nullable = false) - @ColumnDefault("false") @Builder.Default - private Boolean isAdopted = false; + private boolean isAdopted = false; // === 정적 팩토리 메서드 (develop) === public static Comment create( @@ -78,6 +81,7 @@ public static Comment create( Comment parent, String content, boolean isAnonymous, + Integer anonymousNumber, int depth ) { return Comment.builder() @@ -86,6 +90,7 @@ public static Comment create( .parent(parent) .content(content) .isAnonymous(isAnonymous) + .anonymousNumber(anonymousNumber) .depth(depth) .likesCount(0) .isDeleted(false) @@ -111,4 +116,4 @@ public void adopt() { public void unAdopt() { this.isAdopted = false; } -} \ No newline at end of file +} diff --git a/src/main/java/com/hrr/backend/domain/comment/repository/CommentRepository.java b/src/main/java/com/hrr/backend/domain/comment/repository/CommentRepository.java index da6348f5..9acb4b2e 100644 --- a/src/main/java/com/hrr/backend/domain/comment/repository/CommentRepository.java +++ b/src/main/java/com/hrr/backend/domain/comment/repository/CommentRepository.java @@ -1,6 +1,7 @@ package com.hrr.backend.domain.comment.repository; import com.hrr.backend.domain.comment.entity.Comment; +import com.hrr.backend.domain.user.entity.User; import com.hrr.backend.domain.verification.entity.Verification; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -39,4 +40,15 @@ Page findByVerificationAndDepthAndIsDeletedFalseOrderByCreatedAtAsc( List findByParentInAndIsDeletedFalseOrderByCreatedAtAsc(List parents); Optional findByIdAndIsDeletedFalse(Long id); + + /** + * 특정 인증글에 특정 유저가 익명으로 남긴 댓글이 있는지 조회 + */ + Optional findFirstByVerificationAndUserAndIsAnonymousTrue(Verification verification, User user); + + /** + * 특정 인증글에 달린 익명 댓글 중 최대 익명 번호 조회 (익명1, 익명2, 익명3 있으면 3 반환) + */ + @Query("SELECT MAX(c.anonymousNumber) FROM Comment c WHERE c.verification = :verification") + Integer findMaxAnonymousNumberByVerification(Verification verification); } diff --git a/src/main/java/com/hrr/backend/domain/comment/service/CommentService.java b/src/main/java/com/hrr/backend/domain/comment/service/CommentService.java index d42a3649..c0c45b2b 100644 --- a/src/main/java/com/hrr/backend/domain/comment/service/CommentService.java +++ b/src/main/java/com/hrr/backend/domain/comment/service/CommentService.java @@ -12,7 +12,7 @@ public interface CommentService { CommentResponseDto createComment(Long verificationId, Long userId, CommentCreateRequestDto requestDto); /** 댓글 조회 */ - CommentListResponseDto getComments(Long verificationId, Pageable pageable); + CommentListResponseDto getComments(Long verificationId, Long userId, Pageable pageable); /** 댓글 수정 */ CommentResponseDto updateComment(Long commentId, Long userId, CommentUpdateRequestDto requestDto); diff --git a/src/main/java/com/hrr/backend/domain/comment/service/CommentServiceImpl.java b/src/main/java/com/hrr/backend/domain/comment/service/CommentServiceImpl.java index b2d93fa0..af6d80d3 100644 --- a/src/main/java/com/hrr/backend/domain/comment/service/CommentServiceImpl.java +++ b/src/main/java/com/hrr/backend/domain/comment/service/CommentServiceImpl.java @@ -30,6 +30,7 @@ public class CommentServiceImpl implements CommentService { private final VerificationRepository verificationRepository; private final CommentRepository commentRepository; private final UserRepository userRepository; + private final CommentConverter commentConverter; /** 댓글 작성 */ @Override @@ -60,23 +61,41 @@ public CommentResponseDto createComment(Long verificationId, Long userId, Commen } } + Integer anonymousNumber = null; + + if (requestDto.isAnonymous()) { + // 해당 인증글에서 이 유저가 이미 쓴 익명 댓글이 있는지 확인 + Optional existingComment = commentRepository + .findFirstByVerificationAndUserAndIsAnonymousTrue(verification, user); + + if (existingComment.isPresent()) { + // 있다면 해당 익명 번호 그대로 사용 + anonymousNumber = existingComment.get().getAnonymousNumber(); + } else { + // 없다면 해당 인증글의 최대 익명 번호 조회 후 +1 + Integer maxNumber = commentRepository.findMaxAnonymousNumberByVerification(verification); + anonymousNumber = (maxNumber == null) ? 1 : maxNumber + 1; + } + } + Comment comment = Comment.create( verification, user, parent, requestDto.getContent(), requestDto.isAnonymous(), + anonymousNumber, parent == null ? 0 : parent.getDepth() + 1 ); commentRepository.save(comment); - return CommentConverter.toDto(comment); + return commentConverter.toDto(comment, userId); } /** 댓글/대댓글 조회 */ @Override - public CommentListResponseDto getComments(Long verificationId, Pageable pageable) { + public CommentListResponseDto getComments(Long verificationId, Long userId, Pageable pageable) { Verification verification = verificationRepository.findById(verificationId) .orElseThrow(() -> new GlobalException(ErrorCode.VERIFICATION_NOT_FOUND)); @@ -105,7 +124,7 @@ public CommentListResponseDto getComments(Long verificationId, Pageable pageable adoptedChildren = commentRepository .findByParentAndIsDeletedFalseOrderByCreatedAtAsc(adoptedParent) .stream() - .map(CommentConverter::toDto) + .map(child -> commentConverter.toDto(child, userId)) .toList(); @@ -133,18 +152,18 @@ public CommentListResponseDto getComments(Long verificationId, Pageable pageable for (Comment parent : parents) { // 부모 댓글 추가 - result.add(CommentConverter.toDto(parent)); + result.add(commentConverter.toDto(parent, userId)); // 부모에 해당하는 자식(대댓글)들 추가 List childList = childrenMap.getOrDefault(parent.getId(), Collections.emptyList()); for (Comment child : childList) { - result.add(CommentConverter.toDto(child)); + result.add(commentConverter.toDto(child, userId)); } } } return CommentListResponseDto.builder() - .adoptedParent(adoptedParent != null ? CommentConverter.toDto(adoptedParent) : null) + .adoptedParent(adoptedParent != null ? commentConverter.toDto(adoptedParent, userId) : null) .adoptedChildren(adoptedChildren) .comments(result) .currentPage(parentPage.getNumber() + 1) @@ -175,7 +194,10 @@ public CommentResponseDto updateComment(Long commentId, Long userId, CommentUpda comment.updateContent(requestDto.getContent()); - return CommentConverter.toDto(comment); + // 수동으로 DB 에 반영하여 Auditing 필드(updatedAt)를 갱신 + commentRepository.saveAndFlush(comment); + + return commentConverter.toDto(comment, userId); } diff --git a/src/main/java/com/hrr/backend/domain/verification/converter/VerificationConverter.java b/src/main/java/com/hrr/backend/domain/verification/converter/VerificationConverter.java index 9124fc0b..62e685a4 100644 --- a/src/main/java/com/hrr/backend/domain/verification/converter/VerificationConverter.java +++ b/src/main/java/com/hrr/backend/domain/verification/converter/VerificationConverter.java @@ -125,7 +125,7 @@ public VerificationDetailResponseDto toDetailDto( .userId(user.getId()) .nickname(user.getNickname()) .profileImageUrl(user.getProfileImage()) - .role(userChallenge.getRole()) + .level(userChallenge.getUser().getUserLevel()) .build(); VerificationDetailResponseDto.RoundInfo roundInfo = @@ -169,4 +169,4 @@ public VerificationDetailResponseDto toDetailDto( } -} \ No newline at end of file +} diff --git a/src/main/java/com/hrr/backend/domain/verification/dto/VerificationDetailResponseDto.java b/src/main/java/com/hrr/backend/domain/verification/dto/VerificationDetailResponseDto.java index 60f36811..3d98b0e4 100644 --- a/src/main/java/com/hrr/backend/domain/verification/dto/VerificationDetailResponseDto.java +++ b/src/main/java/com/hrr/backend/domain/verification/dto/VerificationDetailResponseDto.java @@ -2,6 +2,7 @@ import com.hrr.backend.domain.comment.dto.CommentListResponseDto; import com.hrr.backend.domain.user.entity.enums.UserChallengeRole; +import com.hrr.backend.domain.user.entity.enums.UserLevel; import com.hrr.backend.domain.verification.entity.enums.VerificationPostType; import com.hrr.backend.domain.verification.entity.enums.VerificationStatus; import lombok.AllArgsConstructor; @@ -66,7 +67,7 @@ public static class UserInfo { private Long userId; private String nickname; private String profileImageUrl; - private UserChallengeRole role; + private UserLevel level; } @Data diff --git a/src/main/java/com/hrr/backend/domain/verification/service/VerificationServiceImpl.java b/src/main/java/com/hrr/backend/domain/verification/service/VerificationServiceImpl.java index c7d1f18e..1f29f214 100644 --- a/src/main/java/com/hrr/backend/domain/verification/service/VerificationServiceImpl.java +++ b/src/main/java/com/hrr/backend/domain/verification/service/VerificationServiceImpl.java @@ -303,7 +303,7 @@ public VerificationDetailResponseDto getVerificationDetail(Long verificationId, && Boolean.TRUE.equals(verification.getIsQuestion()) && !isResolved; Pageable pageable = PageRequest.of(page, size); - CommentListResponseDto comments = commentService.getComments(verificationId, pageable); + CommentListResponseDto comments = commentService.getComments(verificationId, currentUserId, pageable); Long adoptedCommentId = comments.getComments().stream() .filter(CommentResponseDto::isAdopted) @@ -394,7 +394,7 @@ public VerificationDetailResponseDto updateVerification(Long verificationId, Lon ); // 댓글 목록 + 상세 DTO 구성 - CommentListResponseDto comments = commentService.getComments(verificationId, PageRequest.of(0, 10)); + CommentListResponseDto comments = commentService.getComments(verificationId, currentUserId, PageRequest.of(0, 10)); boolean isMine = currentUserId.equals(author.getId()); boolean isResolved = Boolean.TRUE.equals(verification.getIsResolved()); diff --git a/src/main/resources/db/migration/V2.19__Add_anonymous_number_to_comment.sql b/src/main/resources/db/migration/V2.19__Add_anonymous_number_to_comment.sql new file mode 100644 index 00000000..988bb12b --- /dev/null +++ b/src/main/resources/db/migration/V2.19__Add_anonymous_number_to_comment.sql @@ -0,0 +1,30 @@ +-- 1. 프로시저가 이미 존재할 경우 삭제 +DROP PROCEDURE IF EXISTS AddColumnIfNotExist; + +-- 2. 컬럼 추가 프로시저 생성 +DELIMITER $$ + +CREATE PROCEDURE AddColumnIfNotExist() +BEGIN + -- comment 테이블에 anonymous_number 컬럼이 없는 경우에만 실행 + IF NOT EXISTS ( + SELECT * FROM information_schema.COLUMNS + WHERE TABLE_SCHEMA = DATABASE() + AND TABLE_NAME = 'comment' + AND COLUMN_NAME = 'anonymous_number' + ) THEN + -- 익명 번호 컬럼 추가 (Integer 타입, Null 허용) + ALTER TABLE comment ADD COLUMN anonymous_number INT; + + -- 인덱스가 필요한 경우 추가 (선택 사항) + -- CREATE INDEX idx_comment_anonymous_number ON comment (anonymous_number); + END IF; +END $$ + +DELIMITER ; + +-- 3. 프로시저 실행 +CALL AddColumnIfNotExist(); + +-- 4. 사용한 프로시저 삭제 (DB를 깨끗하게 유지하기 위함) +DROP PROCEDURE IF EXISTS AddColumnIfNotExist;