From bc4022920dda7bec4837eabf07395ba82f92e636 Mon Sep 17 00:00:00 2001 From: N-yujeong <1108dbwjd@gmail.com> Date: Tue, 28 Apr 2026 23:26:16 +0900 Subject: [PATCH 1/2] =?UTF-8?q?=EB=82=A8=EC=9C=A0=EC=A0=95/4=EC=A3=BC?= =?UTF-8?q?=EC=B0=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/settings.local.json | 7 ++ .../comment/controller/CommentController.java | 27 ++++++ .../controller/CommentControllerDocs.java | 31 +++++++ .../domain/comment/entity/Comment.java | 19 ++++ .../comment/repository/CommentRepository.java | 9 ++ .../comment/service/CommentService.java | 48 ++++++++++ .../post/controller/PostController.java | 71 ++++---------- .../post/controller/PostControllerDocs.java | 93 +++++++++++++++++++ .../leets7th/domain/post/entity/Post.java | 10 ++ .../domain/post/entity/PostStatus.java | 6 ++ .../domain/post/service/PostService.java | 62 ++++++++----- .../report/controller/ReportController.java | 45 +++++++++ .../controller/ReportControllerDocs.java | 55 +++++++++++ .../domain/report/dto/ReportRequest.java | 11 +++ .../leets7th/domain/report/entity/Report.java | 57 ++++++++++++ .../domain/report/entity/ReportStatus.java | 6 ++ .../report/entity/ReportTargetType.java | 6 ++ .../report/repository/ReportRepository.java | 10 ++ .../domain/report/service/ReportService.java | 81 ++++++++++++++++ .../exception/AlreadyAdoptedException.java | 8 ++ .../exception/AlreadyHiddenException.java | 8 ++ .../exception/AlreadyResolvedException.java | 8 ++ .../exception/CategoryNotFoundException.java | 8 ++ .../exception/CommentNotFoundException.java | 8 ++ .../exception/DuplicateReportException.java | 8 ++ .../exception/GlobalExceptionHandler.java | 48 ++++++++++ .../exception/ReportNotFoundException.java | 8 ++ .../exception/UserNotFoundException.java | 8 ++ 28 files changed, 690 insertions(+), 76 deletions(-) create mode 100644 .claude/settings.local.json create mode 100644 src/main/java/com/example/leets7th/domain/comment/controller/CommentController.java create mode 100644 src/main/java/com/example/leets7th/domain/comment/controller/CommentControllerDocs.java create mode 100644 src/main/java/com/example/leets7th/domain/comment/repository/CommentRepository.java create mode 100644 src/main/java/com/example/leets7th/domain/comment/service/CommentService.java create mode 100644 src/main/java/com/example/leets7th/domain/post/controller/PostControllerDocs.java create mode 100644 src/main/java/com/example/leets7th/domain/post/entity/PostStatus.java create mode 100644 src/main/java/com/example/leets7th/domain/report/controller/ReportController.java create mode 100644 src/main/java/com/example/leets7th/domain/report/controller/ReportControllerDocs.java create mode 100644 src/main/java/com/example/leets7th/domain/report/dto/ReportRequest.java create mode 100644 src/main/java/com/example/leets7th/domain/report/entity/Report.java create mode 100644 src/main/java/com/example/leets7th/domain/report/entity/ReportStatus.java create mode 100644 src/main/java/com/example/leets7th/domain/report/entity/ReportTargetType.java create mode 100644 src/main/java/com/example/leets7th/domain/report/repository/ReportRepository.java create mode 100644 src/main/java/com/example/leets7th/domain/report/service/ReportService.java create mode 100644 src/main/java/com/example/leets7th/global/exception/AlreadyAdoptedException.java create mode 100644 src/main/java/com/example/leets7th/global/exception/AlreadyHiddenException.java create mode 100644 src/main/java/com/example/leets7th/global/exception/AlreadyResolvedException.java create mode 100644 src/main/java/com/example/leets7th/global/exception/CategoryNotFoundException.java create mode 100644 src/main/java/com/example/leets7th/global/exception/CommentNotFoundException.java create mode 100644 src/main/java/com/example/leets7th/global/exception/DuplicateReportException.java create mode 100644 src/main/java/com/example/leets7th/global/exception/ReportNotFoundException.java create mode 100644 src/main/java/com/example/leets7th/global/exception/UserNotFoundException.java diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 00000000..dccad168 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "Bash(./gradlew compileJava)" + ] + } +} diff --git a/src/main/java/com/example/leets7th/domain/comment/controller/CommentController.java b/src/main/java/com/example/leets7th/domain/comment/controller/CommentController.java new file mode 100644 index 00000000..ae5b9321 --- /dev/null +++ b/src/main/java/com/example/leets7th/domain/comment/controller/CommentController.java @@ -0,0 +1,27 @@ +package com.example.leets7th.domain.comment.controller; + +import com.example.leets7th.domain.comment.service.CommentService; +import com.example.leets7th.global.common.ApiResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +@RestController +@RequestMapping("/api/posts/{postId}/comments") +@RequiredArgsConstructor +public class CommentController implements CommentControllerDocs { + + private final CommentService commentService; + + @PatchMapping("/{commentId}/adopt") + public ResponseEntity>> adoptComment( + @PathVariable Long postId, + @PathVariable Long commentId, + @RequestHeader(value = "X-User-Id", defaultValue = "1") Long userId + ) { + commentService.adoptComment(postId, commentId, userId); + return ResponseEntity.ok(ApiResponse.success(Map.of("message", "댓글이 채택되었습니다."))); + } +} diff --git a/src/main/java/com/example/leets7th/domain/comment/controller/CommentControllerDocs.java b/src/main/java/com/example/leets7th/domain/comment/controller/CommentControllerDocs.java new file mode 100644 index 00000000..3a8ccfa1 --- /dev/null +++ b/src/main/java/com/example/leets7th/domain/comment/controller/CommentControllerDocs.java @@ -0,0 +1,31 @@ +package com.example.leets7th.domain.comment.controller; + +import com.example.leets7th.global.common.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestHeader; + +import java.util.Map; + +@Tag(name = "Comment", description = "댓글 관련 API") +public interface CommentControllerDocs { + + @Operation(summary = "댓글 채택", description = "게시글 작성자가 댓글을 채택합니다. 게시글당 하나의 댓글만 채택 가능합니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "채택 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "해당 게시글의 댓글이 아님"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "채택 권한 없음 (게시글 작성자가 아님)"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "게시글 또는 댓글 없음"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "이미 채택된 댓글 존재") + }) + ResponseEntity>> adoptComment( + @Parameter(description = "게시글 ID", example = "1") @PathVariable Long postId, + @Parameter(description = "댓글 ID", example = "1") @PathVariable Long commentId, + @Parameter(description = "요청자 ID (임시)", example = "1") + @RequestHeader(value = "X-User-Id", defaultValue = "1") Long userId + ); +} diff --git a/src/main/java/com/example/leets7th/domain/comment/entity/Comment.java b/src/main/java/com/example/leets7th/domain/comment/entity/Comment.java index cfaa6efd..9cc3121d 100644 --- a/src/main/java/com/example/leets7th/domain/comment/entity/Comment.java +++ b/src/main/java/com/example/leets7th/domain/comment/entity/Comment.java @@ -5,8 +5,11 @@ import com.example.leets7th.global.entity.BaseEntity; import jakarta.persistence.*; import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.AccessLevel; @Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) @Entity @Table(name = "comments") public class Comment extends BaseEntity { @@ -29,6 +32,22 @@ public class Comment extends BaseEntity { @JoinColumn(name = "post_id", nullable = false) private Post post; + // 채택 여부 + @Column(nullable = false) + private boolean adopted = false; + + public static Comment create(String content, User user, Post post) { + Comment comment = new Comment(); + comment.content = content; + comment.user = user; + comment.post = post; + return comment; + } + + public void adopt() { + this.adopted = true; + } + // 연관관계 편의 메서드 public void setUser(User user) { this.user = user; diff --git a/src/main/java/com/example/leets7th/domain/comment/repository/CommentRepository.java b/src/main/java/com/example/leets7th/domain/comment/repository/CommentRepository.java new file mode 100644 index 00000000..d6afde19 --- /dev/null +++ b/src/main/java/com/example/leets7th/domain/comment/repository/CommentRepository.java @@ -0,0 +1,9 @@ +package com.example.leets7th.domain.comment.repository; + +import com.example.leets7th.domain.comment.entity.Comment; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface CommentRepository extends JpaRepository { + + boolean existsByPostIdAndAdoptedTrue(Long postId); +} diff --git a/src/main/java/com/example/leets7th/domain/comment/service/CommentService.java b/src/main/java/com/example/leets7th/domain/comment/service/CommentService.java new file mode 100644 index 00000000..aa87bb82 --- /dev/null +++ b/src/main/java/com/example/leets7th/domain/comment/service/CommentService.java @@ -0,0 +1,48 @@ +package com.example.leets7th.domain.comment.service; + +import com.example.leets7th.domain.comment.entity.Comment; +import com.example.leets7th.domain.comment.repository.CommentRepository; +import com.example.leets7th.domain.post.entity.Post; +import com.example.leets7th.domain.post.repository.PostRepository; +import com.example.leets7th.global.exception.AlreadyAdoptedException; +import com.example.leets7th.global.exception.CommentNotFoundException; +import com.example.leets7th.global.exception.ForbiddenException; +import com.example.leets7th.global.exception.PostNotFoundException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class CommentService { + + private final CommentRepository commentRepository; + private final PostRepository postRepository; + + @Transactional + public void adoptComment(Long postId, Long commentId, Long userId) { + Post post = postRepository.findById(postId) + .orElseThrow(() -> new PostNotFoundException(postId)); + + // 게시글 작성자만 채택 가능 + if (!post.getUser().getId().equals(userId)) { + throw new ForbiddenException("게시글 작성자만 댓글을 채택할 수 있습니다."); + } + + Comment comment = commentRepository.findById(commentId) + .orElseThrow(() -> new CommentNotFoundException(commentId)); + + // 해당 게시글의 댓글인지 확인 + if (!comment.getPost().getId().equals(postId)) { + throw new IllegalArgumentException("해당 게시글에 속한 댓글이 아닙니다."); + } + + // 이미 채택된 댓글이 있는지 확인 + if (commentRepository.existsByPostIdAndAdoptedTrue(postId)) { + throw new AlreadyAdoptedException(); + } + + comment.adopt(); + } +} diff --git a/src/main/java/com/example/leets7th/domain/post/controller/PostController.java b/src/main/java/com/example/leets7th/domain/post/controller/PostController.java index e8542e5a..47056870 100644 --- a/src/main/java/com/example/leets7th/domain/post/controller/PostController.java +++ b/src/main/java/com/example/leets7th/domain/post/controller/PostController.java @@ -6,10 +6,6 @@ import com.example.leets7th.domain.post.dto.response.PostListResponse; import com.example.leets7th.domain.post.service.PostService; import com.example.leets7th.global.common.ApiResponse; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.responses.ApiResponses; -import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import jakarta.validation.constraints.Min; import lombok.RequiredArgsConstructor; @@ -20,97 +16,62 @@ import java.util.Map; -@Tag(name = "Post", description = "게시글 관련 API") @RestController @RequestMapping("/api/posts") @RequiredArgsConstructor @Validated -public class PostController { +public class PostController implements PostControllerDocs { private final PostService postService; - // 게시글 목록 조회 - @Operation(summary = "게시글 목록 조회", description = "페이지 단위로 게시글 목록을 조회합니다.") - @ApiResponses({ - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "조회 성공") - }) @GetMapping public ResponseEntity> getPosts( - @Parameter(description = "페이지 번호 (0부터 시작)", example = "0") @RequestParam(defaultValue = "0") @Min(value = 0, message = "페이지 번호는 0 이상이어야 합니다.") int page, - @Parameter(description = "페이지 크기", example = "10") @RequestParam(defaultValue = "10") @Min(value = 1, message = "페이지 크기는 1 이상이어야 합니다.") int size ) { - PostListResponse response = postService.getPosts(page, size); - return ResponseEntity.ok(ApiResponse.success("POST_LIST_SUCCESS", "게시글 목록 조회 성공", response)); + return ResponseEntity.ok(ApiResponse.success("POST_LIST_SUCCESS", "게시글 목록 조회 성공", postService.getPosts(page, size))); } - // 게시글 상세 조회 - @Operation(summary = "게시글 상세 조회", description = "게시글 ID로 단건 조회합니다.") - @ApiResponses({ - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "조회 성공"), - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "게시글 없음") - }) @GetMapping("/{postId}") - public ResponseEntity> getPost( - @Parameter(description = "게시글 ID", example = "1") @PathVariable Long postId) { - PostDetailResponse response = postService.getPost(postId); - return ResponseEntity.ok(ApiResponse.success("POST_DETAIL_SUCCESS", "게시글 상세 조회 성공", response)); + public ResponseEntity> getPost(@PathVariable Long postId) { + return ResponseEntity.ok(ApiResponse.success("POST_DETAIL_SUCCESS", "게시글 상세 조회 성공", postService.getPost(postId))); } - // 게시글 작성 // TODO: 실제 인증 구현 시 @AuthenticationPrincipal로 userId 주입 - @Operation(summary = "게시글 작성", description = "새 게시글을 생성합니다. X-User-Id 헤더로 작성자를 지정합니다.") - @ApiResponses({ - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "201", description = "생성 성공"), - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "입력값 오류") - }) @PostMapping public ResponseEntity>> createPost( @RequestBody @Valid PostCreateRequest request, - @Parameter(description = "작성자 ID (임시)", example = "1") @RequestHeader(value = "X-User-Id", defaultValue = "1") Long userId ) { Long postId = postService.createPost(request, userId); - Map data = Map.of( - "postId", postId, - "message", "게시글이 생성되었습니다." - ); - return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.success(data)); + return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.success(Map.of("postId", postId, "message", "게시글이 생성되었습니다."))); } - // 게시글 수정 - @Operation(summary = "게시글 수정", description = "게시글 제목/내용을 수정합니다. 작성자만 수정 가능합니다.") - @ApiResponses({ - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "수정 성공"), - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "수정 권한 없음"), - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "게시글 없음") - }) @PatchMapping("/{postId}") public ResponseEntity>> updatePost( - @Parameter(description = "게시글 ID", example = "1") @PathVariable Long postId, + @PathVariable Long postId, @RequestBody PostUpdateRequest request, - @Parameter(description = "요청자 ID (임시)", example = "1") @RequestHeader(value = "X-User-Id", defaultValue = "1") Long userId ) { postService.updatePost(postId, request, userId); return ResponseEntity.ok(ApiResponse.success(Map.of("message", "게시글이 수정되었습니다."))); } - // 게시글 삭제 - @Operation(summary = "게시글 삭제", description = "게시글을 삭제합니다. 작성자만 삭제 가능합니다.") - @ApiResponses({ - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "삭제 성공"), - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "삭제 권한 없음"), - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "게시글 없음") - }) @DeleteMapping("/{postId}") public ResponseEntity>> deletePost( - @Parameter(description = "게시글 ID", example = "1") @PathVariable Long postId, - @Parameter(description = "요청자 ID (임시)", example = "1") + @PathVariable Long postId, @RequestHeader(value = "X-User-Id", defaultValue = "1") Long userId ) { postService.deletePost(postId, userId); return ResponseEntity.ok(ApiResponse.success(Map.of("message", "게시글이 삭제되었습니다."))); } + + @PatchMapping("/{postId}/hide") + public ResponseEntity>> hidePost( + @PathVariable Long postId, + @RequestHeader(value = "X-User-Id", defaultValue = "1") Long userId + ) { + postService.hidePost(postId, userId); + return ResponseEntity.ok(ApiResponse.success(Map.of("message", "게시글이 숨김 처리되었습니다."))); + } } diff --git a/src/main/java/com/example/leets7th/domain/post/controller/PostControllerDocs.java b/src/main/java/com/example/leets7th/domain/post/controller/PostControllerDocs.java new file mode 100644 index 00000000..054b39d5 --- /dev/null +++ b/src/main/java/com/example/leets7th/domain/post/controller/PostControllerDocs.java @@ -0,0 +1,93 @@ +package com.example.leets7th.domain.post.controller; + +import com.example.leets7th.domain.post.dto.request.PostCreateRequest; +import com.example.leets7th.domain.post.dto.request.PostUpdateRequest; +import com.example.leets7th.domain.post.dto.response.PostDetailResponse; +import com.example.leets7th.domain.post.dto.response.PostListResponse; +import com.example.leets7th.global.common.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.validation.constraints.Min; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestParam; + +import java.util.Map; + +@Tag(name = "Post", description = "게시글 관련 API") +public interface PostControllerDocs { + + @Operation(summary = "게시글 목록 조회", description = "페이지 단위로 게시글 목록을 조회합니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "조회 성공") + }) + ResponseEntity> getPosts( + @Parameter(description = "페이지 번호 (0부터 시작)", example = "0") + @RequestParam(defaultValue = "0") @Min(value = 0, message = "페이지 번호는 0 이상이어야 합니다.") int page, + @Parameter(description = "페이지 크기", example = "10") + @RequestParam(defaultValue = "10") @Min(value = 1, message = "페이지 크기는 1 이상이어야 합니다.") int size + ); + + @Operation(summary = "게시글 상세 조회", description = "게시글 ID로 단건 조회합니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "조회 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "게시글 없음") + }) + ResponseEntity> getPost( + @Parameter(description = "게시글 ID", example = "1") @PathVariable Long postId + ); + + @Operation(summary = "게시글 작성", description = "새 게시글을 생성합니다. X-User-Id 헤더로 작성자를 지정합니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "201", description = "생성 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400", description = "입력값 오류") + }) + ResponseEntity>> createPost( + @RequestBody @Valid PostCreateRequest request, + @Parameter(description = "작성자 ID (임시)", example = "1") + @RequestHeader(value = "X-User-Id", defaultValue = "1") Long userId + ); + + @Operation(summary = "게시글 수정", description = "게시글 제목/내용을 수정합니다. 작성자만 수정 가능합니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "수정 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "수정 권한 없음"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "게시글 없음") + }) + ResponseEntity>> updatePost( + @Parameter(description = "게시글 ID", example = "1") @PathVariable Long postId, + @RequestBody PostUpdateRequest request, + @Parameter(description = "요청자 ID (임시)", example = "1") + @RequestHeader(value = "X-User-Id", defaultValue = "1") Long userId + ); + + @Operation(summary = "게시글 삭제", description = "게시글을 삭제합니다. 작성자만 삭제 가능합니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "삭제 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "삭제 권한 없음"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "게시글 없음") + }) + ResponseEntity>> deletePost( + @Parameter(description = "게시글 ID", example = "1") @PathVariable Long postId, + @Parameter(description = "요청자 ID (임시)", example = "1") + @RequestHeader(value = "X-User-Id", defaultValue = "1") Long userId + ); + + @Operation(summary = "게시글 숨김", description = "게시글 상태를 ACTIVE에서 HIDDEN으로 변경합니다. 작성자만 가능합니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "숨김 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "403", description = "숨김 권한 없음"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "게시글 없음"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "이미 숨김 처리된 게시글") + }) + ResponseEntity>> hidePost( + @Parameter(description = "게시글 ID", example = "1") @PathVariable Long postId, + @Parameter(description = "요청자 ID (임시)", example = "1") + @RequestHeader(value = "X-User-Id", defaultValue = "1") Long userId + ); +} diff --git a/src/main/java/com/example/leets7th/domain/post/entity/Post.java b/src/main/java/com/example/leets7th/domain/post/entity/Post.java index b517a555..fd8807e7 100644 --- a/src/main/java/com/example/leets7th/domain/post/entity/Post.java +++ b/src/main/java/com/example/leets7th/domain/post/entity/Post.java @@ -52,6 +52,11 @@ public class Post extends BaseEntity { @OrderBy("imageOrder ASC") private List images = new ArrayList<>(); + // 게시글 상태 + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private PostStatus status = PostStatus.ACTIVE; + public static Post create(String title, String content, String thumbnailImageUrl, User user, Category category) { Post post = new Post(); post.title = title; @@ -59,9 +64,14 @@ public static Post create(String title, String content, String thumbnailImageUrl post.thumbnailImageUrl = thumbnailImageUrl; post.user = user; post.category = category; + post.status = PostStatus.ACTIVE; return post; } + public void hide() { + this.status = PostStatus.HIDDEN; + } + public void update(String title, String content) { if (title != null && !title.isBlank()) { this.title = title; diff --git a/src/main/java/com/example/leets7th/domain/post/entity/PostStatus.java b/src/main/java/com/example/leets7th/domain/post/entity/PostStatus.java new file mode 100644 index 00000000..e914f87f --- /dev/null +++ b/src/main/java/com/example/leets7th/domain/post/entity/PostStatus.java @@ -0,0 +1,6 @@ +package com.example.leets7th.domain.post.entity; + +public enum PostStatus { + ACTIVE, + HIDDEN +} diff --git a/src/main/java/com/example/leets7th/domain/post/service/PostService.java b/src/main/java/com/example/leets7th/domain/post/service/PostService.java index 1330904c..18236fa0 100644 --- a/src/main/java/com/example/leets7th/domain/post/service/PostService.java +++ b/src/main/java/com/example/leets7th/domain/post/service/PostService.java @@ -7,11 +7,15 @@ import com.example.leets7th.domain.post.dto.response.PostDetailResponse; import com.example.leets7th.domain.post.dto.response.PostListResponse; import com.example.leets7th.domain.post.entity.Post; +import com.example.leets7th.domain.post.entity.PostStatus; import com.example.leets7th.domain.post.repository.PostRepository; import com.example.leets7th.domain.user.entity.User; import com.example.leets7th.domain.user.repository.UserRepository; +import com.example.leets7th.global.exception.AlreadyHiddenException; +import com.example.leets7th.global.exception.CategoryNotFoundException; import com.example.leets7th.global.exception.ForbiddenException; import com.example.leets7th.global.exception.PostNotFoundException; +import com.example.leets7th.global.exception.UserNotFoundException; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; @@ -36,19 +40,13 @@ public PostListResponse getPosts(int page, int size) { } public PostDetailResponse getPost(Long postId) { - Post post = postRepository.findById(postId) - .orElseThrow(() -> new PostNotFoundException(postId)); - return PostDetailResponse.from(post); + return PostDetailResponse.from(findPostById(postId)); } @Transactional public Long createPost(PostCreateRequest request, Long userId) { - User user = userRepository.findById(userId) - .orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다.")); - - Category category = categoryRepository.findById(request.categoryId()) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 카테고리입니다. id=" + request.categoryId())); - + User user = findUserById(userId); + Category category = findCategoryById(request.categoryId()); Post post = Post.create(request.title(), request.content(), null, user, category); postRepository.save(post); return post.getId(); @@ -56,26 +54,48 @@ public Long createPost(PostCreateRequest request, Long userId) { @Transactional public void updatePost(Long postId, PostUpdateRequest request, Long userId) { - Post post = postRepository.findById(postId) - .orElseThrow(() -> new PostNotFoundException(postId)); - - if (!post.getUser().getId().equals(userId)) { - // 수정 spec에서 작성자와 수정자가 다름 -> 400 - throw new IllegalArgumentException("작성자만 수정할 수 있습니다."); - } - + Post post = findPostById(postId); + validateAuthor(post, userId, "작성자만 수정할 수 있습니다."); post.update(request.title(), request.content()); } @Transactional public void deletePost(Long postId, Long userId) { - Post post = postRepository.findById(postId) + Post post = findPostById(postId); + validateAuthor(post, userId, "삭제 권한이 없습니다."); + postRepository.delete(post); + } + + @Transactional + public void hidePost(Long postId, Long userId) { + Post post = findPostById(postId); + validateAuthor(post, userId, "숨김 권한이 없습니다."); + + if (post.getStatus() == PostStatus.HIDDEN) { + throw new AlreadyHiddenException(); + } + + post.hide(); + } + + private Post findPostById(Long postId) { + return postRepository.findById(postId) .orElseThrow(() -> new PostNotFoundException(postId)); + } + private User findUserById(Long userId) { + return userRepository.findById(userId) + .orElseThrow(() -> new UserNotFoundException(userId)); + } + + private Category findCategoryById(Long categoryId) { + return categoryRepository.findById(categoryId) + .orElseThrow(() -> new CategoryNotFoundException(categoryId)); + } + + private void validateAuthor(Post post, Long userId, String message) { if (!post.getUser().getId().equals(userId)) { - throw new ForbiddenException("삭제 권한이 없습니다."); + throw new ForbiddenException(message); } - - postRepository.delete(post); } } diff --git a/src/main/java/com/example/leets7th/domain/report/controller/ReportController.java b/src/main/java/com/example/leets7th/domain/report/controller/ReportController.java new file mode 100644 index 00000000..dbc2924e --- /dev/null +++ b/src/main/java/com/example/leets7th/domain/report/controller/ReportController.java @@ -0,0 +1,45 @@ +package com.example.leets7th.domain.report.controller; + +import com.example.leets7th.domain.report.dto.ReportRequest; +import com.example.leets7th.domain.report.service.ReportService; +import com.example.leets7th.global.common.ApiResponse; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.Map; + +@RestController +@RequiredArgsConstructor +public class ReportController implements ReportControllerDocs { + + private final ReportService reportService; + + @PostMapping("/api/posts/{postId}/reports") + public ResponseEntity>> reportPost( + @PathVariable Long postId, + @RequestBody @Valid ReportRequest request, + @RequestHeader(value = "X-User-Id", defaultValue = "1") Long userId + ) { + Long reportId = reportService.reportPost(postId, request, userId); + return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.success(Map.of("reportId", reportId, "message", "게시글이 신고되었습니다."))); + } + + @PostMapping("/api/comments/{commentId}/reports") + public ResponseEntity>> reportComment( + @PathVariable Long commentId, + @RequestBody @Valid ReportRequest request, + @RequestHeader(value = "X-User-Id", defaultValue = "1") Long userId + ) { + Long reportId = reportService.reportComment(commentId, request, userId); + return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.success(Map.of("reportId", reportId, "message", "댓글이 신고되었습니다."))); + } + + @PatchMapping("/api/reports/{reportId}/resolve") + public ResponseEntity>> resolveReport(@PathVariable Long reportId) { + reportService.resolveReport(reportId); + return ResponseEntity.ok(ApiResponse.success(Map.of("message", "신고가 처리되었습니다."))); + } +} diff --git a/src/main/java/com/example/leets7th/domain/report/controller/ReportControllerDocs.java b/src/main/java/com/example/leets7th/domain/report/controller/ReportControllerDocs.java new file mode 100644 index 00000000..2914b1db --- /dev/null +++ b/src/main/java/com/example/leets7th/domain/report/controller/ReportControllerDocs.java @@ -0,0 +1,55 @@ +package com.example.leets7th.domain.report.controller; + +import com.example.leets7th.domain.report.dto.ReportRequest; +import com.example.leets7th.global.common.ApiResponse; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; + +import java.util.Map; + +@Tag(name = "Report", description = "신고 관련 API") +public interface ReportControllerDocs { + + @Operation(summary = "게시글 신고", description = "게시글을 신고합니다. 동일한 게시글에 중복 신고는 불가합니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "201", description = "신고 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "게시글 없음"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "중복 신고") + }) + ResponseEntity>> reportPost( + @Parameter(description = "게시글 ID", example = "1") @PathVariable Long postId, + @RequestBody @Valid ReportRequest request, + @Parameter(description = "신고자 ID (임시)", example = "1") + @RequestHeader(value = "X-User-Id", defaultValue = "1") Long userId + ); + + @Operation(summary = "댓글 신고", description = "댓글을 신고합니다. 동일한 댓글에 중복 신고는 불가합니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "201", description = "신고 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "댓글 없음"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "중복 신고") + }) + ResponseEntity>> reportComment( + @Parameter(description = "댓글 ID", example = "1") @PathVariable Long commentId, + @RequestBody @Valid ReportRequest request, + @Parameter(description = "신고자 ID (임시)", example = "1") + @RequestHeader(value = "X-User-Id", defaultValue = "1") Long userId + ); + + @Operation(summary = "신고 처리 완료", description = "신고 상태를 PENDING에서 RESOLVED로 변경합니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "처리 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "신고 없음"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "이미 처리된 신고") + }) + ResponseEntity>> resolveReport( + @Parameter(description = "신고 ID", example = "1") @PathVariable Long reportId + ); +} diff --git a/src/main/java/com/example/leets7th/domain/report/dto/ReportRequest.java b/src/main/java/com/example/leets7th/domain/report/dto/ReportRequest.java new file mode 100644 index 00000000..be6d706c --- /dev/null +++ b/src/main/java/com/example/leets7th/domain/report/dto/ReportRequest.java @@ -0,0 +1,11 @@ +package com.example.leets7th.domain.report.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record ReportRequest( + @NotBlank(message = "신고 사유를 입력해주세요.") + @Size(max = 255, message = "신고 사유는 255자 이하여야 합니다.") + String reason +) { +} diff --git a/src/main/java/com/example/leets7th/domain/report/entity/Report.java b/src/main/java/com/example/leets7th/domain/report/entity/Report.java new file mode 100644 index 00000000..0d04d4e1 --- /dev/null +++ b/src/main/java/com/example/leets7th/domain/report/entity/Report.java @@ -0,0 +1,57 @@ +package com.example.leets7th.domain.report.entity; + +import com.example.leets7th.domain.user.entity.User; +import com.example.leets7th.global.entity.BaseEntity; +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Entity +@Table(name = "reports", + uniqueConstraints = @UniqueConstraint(columnNames = {"reporter_id", "target_type", "target_id"})) +public class Report extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + // 신고자 + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "reporter_id", nullable = false) + private User reporter; + + // 신고 대상 타입 (POST / COMMENT) + @Enumerated(EnumType.STRING) + @Column(name = "target_type", nullable = false) + private ReportTargetType targetType; + + // 신고 대상 ID + @Column(name = "target_id", nullable = false) + private Long targetId; + + // 신고 사유 + @Column(nullable = false) + private String reason; + + // 신고 상태 + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private ReportStatus status = ReportStatus.PENDING; + + public static Report create(User reporter, ReportTargetType targetType, Long targetId, String reason) { + Report report = new Report(); + report.reporter = reporter; + report.targetType = targetType; + report.targetId = targetId; + report.reason = reason; + report.status = ReportStatus.PENDING; + return report; + } + + public void resolve() { + this.status = ReportStatus.RESOLVED; + } +} diff --git a/src/main/java/com/example/leets7th/domain/report/entity/ReportStatus.java b/src/main/java/com/example/leets7th/domain/report/entity/ReportStatus.java new file mode 100644 index 00000000..7ad18ac3 --- /dev/null +++ b/src/main/java/com/example/leets7th/domain/report/entity/ReportStatus.java @@ -0,0 +1,6 @@ +package com.example.leets7th.domain.report.entity; + +public enum ReportStatus { + PENDING, + RESOLVED +} diff --git a/src/main/java/com/example/leets7th/domain/report/entity/ReportTargetType.java b/src/main/java/com/example/leets7th/domain/report/entity/ReportTargetType.java new file mode 100644 index 00000000..c8a0765d --- /dev/null +++ b/src/main/java/com/example/leets7th/domain/report/entity/ReportTargetType.java @@ -0,0 +1,6 @@ +package com.example.leets7th.domain.report.entity; + +public enum ReportTargetType { + POST, + COMMENT +} diff --git a/src/main/java/com/example/leets7th/domain/report/repository/ReportRepository.java b/src/main/java/com/example/leets7th/domain/report/repository/ReportRepository.java new file mode 100644 index 00000000..52681f32 --- /dev/null +++ b/src/main/java/com/example/leets7th/domain/report/repository/ReportRepository.java @@ -0,0 +1,10 @@ +package com.example.leets7th.domain.report.repository; + +import com.example.leets7th.domain.report.entity.Report; +import com.example.leets7th.domain.report.entity.ReportTargetType; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ReportRepository extends JpaRepository { + + boolean existsByReporterIdAndTargetTypeAndTargetId(Long reporterId, ReportTargetType targetType, Long targetId); +} diff --git a/src/main/java/com/example/leets7th/domain/report/service/ReportService.java b/src/main/java/com/example/leets7th/domain/report/service/ReportService.java new file mode 100644 index 00000000..9aeb2d68 --- /dev/null +++ b/src/main/java/com/example/leets7th/domain/report/service/ReportService.java @@ -0,0 +1,81 @@ +package com.example.leets7th.domain.report.service; + +import com.example.leets7th.domain.comment.repository.CommentRepository; +import com.example.leets7th.domain.post.repository.PostRepository; +import com.example.leets7th.domain.report.dto.ReportRequest; +import com.example.leets7th.domain.report.entity.Report; +import com.example.leets7th.domain.report.entity.ReportStatus; +import com.example.leets7th.domain.report.entity.ReportTargetType; +import com.example.leets7th.domain.report.repository.ReportRepository; +import com.example.leets7th.domain.user.entity.User; +import com.example.leets7th.domain.user.repository.UserRepository; +import com.example.leets7th.global.exception.AlreadyResolvedException; +import com.example.leets7th.global.exception.CommentNotFoundException; +import com.example.leets7th.global.exception.DuplicateReportException; +import com.example.leets7th.global.exception.PostNotFoundException; +import com.example.leets7th.global.exception.ReportNotFoundException; +import com.example.leets7th.global.exception.UserNotFoundException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ReportService { + + private final ReportRepository reportRepository; + private final PostRepository postRepository; + private final CommentRepository commentRepository; + private final UserRepository userRepository; + + @Transactional + public Long reportPost(Long postId, ReportRequest request, Long reporterId) { + // 게시글 존재 확인 + postRepository.findById(postId) + .orElseThrow(() -> new PostNotFoundException(postId)); + + User reporter = userRepository.findById(reporterId) + .orElseThrow(() -> new UserNotFoundException(reporterId)); + + // 중복 신고 방지 + if (reportRepository.existsByReporterIdAndTargetTypeAndTargetId(reporterId, ReportTargetType.POST, postId)) { + throw new DuplicateReportException(); + } + + Report report = Report.create(reporter, ReportTargetType.POST, postId, request.reason()); + reportRepository.save(report); + return report.getId(); + } + + @Transactional + public Long reportComment(Long commentId, ReportRequest request, Long reporterId) { + // 댓글 존재 확인 + commentRepository.findById(commentId) + .orElseThrow(() -> new CommentNotFoundException(commentId)); + + User reporter = userRepository.findById(reporterId) + .orElseThrow(() -> new UserNotFoundException(reporterId)); + + // 중복 신고 방지 + if (reportRepository.existsByReporterIdAndTargetTypeAndTargetId(reporterId, ReportTargetType.COMMENT, commentId)) { + throw new DuplicateReportException(); + } + + Report report = Report.create(reporter, ReportTargetType.COMMENT, commentId, request.reason()); + reportRepository.save(report); + return report.getId(); + } + + @Transactional + public void resolveReport(Long reportId) { + Report report = reportRepository.findById(reportId) + .orElseThrow(() -> new ReportNotFoundException(reportId)); + + if (report.getStatus() == ReportStatus.RESOLVED) { + throw new AlreadyResolvedException(); + } + + report.resolve(); + } +} diff --git a/src/main/java/com/example/leets7th/global/exception/AlreadyAdoptedException.java b/src/main/java/com/example/leets7th/global/exception/AlreadyAdoptedException.java new file mode 100644 index 00000000..9c37d370 --- /dev/null +++ b/src/main/java/com/example/leets7th/global/exception/AlreadyAdoptedException.java @@ -0,0 +1,8 @@ +package com.example.leets7th.global.exception; + +public class AlreadyAdoptedException extends RuntimeException { + + public AlreadyAdoptedException() { + super("이미 채택된 댓글이 존재합니다."); + } +} diff --git a/src/main/java/com/example/leets7th/global/exception/AlreadyHiddenException.java b/src/main/java/com/example/leets7th/global/exception/AlreadyHiddenException.java new file mode 100644 index 00000000..e6722e2b --- /dev/null +++ b/src/main/java/com/example/leets7th/global/exception/AlreadyHiddenException.java @@ -0,0 +1,8 @@ +package com.example.leets7th.global.exception; + +public class AlreadyHiddenException extends RuntimeException { + + public AlreadyHiddenException() { + super("이미 숨김 처리된 게시글입니다."); + } +} diff --git a/src/main/java/com/example/leets7th/global/exception/AlreadyResolvedException.java b/src/main/java/com/example/leets7th/global/exception/AlreadyResolvedException.java new file mode 100644 index 00000000..a3d08f54 --- /dev/null +++ b/src/main/java/com/example/leets7th/global/exception/AlreadyResolvedException.java @@ -0,0 +1,8 @@ +package com.example.leets7th.global.exception; + +public class AlreadyResolvedException extends RuntimeException { + + public AlreadyResolvedException() { + super("이미 처리된 신고입니다."); + } +} diff --git a/src/main/java/com/example/leets7th/global/exception/CategoryNotFoundException.java b/src/main/java/com/example/leets7th/global/exception/CategoryNotFoundException.java new file mode 100644 index 00000000..fd97d38d --- /dev/null +++ b/src/main/java/com/example/leets7th/global/exception/CategoryNotFoundException.java @@ -0,0 +1,8 @@ +package com.example.leets7th.global.exception; + +public class CategoryNotFoundException extends RuntimeException { + + public CategoryNotFoundException(Long categoryId) { + super("해당 카테고리를 찾을 수 없습니다. id=" + categoryId); + } +} diff --git a/src/main/java/com/example/leets7th/global/exception/CommentNotFoundException.java b/src/main/java/com/example/leets7th/global/exception/CommentNotFoundException.java new file mode 100644 index 00000000..e7faceab --- /dev/null +++ b/src/main/java/com/example/leets7th/global/exception/CommentNotFoundException.java @@ -0,0 +1,8 @@ +package com.example.leets7th.global.exception; + +public class CommentNotFoundException extends RuntimeException { + + public CommentNotFoundException(Long commentId) { + super("해당 댓글을 찾을 수 없습니다. id=" + commentId); + } +} diff --git a/src/main/java/com/example/leets7th/global/exception/DuplicateReportException.java b/src/main/java/com/example/leets7th/global/exception/DuplicateReportException.java new file mode 100644 index 00000000..35be80f6 --- /dev/null +++ b/src/main/java/com/example/leets7th/global/exception/DuplicateReportException.java @@ -0,0 +1,8 @@ +package com.example.leets7th.global.exception; + +public class DuplicateReportException extends RuntimeException { + + public DuplicateReportException() { + super("이미 신고한 대상입니다."); + } +} diff --git a/src/main/java/com/example/leets7th/global/exception/GlobalExceptionHandler.java b/src/main/java/com/example/leets7th/global/exception/GlobalExceptionHandler.java index 9268d331..6070a262 100644 --- a/src/main/java/com/example/leets7th/global/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/example/leets7th/global/exception/GlobalExceptionHandler.java @@ -21,6 +21,54 @@ public ResponseEntity> handlePostNotFound(PostNotFoundExceptio .body(ApiResponse.error("POST_NOT_FOUND", "해당 게시글을 찾을 수 없습니다.")); } + @ExceptionHandler(CommentNotFoundException.class) + public ResponseEntity> handleCommentNotFound(CommentNotFoundException e) { + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(ApiResponse.error("COMMENT_NOT_FOUND", e.getMessage())); + } + + @ExceptionHandler(ReportNotFoundException.class) + public ResponseEntity> handleReportNotFound(ReportNotFoundException e) { + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(ApiResponse.error("REPORT_NOT_FOUND", e.getMessage())); + } + + @ExceptionHandler(DuplicateReportException.class) + public ResponseEntity> handleDuplicateReport(DuplicateReportException e) { + return ResponseEntity.status(HttpStatus.CONFLICT) + .body(ApiResponse.error("DUPLICATE_REPORT", e.getMessage())); + } + + @ExceptionHandler(AlreadyAdoptedException.class) + public ResponseEntity> handleAlreadyAdopted(AlreadyAdoptedException e) { + return ResponseEntity.status(HttpStatus.CONFLICT) + .body(ApiResponse.error("ALREADY_ADOPTED", e.getMessage())); + } + + @ExceptionHandler(UserNotFoundException.class) + public ResponseEntity> handleUserNotFound(UserNotFoundException e) { + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(ApiResponse.error("USER_NOT_FOUND", e.getMessage())); + } + + @ExceptionHandler(CategoryNotFoundException.class) + public ResponseEntity> handleCategoryNotFound(CategoryNotFoundException e) { + return ResponseEntity.status(HttpStatus.NOT_FOUND) + .body(ApiResponse.error("CATEGORY_NOT_FOUND", e.getMessage())); + } + + @ExceptionHandler(AlreadyHiddenException.class) + public ResponseEntity> handleAlreadyHidden(AlreadyHiddenException e) { + return ResponseEntity.status(HttpStatus.CONFLICT) + .body(ApiResponse.error("ALREADY_HIDDEN", e.getMessage())); + } + + @ExceptionHandler(AlreadyResolvedException.class) + public ResponseEntity> handleAlreadyResolved(AlreadyResolvedException e) { + return ResponseEntity.status(HttpStatus.CONFLICT) + .body(ApiResponse.error("ALREADY_RESOLVED", e.getMessage())); + } + @ExceptionHandler(ForbiddenException.class) public ResponseEntity> handleForbidden(ForbiddenException e) { return ResponseEntity.status(HttpStatus.FORBIDDEN) diff --git a/src/main/java/com/example/leets7th/global/exception/ReportNotFoundException.java b/src/main/java/com/example/leets7th/global/exception/ReportNotFoundException.java new file mode 100644 index 00000000..c175b2ec --- /dev/null +++ b/src/main/java/com/example/leets7th/global/exception/ReportNotFoundException.java @@ -0,0 +1,8 @@ +package com.example.leets7th.global.exception; + +public class ReportNotFoundException extends RuntimeException { + + public ReportNotFoundException(Long reportId) { + super("해당 신고를 찾을 수 없습니다. id=" + reportId); + } +} diff --git a/src/main/java/com/example/leets7th/global/exception/UserNotFoundException.java b/src/main/java/com/example/leets7th/global/exception/UserNotFoundException.java new file mode 100644 index 00000000..7919e6eb --- /dev/null +++ b/src/main/java/com/example/leets7th/global/exception/UserNotFoundException.java @@ -0,0 +1,8 @@ +package com.example.leets7th.global.exception; + +public class UserNotFoundException extends RuntimeException { + + public UserNotFoundException(Long userId) { + super("해당 사용자를 찾을 수 없습니다. id=" + userId); + } +} From e7401a6c3f6584baa31cd204105c3b5b71bf471a Mon Sep 17 00:00:00 2001 From: N-yujeong <1108dbwjd@gmail.com> Date: Tue, 28 Apr 2026 23:56:37 +0900 Subject: [PATCH 2/2] =?UTF-8?q?=EB=82=A8=EC=9C=A0=EC=A0=95/4=EC=A3=BC?= =?UTF-8?q?=EC=B0=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../comment/controller/CommentController.java | 13 +++++++++++++ .../controller/CommentControllerDocs.java | 15 +++++++++++++++ .../domain/comment/dto/CommentCreateRequest.java | 9 +++++++++ .../domain/comment/service/CommentService.java | 16 ++++++++++++++++ 4 files changed, 53 insertions(+) create mode 100644 src/main/java/com/example/leets7th/domain/comment/dto/CommentCreateRequest.java diff --git a/src/main/java/com/example/leets7th/domain/comment/controller/CommentController.java b/src/main/java/com/example/leets7th/domain/comment/controller/CommentController.java index ae5b9321..9df689e5 100644 --- a/src/main/java/com/example/leets7th/domain/comment/controller/CommentController.java +++ b/src/main/java/com/example/leets7th/domain/comment/controller/CommentController.java @@ -1,8 +1,11 @@ package com.example.leets7th.domain.comment.controller; +import com.example.leets7th.domain.comment.dto.CommentCreateRequest; import com.example.leets7th.domain.comment.service.CommentService; import com.example.leets7th.global.common.ApiResponse; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -15,6 +18,16 @@ public class CommentController implements CommentControllerDocs { private final CommentService commentService; + @PostMapping + public ResponseEntity>> createComment( + @PathVariable Long postId, + @RequestBody @Valid CommentCreateRequest request, + @RequestHeader(value = "X-User-Id", defaultValue = "1") Long userId + ) { + Long commentId = commentService.createComment(postId, request, userId); + return ResponseEntity.status(HttpStatus.CREATED).body(ApiResponse.success(Map.of("commentId", commentId, "message", "댓글이 작성되었습니다."))); + } + @PatchMapping("/{commentId}/adopt") public ResponseEntity>> adoptComment( @PathVariable Long postId, diff --git a/src/main/java/com/example/leets7th/domain/comment/controller/CommentControllerDocs.java b/src/main/java/com/example/leets7th/domain/comment/controller/CommentControllerDocs.java index 3a8ccfa1..43b7f499 100644 --- a/src/main/java/com/example/leets7th/domain/comment/controller/CommentControllerDocs.java +++ b/src/main/java/com/example/leets7th/domain/comment/controller/CommentControllerDocs.java @@ -1,12 +1,15 @@ package com.example.leets7th.domain.comment.controller; +import com.example.leets7th.domain.comment.dto.CommentCreateRequest; import com.example.leets7th.global.common.ApiResponse; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; import java.util.Map; @@ -14,6 +17,18 @@ @Tag(name = "Comment", description = "댓글 관련 API") public interface CommentControllerDocs { + @Operation(summary = "댓글 작성", description = "게시글에 댓글을 작성합니다.") + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "201", description = "작성 성공"), + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404", description = "게시글 없음") + }) + ResponseEntity>> createComment( + @Parameter(description = "게시글 ID", example = "1") @PathVariable Long postId, + @RequestBody @Valid CommentCreateRequest request, + @Parameter(description = "작성자 ID (임시)", example = "1") + @RequestHeader(value = "X-User-Id", defaultValue = "1") Long userId + ); + @Operation(summary = "댓글 채택", description = "게시글 작성자가 댓글을 채택합니다. 게시글당 하나의 댓글만 채택 가능합니다.") @ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "채택 성공"), diff --git a/src/main/java/com/example/leets7th/domain/comment/dto/CommentCreateRequest.java b/src/main/java/com/example/leets7th/domain/comment/dto/CommentCreateRequest.java new file mode 100644 index 00000000..480dce91 --- /dev/null +++ b/src/main/java/com/example/leets7th/domain/comment/dto/CommentCreateRequest.java @@ -0,0 +1,9 @@ +package com.example.leets7th.domain.comment.dto; + +import jakarta.validation.constraints.NotBlank; + +public record CommentCreateRequest( + @NotBlank(message = "댓글 내용을 입력해주세요.") + String content +) { +} diff --git a/src/main/java/com/example/leets7th/domain/comment/service/CommentService.java b/src/main/java/com/example/leets7th/domain/comment/service/CommentService.java index aa87bb82..8cb8a07e 100644 --- a/src/main/java/com/example/leets7th/domain/comment/service/CommentService.java +++ b/src/main/java/com/example/leets7th/domain/comment/service/CommentService.java @@ -1,13 +1,17 @@ package com.example.leets7th.domain.comment.service; +import com.example.leets7th.domain.comment.dto.CommentCreateRequest; import com.example.leets7th.domain.comment.entity.Comment; import com.example.leets7th.domain.comment.repository.CommentRepository; import com.example.leets7th.domain.post.entity.Post; import com.example.leets7th.domain.post.repository.PostRepository; +import com.example.leets7th.domain.user.entity.User; +import com.example.leets7th.domain.user.repository.UserRepository; import com.example.leets7th.global.exception.AlreadyAdoptedException; import com.example.leets7th.global.exception.CommentNotFoundException; import com.example.leets7th.global.exception.ForbiddenException; import com.example.leets7th.global.exception.PostNotFoundException; +import com.example.leets7th.global.exception.UserNotFoundException; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -19,6 +23,18 @@ public class CommentService { private final CommentRepository commentRepository; private final PostRepository postRepository; + private final UserRepository userRepository; + + @Transactional + public Long createComment(Long postId, CommentCreateRequest request, Long userId) { + Post post = postRepository.findById(postId) + .orElseThrow(() -> new PostNotFoundException(postId)); + User user = userRepository.findById(userId) + .orElseThrow(() -> new UserNotFoundException(userId)); + Comment comment = Comment.create(request.content(), user, post); + commentRepository.save(comment); + return comment.getId(); + } @Transactional public void adoptComment(Long postId, Long commentId, Long userId) {