-
Notifications
You must be signed in to change notification settings - Fork 0
feat: 댓글 수정(PUT /api/v1/comments/{commentId}) 기능 및 테스트 추가 #15
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: feature/sprint03-comment
Are you sure you want to change the base?
Changes from all commits
0e54658
052784e
33fef4d
bfbb5e6
a4490a9
4396c24
834b313
b82aa89
3c624c6
31bf804
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| # Copy this file to .env and replace the placeholders with local-only values. | ||
| # Never commit the generated .env file. | ||
| SNOWTHING_DB_USERNAME=snowuser | ||
| SNOWTHING_DB_PASSWORD=replace-with-a-local-password | ||
| SNOWTHING_DB_ROOT_PASSWORD=replace-with-a-different-root-password | ||
|
|
||
| # Optional credentials for CommentCreateTest & CommentUpdateTest's real MySQL schema. | ||
| # If SNOWTHING_TEST_DB_URL is unset, tests run against in-memory H2 by default. | ||
| # When testing against MySQL, export these environment variables before running `./gradlew test`: | ||
| SNOWTHING_TEST_DB_URL=jdbc:mysql://localhost:3306/snowthing_test?useSSL=false&allowPublicKeyRetrieval=true&characterEncoding=UTF-8&serverTimezone=Asia/Seoul | ||
| SNOWTHING_TEST_DB_USERNAME=snowuser | ||
| SNOWTHING_TEST_DB_PASSWORD=replace-with-a-local-test-password |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| package com.ikae.snowthing.domain.comment.dto; | ||
|
|
||
| import java.util.List; | ||
|
|
||
| public record CommentReplyListResponse( | ||
| Long rootCommentId, | ||
| long totalReplyCount, | ||
| List<CommentResponse> replies, | ||
| Long nextCursor, | ||
| boolean hasNext) { | ||
| public CommentReplyListResponse { | ||
| replies = replies == null ? List.of() : List.copyOf(replies); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,35 +1,80 @@ | ||
| package com.ikae.snowthing.domain.comment.dto; | ||
|
|
||
| import java.time.LocalDateTime; | ||
| import java.util.ArrayList; | ||
| import java.util.List; | ||
|
|
||
| import com.ikae.snowthing.domain.comment.entity.Comment; | ||
| import com.ikae.snowthing.domain.member.entity.Member; | ||
| import com.ikae.snowthing.global.util.WriterDisplayFormatter; | ||
|
|
||
| public record CommentResponse( | ||
| Long commentId, | ||
| Long postId, | ||
| Long parentId, | ||
| String writerName, | ||
| WriterResponse writer, | ||
| boolean isAnonymous, | ||
| String writerIp, | ||
| String content, | ||
| boolean isDeleted, | ||
| LocalDateTime createdAt, | ||
| List<CommentResponse> children) { | ||
| public static CommentResponse from(Comment comment) { | ||
| String writerName = | ||
| WriterDisplayFormatter.format( | ||
| comment.isAnonymous(), comment.getMember(), comment.getWriterIp()); | ||
| long replyCount, | ||
| List<CommentResponse> previewReplies, | ||
| boolean hasMoreReplies, | ||
| LocalDateTime createdAt) { | ||
|
|
||
| public CommentResponse { | ||
| previewReplies = previewReplies == null ? List.of() : List.copyOf(previewReplies); | ||
| } | ||
|
|
||
| String displayContent = comment.isDeleted() ? "삭제된 댓글입니다." : comment.getContent(); | ||
| Long parentIdValue = comment.getParent() != null ? comment.getParent().getId() : null; | ||
| public record WriterResponse(String publicId, String nickname, String profileImageUrl) {} | ||
|
|
||
| public static CommentResponse from(Comment comment) { | ||
| Member member = comment.getMember(); | ||
| WriterResponse writer = | ||
| !comment.isAnonymous() && member != null | ||
| ? new WriterResponse( | ||
| member.getPublicId(), | ||
| member.getNickname(), | ||
| member.getProfileImageUrl()) | ||
| : null; | ||
| return new CommentResponse( | ||
| comment.getId(), | ||
| parentIdValue, | ||
| writerName, | ||
| displayContent, | ||
| comment.getPost().getId(), | ||
| comment.getParent() == null ? null : comment.getParent().getId(), | ||
| writer, | ||
| comment.isAnonymous(), | ||
| WriterDisplayFormatter.maskIp(comment.getWriterIp()), | ||
| comment.isDeleted() ? "삭제된 댓글입니다." : comment.getContent(), | ||
| comment.isDeleted(), | ||
| comment.getCreatedAt(), | ||
| new ArrayList<>()); | ||
| 0, | ||
| List.of(), | ||
| false, | ||
| comment.getCreatedAt()); | ||
| } | ||
|
|
||
| public CommentResponse withPreviewReplies(List<CommentResponse> replies) { | ||
| return new CommentResponse( | ||
| commentId, | ||
| postId, | ||
| parentId, | ||
| writer, | ||
| isAnonymous, | ||
| writerIp, | ||
| content, | ||
| isDeleted, | ||
| replyCount, | ||
| replies, | ||
| hasMoreReplies, | ||
| createdAt); | ||
| } | ||
|
|
||
| public List<CommentResponse> children() { | ||
| return previewReplies; | ||
| } | ||
|
|
||
| public String writerName() { | ||
| if (isAnonymous) { | ||
| return "익명 (" + writerIp + ")"; | ||
| } | ||
| return writer == null ? "알 수 없음" : writer.nickname(); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,10 @@ | ||
| package com.ikae.snowthing.domain.comment.dto; | ||
|
|
||
| import jakarta.validation.constraints.NotBlank; | ||
| import jakarta.validation.constraints.Size; | ||
|
|
||
| public record CommentUpdateRequest( | ||
| @NotBlank(message = "댓글 내용은 필수 입력값입니다.") | ||
| @Size(max = 1000, message = "댓글은 최대 1000자까지 입력 가능합니다.") | ||
| String content, | ||
| String anonymousPassword) {} |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| package com.ikae.snowthing.domain.comment.dto; | ||
|
|
||
| import java.time.LocalDateTime; | ||
|
|
||
| public record CommentUpdateResponse(Long commentId, String content, LocalDateTime updatedAt) {} |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -16,7 +16,16 @@ | |
| import lombok.NoArgsConstructor; | ||
|
|
||
| @Entity | ||
| @Table(name = "comment") | ||
| @Table( | ||
| name = "comment", | ||
| indexes = { | ||
| @Index( | ||
| name = "idx_comment_post_parent_created", | ||
| columnList = "post_id,parent_id,created_at,comment_id"), | ||
| @Index( | ||
| name = "idx_comment_parent_deleted_created", | ||
| columnList = "parent_id,is_deleted,created_at,comment_id") | ||
| }) | ||
| @Getter | ||
| @NoArgsConstructor(access = AccessLevel.PROTECTED) | ||
| @SQLDelete(sql = "UPDATE comment SET is_deleted = true, deleted_at = NOW() WHERE comment_id = ?") | ||
|
|
@@ -76,8 +85,27 @@ public Comment( | |
| this.isDeleted = false; | ||
| } | ||
|
|
||
| public static Comment create( | ||
| Post post, | ||
| Member member, | ||
| Comment parent, | ||
| String content, | ||
| String writerIp, | ||
| boolean isAnonymous, | ||
| String anonymousPassword) { | ||
| return new Comment(post, member, parent, content, writerIp, isAnonymous, anonymousPassword); | ||
| } | ||
|
|
||
| public Comment rootParent() { | ||
| return parent != null ? parent : this; | ||
| } | ||
|
|
||
| public void softDelete() { | ||
| this.isDeleted = true; | ||
| this.deletedAt = LocalDateTime.now(); | ||
| } | ||
|
|
||
| public void updateContent(String newContent) { | ||
| this.content = newContent; | ||
| } | ||
|
Comment on lines
+108
to
+110
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: #!/bin/bash
# Description: BaseTimeEntity의 updatedAt 관리 방식과 auditing 활성화 여부를 확인한다.
set -euo pipefail
# BaseTimeEntity 정의 위치 탐색
fd -t f 'BaseTimeEntity.java' --exec cat -n {}
# updatedAt 관련 애노테이션 확인
rg -nP -C3 '@(LastModifiedDate|UpdateTimestamp|CreatedDate|CreationTimestamp|EntityListeners|PreUpdate)' --glob '*.java'
# JPA Auditing 활성화 설정 확인
rg -nP -C3 '`@EnableJpaAuditing`' --glob '*.java'
# CommentUpdateResponse 필드 구성 확인
fd -t f 'CommentUpdateResponse.java' --exec cat -n {}
# CommentUpdateRequest의 검증 애노테이션 확인
fd -t f 'CommentUpdateRequest.java' --exec cat -n {}Repository: devikae/snowthing Length of output: 1111 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- repository conventions and learnings ---'
find /tmp/coderabbit-repo-knowledge/devikae-snowthing-e2edcedb \
-maxdepth 2 -type f -name '*.md' -print
printf '%s\n' '--- directly bound source files ---'
fd -t f -E build -E dist \
'Comment.java|CommentService.java|CommentUpdateResponse.java|CommentUpdateRequest.java|.*Config.*\\.java|.*Test.*\\.java' backend
printf '%s\n' '--- update flow and relevant declarations ---'
rg -n -C6 \
'updateComment|updateContent|getUpdatedAt|CommentUpdateResponse|CommentUpdateRequest|EnableJpaAuditing|Transactional' \
backend/src/main/java backend/src/test 2>/dev/null || trueRepository: devikae/snowthing Length of output: 50374 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- repository conventions and learnings ---'
find /tmp/coderabbit-repo-knowledge/devikae-snowthing-e2edcedb \
-maxdepth 2 -type f -name '*.md' -print
printf '%s\n' '--- directly bound source files ---'
fd -t f -E build -E dist \
'Comment.java|CommentService.java|CommentUpdateResponse.java|CommentUpdateRequest.java|.*Config.*\.java|.*Test.*\.java' backend
printf '%s\n' '--- update flow and relevant declarations ---'
rg -n -C6 \
'updateComment|updateContent|getUpdatedAt|CommentUpdateResponse|CommentUpdateRequest|EnableJpaAuditing|Transactional' \
backend/src/main/java backend/src/test 2>/dev/null || trueRepository: devikae/snowthing Length of output: 50374 🏁 Script executed: #!/bin/bash
set -euo pipefail
printf '%s\n' '--- backend review conventions ---'
cat /tmp/coderabbit-repo-knowledge/devikae-snowthing-e2edcedb/conventions/backend.md
printf '%s\n' '--- Comment entity ---'
cat -n backend/src/main/java/com/ikae/snowthing/domain/comment/entity/Comment.java
printf '%s\n' '--- CommentService update flow ---'
sed -n '200,245p' backend/src/main/java/com/ikae/snowthing/domain/comment/service/CommentService.java
printf '%s\n' '--- request, response, and update test ---'
cat -n backend/src/main/java/com/ikae/snowthing/domain/comment/dto/CommentUpdateRequest.java
cat -n backend/src/main/java/com/ikae/snowthing/domain/comment/dto/CommentUpdateResponse.java
rg -n -C8 'updateOwnCommentAsMember|updatedAt|updateComment' \
backend/src/test/java/com/ikae/snowthing/domain/comment/service/CommentUpdateTest.javaRepository: devikae/snowthing Length of output: 14122 🌐 Web query:
💡 Result: In Spring Data JPA, the AuditingEntityListener uses JPA lifecycle events to manage fields annotated with Citations:
업데이트 응답 전에 JPA flush를 수행하세요
🤖 Prompt for AI AgentsSource: Path instructions |
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,16 +1,26 @@ | ||
| package com.ikae.snowthing.domain.comment.repository; | ||
|
|
||
| import java.util.List; | ||
| import java.util.Optional; | ||
|
|
||
| import jakarta.persistence.LockModeType; | ||
|
|
||
| import org.springframework.data.jpa.repository.JpaRepository; | ||
| import org.springframework.data.jpa.repository.Lock; | ||
| import org.springframework.data.jpa.repository.Query; | ||
| import org.springframework.data.repository.query.Param; | ||
|
|
||
| import com.ikae.snowthing.domain.comment.entity.Comment; | ||
|
|
||
| public interface CommentRepository extends JpaRepository<Comment, Long> { | ||
| public interface CommentRepository extends JpaRepository<Comment, Long>, CommentRepositoryCustom { | ||
|
|
||
| @Lock(LockModeType.PESSIMISTIC_WRITE) | ||
| @Query("SELECT c FROM Comment c WHERE c.id = :commentId") | ||
| Optional<Comment> findByIdForUpdate(@Param("commentId") Long commentId); | ||
|
|
||
| long countByParentIdAndIsDeletedFalse(Long parentId); | ||
|
|
||
| @Query( | ||
| "SELECT c FROM Comment c LEFT JOIN FETCH c.member WHERE c.post.id = :postId ORDER BY c.createdAt ASC, c.id ASC") | ||
| List<Comment> findByPostIdWithMember(@Param("postId") Long postId); | ||
| @Lock(LockModeType.PESSIMISTIC_READ) | ||
| @Query("SELECT c.id FROM Comment c WHERE c.parent.id = :parentId AND c.isDeleted = false") | ||
| List<Long> findActiveReplyIdsForUpdate(@Param("parentId") Long parentId); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| package com.ikae.snowthing.domain.comment.repository; | ||
|
|
||
| import java.time.LocalDateTime; | ||
| import java.util.List; | ||
| import java.util.Map; | ||
| import java.util.Optional; | ||
|
|
||
| import com.ikae.snowthing.domain.comment.dto.CommentResponse; | ||
|
|
||
| public interface CommentRepositoryCustom { | ||
|
|
||
| record CursorPosition(LocalDateTime createdAt, Long commentId) {} | ||
|
|
||
| Optional<CursorPosition> findRootCursor(Long postId, Long cursorId); | ||
|
|
||
| Optional<CursorPosition> findReplyCursor(Long rootCommentId, Long cursorId); | ||
|
|
||
| List<CommentResponse> findRootComments(Long postId, CursorPosition cursor, int fetchSize); | ||
|
|
||
| Map<Long, List<CommentResponse>> findTopReplyPreviews(List<Long> rootCommentIds); | ||
|
|
||
| List<CommentResponse> findReplies(Long rootCommentId, CursorPosition cursor, int fetchSize); | ||
|
|
||
| long countActiveReplies(Long rootCommentId); | ||
| } |
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔒 Security & Privacy | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
Repository: devikae/snowthing
Length of output: 155
🏁 Script executed:
Repository: devikae/snowthing
Length of output: 50373
🏁 Script executed:
Repository: devikae/snowthing
Length of output: 50373
🏁 Script executed:
Repository: devikae/snowthing
Length of output: 50373
🏁 Script executed:
Repository: devikae/snowthing
Length of output: 50373
🏁 Script executed:
Repository: devikae/snowthing
Length of output: 26311
🏁 Script executed:
Repository: devikae/snowthing
Length of output: 26107
Broken Authentication (CWE-307): Improper Restriction of Excessive Authentication Attempts
Reachability: External · Exploitability: Moderate
익명 댓글 비밀번호 검증에 시도 횟수 제한을 추가하세요
/api/v1/comments/**는 인증 없이 접근할 수 있습니다.validateUpdatePermission과validateDeletePermission은 실패 시도 제한 없이 매번passwordEncoder.matches를 실행합니다. 비밀번호 정책도 없어"1234"같은 4자리 비밀번호가 허용됩니다.IDENTITY기반Long댓글 ID와 결합하면 공격자는 ID를 열거하고 비밀번호를 대입하여 댓글을 수정하거나 삭제할 수 있습니다. BCrypt 연산과 트랜잭션이 반복되므로 요청 스레드와 DB 커넥션도 고갈될 수 있습니다.두 검증 경로에 분산 원자 카운터, 시도 제한, 잠금 또는 지연을 공통 적용하세요. 성공 시 카운터를 초기화하세요.
ClientIpResolver가 임의의X-Forwarded-For값을 신뢰하지 않도록 신뢰 프록시 범위를 설정하세요. 생성 시 비밀번호의 최소 길이와 엔트로피도 제한하세요.🤖 Prompt for AI Agents
Source: Path instructions
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
리뷰처리가 완료되었다면 resolve처리를 해주세요
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
Length of output: 31995
@yyy9942, 현재 PR 브랜치에서 지적 사항이 아직 수정되지 않았습니다.validateUpdatePermission과validateDeletePermission은 여전히 제한 없이passwordEncoder.matches를 실행합니다. 익명 비밀번호에 대한 길이 또는 복잡도 검증도 없습니다. 테스트는 약한 비밀번호인"1234"를 허용합니다.따라서 이 리뷰 스레드는 resolve하지 않습니다.
You are interacting with an AI system.