From c02a7c9c01271a1aa50ac127c4bf6b4b6f0459dd Mon Sep 17 00:00:00 2001 From: si-zero Date: Wed, 21 Oct 2026 21:06:05 +0900 Subject: [PATCH] =?UTF-8?q?#559=20[Fix]=20=ED=81=B4=EB=9E=98=EC=8A=A4=20?= =?UTF-8?q?=EC=9D=98=EA=B2=AC=C2=B7=EB=8C=93=EA=B8=80=20=EC=8B=A0=EA=B3=A0?= =?UTF-8?q?=20API=20=ED=81=B4=EB=9E=98=EC=8A=A4=20=EA=B6=8C=ED=95=9C=20?= =?UTF-8?q?=EA=B2=80=EC=82=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/api-specs/class-api.md | 1 + .../controller/ReportController.java | 6 +- .../perspective/service/ReportService.java | 18 ++++- .../ClassBattleApiIntegrationTest.java | 65 +++++++++++++++++++ 4 files changed, 86 insertions(+), 4 deletions(-) diff --git a/docs/api-specs/class-api.md b/docs/api-specs/class-api.md index 38f53e12..fadf43cc 100644 --- a/docs/api-specs/class-api.md +++ b/docs/api-specs/class-api.md @@ -807,6 +807,7 @@ AI가 주제 후보를 제안하면 운영자가 하나를 고르거나 직접 | 의견 좋아요 수 | `GET /api/v1/perspectives/{perspectiveId}/likes` | 읽기 규칙 | | 의견 좋아요 등록·취소 | `POST` / `DELETE /api/v1/perspectives/{perspectiveId}/likes` | 쓰기 규칙 | | 댓글 좋아요 등록·취소 | `POST` / `DELETE /api/v1/comments/{commentId}/likes` | 쓰기 규칙 | +| 의견·댓글 신고 | `POST /api/v1/perspectives/{perspectiveId}/reports`, `POST /api/v1/perspectives/{perspectiveId}/comments/{commentId}/reports` | 읽기 규칙 (마감 후에도 신고 가능). 댓글이 경로의 관점에 속하지 않으면 404 `COMMENT_NOT_FOUND` | --- diff --git a/src/main/java/com/swyp/picke/domain/perspective/controller/ReportController.java b/src/main/java/com/swyp/picke/domain/perspective/controller/ReportController.java index eb227348..68615381 100644 --- a/src/main/java/com/swyp/picke/domain/perspective/controller/ReportController.java +++ b/src/main/java/com/swyp/picke/domain/perspective/controller/ReportController.java @@ -19,7 +19,7 @@ public class ReportController { private final ReportService reportService; - @Operation(summary = "관점 신고", description = "관점을 신고합니다. 신고 5회 누적 시 자동 숨김 처리됩니다.") + @Operation(summary = "관점 신고", description = "관점을 신고합니다. 신고 10회 누적 시 자동 숨김 처리됩니다. 클래스 의견이면 운영자·참여 중인 멤버만 신고할 수 있습니다(403 CLASS_403_MEMBER).") @PostMapping("/perspectives/{perspectiveId}/reports") public ApiResponse reportPerspective( @PathVariable Long perspectiveId, @@ -28,13 +28,13 @@ public ApiResponse reportPerspective( return ApiResponse.onSuccess(null); } - @Operation(summary = "댓글 신고", description = "댓글을 신고합니다. 신고 5회 누적 시 자동 숨김 처리됩니다.") + @Operation(summary = "댓글 신고", description = "댓글을 신고합니다. 신고 10회 누적 시 자동 숨김 처리됩니다. 댓글이 경로의 관점에 속하지 않으면 404 COMMENT_NOT_FOUND, 클래스 댓글이면 운영자·참여 중인 멤버만 신고할 수 있습니다(403 CLASS_403_MEMBER).") @PostMapping("/perspectives/{perspectiveId}/comments/{commentId}/reports") public ApiResponse reportComment( @PathVariable Long perspectiveId, @PathVariable Long commentId, @AuthenticationPrincipal Long userId) { - reportService.reportComment(commentId, userId); + reportService.reportComment(perspectiveId, commentId, userId); return ApiResponse.onSuccess(null); } } diff --git a/src/main/java/com/swyp/picke/domain/perspective/service/ReportService.java b/src/main/java/com/swyp/picke/domain/perspective/service/ReportService.java index 604614e3..8bba7c19 100644 --- a/src/main/java/com/swyp/picke/domain/perspective/service/ReportService.java +++ b/src/main/java/com/swyp/picke/domain/perspective/service/ReportService.java @@ -1,5 +1,7 @@ package com.swyp.picke.domain.perspective.service; +import com.swyp.picke.domain.classroom.entity.Classroom; +import com.swyp.picke.domain.classroom.service.ClassAccessService; import com.swyp.picke.domain.perspective.entity.CommentReport; import com.swyp.picke.domain.perspective.entity.Perspective; import com.swyp.picke.domain.perspective.entity.PerspectiveComment; @@ -16,6 +18,10 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +/** + * 클래스 의견·댓글은 클래스 읽기 권한(운영자·참여 중인 멤버)이 있어야 신고할 수 있다. + * 볼 수 있는 글은 마감 후에도 신고할 수 있도록 쓰기 권한이 아닌 읽기 권한으로 판정한다. + */ @Service @RequiredArgsConstructor public class ReportService { @@ -27,11 +33,13 @@ public class ReportService { private final PerspectiveReportRepository perspectiveReportRepository; private final CommentReportRepository commentReportRepository; private final UserRepository userRepository; + private final ClassAccessService classAccessService; @Transactional public void reportPerspective(Long perspectiveId, Long userId) { Perspective perspective = perspectiveRepository.findById(perspectiveId) .orElseThrow(() -> new CustomException(ErrorCode.PERSPECTIVE_NOT_FOUND)); + requireClassReadable(perspective.getClassroom(), userId); User user = userRepository.findById(userId) .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); @@ -54,9 +62,11 @@ public void reportPerspective(Long perspectiveId, Long userId) { } @Transactional - public void reportComment(Long commentId, Long userId) { + public void reportComment(Long perspectiveId, Long commentId, Long userId) { PerspectiveComment comment = commentRepository.findById(commentId) + .filter(c -> c.getPerspective().getId().equals(perspectiveId)) .orElseThrow(() -> new CustomException(ErrorCode.COMMENT_NOT_FOUND)); + requireClassReadable(comment.getPerspective().getClassroom(), userId); User user = userRepository.findById(userId) .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); @@ -77,4 +87,10 @@ public void reportComment(Long commentId, Long userId) { comment.hide(); } } + + private void requireClassReadable(Classroom classroom, Long userId) { + if (classroom != null) { + classAccessService.requireReadable(classroom, userId); + } + } } diff --git a/src/test/java/com/swyp/picke/domain/classroom/controller/ClassBattleApiIntegrationTest.java b/src/test/java/com/swyp/picke/domain/classroom/controller/ClassBattleApiIntegrationTest.java index e7456aa9..47c350cc 100644 --- a/src/test/java/com/swyp/picke/domain/classroom/controller/ClassBattleApiIntegrationTest.java +++ b/src/test/java/com/swyp/picke/domain/classroom/controller/ClassBattleApiIntegrationTest.java @@ -15,8 +15,10 @@ import com.swyp.picke.domain.classroom.repository.ClassroomRepository; import com.swyp.picke.domain.oauth.jwt.JwtProvider; import com.swyp.picke.domain.perspective.repository.CommentLikeRepository; +import com.swyp.picke.domain.perspective.repository.CommentReportRepository; import com.swyp.picke.domain.perspective.repository.PerspectiveCommentRepository; import com.swyp.picke.domain.perspective.repository.PerspectiveLikeRepository; +import com.swyp.picke.domain.perspective.repository.PerspectiveReportRepository; import com.swyp.picke.domain.perspective.repository.PerspectiveRepository; import com.swyp.picke.domain.ticket.entity.ClassTicket; import com.swyp.picke.domain.ticket.entity.FundingOrder; @@ -107,6 +109,10 @@ class ClassBattleApiIntegrationTest { private PerspectiveLikeRepository perspectiveLikeRepository; @Autowired private CommentLikeRepository commentLikeRepository; + @Autowired + private PerspectiveReportRepository perspectiveReportRepository; + @Autowired + private CommentReportRepository commentReportRepository; private final List createdBattleIds = new ArrayList<>(); private final List createdUserIds = new ArrayList<>(); @@ -144,6 +150,8 @@ void tearDown() { } private void cleanUp() { + commentReportRepository.deleteAll(); + perspectiveReportRepository.deleteAll(); commentLikeRepository.deleteAll(); perspectiveLikeRepository.deleteAll(); perspectiveCommentRepository.deleteAll(); @@ -413,6 +421,63 @@ void likes_followClassAccess() throws Exception { assertThat(commentLikeRepository.count()).isEqualTo(1); } + @Test + @DisplayName("클래스 의견·댓글 신고: 비멤버 403, 참여하지 않는 운영자·멤버는 마감 후에도 신고 가능, 다른 관점 경로의 댓글은 404") + void reports_requireClassReadAccess() throws Exception { + ClassInfo cls = createClass(false); + User writer = newUser("학생A"); + String writerToken = tokenOf(writer); + User reader = newUser("학생B"); + String readerToken = tokenOf(reader); + join(writerToken, cls.inviteCode, "학생A").andExpect(status().isOk()); + join(readerToken, cls.inviteCode, "학생B").andExpect(status().isOk()); + + vote(writerToken, cls, "/votes/pre", optionA).andExpect(status().isOk()); + vote(writerToken, cls, "/votes/post", optionA).andExpect(status().isOk()); + long perspectiveId = dataOf(mockMvc.perform(post(classBattle(cls) + "/perspectives") + .header("Authorization", "Bearer " + writerToken) + .contentType(MediaType.APPLICATION_JSON) + .content(json(Map.of("content", "신고 권한을 확인할 클래스 의견입니다.")))) + .andExpect(status().isOk()) + .andReturn()).get("perspectiveId").asLong(); + long commentId = dataOf(mockMvc.perform(post("/api/v1/perspectives/" + perspectiveId + "/comments") + .header("Authorization", "Bearer " + writerToken) + .contentType(MediaType.APPLICATION_JSON) + .content(json(Map.of("content", "신고 권한을 확인할 클래스 댓글입니다.")))) + .andExpect(status().isOk()) + .andReturn()).get("commentId").asLong(); + String perspectiveReports = "/api/v1/perspectives/" + perspectiveId + "/reports"; + String commentReports = "/api/v1/perspectives/" + perspectiveId + "/comments/" + commentId + "/reports"; + + // 클래스 밖 사용자는 403 + String strangerToken = tokenOf(newUser("외부인")); + mockMvc.perform(post(perspectiveReports).header("Authorization", "Bearer " + strangerToken)) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.error.code").value("CLASS_403_MEMBER")); + mockMvc.perform(post(commentReports).header("Authorization", "Bearer " + strangerToken)) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.error.code").value("CLASS_403_MEMBER")); + + // 댓글이 경로의 관점에 속하지 않으면 404 + mockMvc.perform(post("/api/v1/perspectives/" + (perspectiveId + 1000) + "/comments/" + commentId + "/reports") + .header("Authorization", "Bearer " + readerToken)) + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.error.code").value("COMMENT_NOT_FOUND")); + + // 참여하지 않는 운영자도 볼 수 있는 글이라 신고 가능 + mockMvc.perform(post(perspectiveReports).header("Authorization", "Bearer " + ownerToken)) + .andExpect(status().isOk()); + + // 마감 후에도 멤버는 신고 가능 + closeClass(cls.classId); + mockMvc.perform(post(perspectiveReports).header("Authorization", "Bearer " + readerToken)) + .andExpect(status().isOk()); + mockMvc.perform(post(commentReports).header("Authorization", "Bearer " + readerToken)) + .andExpect(status().isOk()); + assertThat(perspectiveReportRepository.count()).isEqualTo(2); + assertThat(commentReportRepository.count()).isEqualTo(1); + } + @Test @DisplayName("투표한 뒤 나가면 좌석은 유지(F15)되고, 재참여하면 진행 상태가 이어진다(F07)") void leaveAfterActivity_keepsSeat_andRejoinKeepsProgress() throws Exception {