Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/api-specs/class-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |

---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Void> reportPerspective(
@PathVariable Long perspectiveId,
Expand All @@ -28,13 +28,13 @@ public ApiResponse<Void> 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<Void> reportComment(
@PathVariable Long perspectiveId,
@PathVariable Long commentId,
@AuthenticationPrincipal Long userId) {
reportService.reportComment(commentId, userId);
reportService.reportComment(perspectiveId, commentId, userId);
return ApiResponse.onSuccess(null);
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -16,6 +18,10 @@
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

/**
* 클래스 의견·댓글은 클래스 읽기 권한(운영자·참여 중인 멤버)이 있어야 신고할 수 있다.
* 볼 수 있는 글은 마감 후에도 신고할 수 있도록 쓰기 권한이 아닌 읽기 권한으로 판정한다.
*/
@Service
@RequiredArgsConstructor
public class ReportService {
Expand All @@ -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));

Expand All @@ -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));

Expand All @@ -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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -107,6 +109,10 @@ class ClassBattleApiIntegrationTest {
private PerspectiveLikeRepository perspectiveLikeRepository;
@Autowired
private CommentLikeRepository commentLikeRepository;
@Autowired
private PerspectiveReportRepository perspectiveReportRepository;
@Autowired
private CommentReportRepository commentReportRepository;

private final List<Long> createdBattleIds = new ArrayList<>();
private final List<Long> createdUserIds = new ArrayList<>();
Expand Down Expand Up @@ -144,6 +150,8 @@ void tearDown() {
}

private void cleanUp() {
commentReportRepository.deleteAll();
perspectiveReportRepository.deleteAll();
commentLikeRepository.deleteAll();
perspectiveLikeRepository.deleteAll();
perspectiveCommentRepository.deleteAll();
Expand Down Expand Up @@ -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 {
Expand Down
Loading