diff --git a/docs/api-specs/class-api.md b/docs/api-specs/class-api.md index 5e05b635..38f53e12 100644 --- a/docs/api-specs/class-api.md +++ b/docs/api-specs/class-api.md @@ -804,7 +804,9 @@ AI가 주제 후보를 제안하면 운영자가 하나를 고르거나 직접 | 의견 상세·수정·삭제 | `GET` / `PATCH` / `DELETE /api/v1/perspectives/{perspectiveId}` | 조회는 읽기 규칙, 수정·삭제는 쓰기 규칙 (0.3) | | 댓글 목록 | `GET /api/v1/perspectives/{perspectiveId}/comments` (`/labeled` 포함) | 읽기 규칙 | | 댓글 작성·수정·삭제 | `POST` / `PATCH` / `DELETE /api/v1/perspectives/{perspectiveId}/comments/...` | 쓰기 규칙 | -| 좋아요 | `GET` / `POST` / `DELETE /api/v1/perspectives/{perspectiveId}/likes` | **없음** (클래스 밖 사용자도 호출할 수 있음) | +| 의견 좋아요 수 | `GET /api/v1/perspectives/{perspectiveId}/likes` | 읽기 규칙 | +| 의견 좋아요 등록·취소 | `POST` / `DELETE /api/v1/perspectives/{perspectiveId}/likes` | 쓰기 규칙 | +| 댓글 좋아요 등록·취소 | `POST` / `DELETE /api/v1/comments/{commentId}/likes` | 쓰기 규칙 | --- diff --git a/src/main/java/com/swyp/picke/domain/perspective/controller/CommentLikeController.java b/src/main/java/com/swyp/picke/domain/perspective/controller/CommentLikeController.java index c17eba4c..a55b90fa 100644 --- a/src/main/java/com/swyp/picke/domain/perspective/controller/CommentLikeController.java +++ b/src/main/java/com/swyp/picke/domain/perspective/controller/CommentLikeController.java @@ -21,14 +21,14 @@ public class CommentLikeController { private final CommentLikeService commentLikeService; - @Operation(summary = "댓글 좋아요 등록", description = "특정 댓글에 좋아요를 등록합니다.") + @Operation(summary = "댓글 좋아요 등록", description = "특정 댓글에 좋아요를 등록합니다. 클래스 댓글이면 참여 중인 멤버만, 마감 전에만 할 수 있습니다(403 CLASS_403_MEMBER, 409 CLASS_409_CLOSED / CLASS_409_TICKET_ENDED).") @PostMapping("/comments/{commentId}/likes") public ApiResponse addLike(@PathVariable Long commentId, @AuthenticationPrincipal Long userId) { return ApiResponse.onSuccess(commentLikeService.addLike(commentId, userId)); } - @Operation(summary = "댓글 좋아요 취소", description = "특정 댓글의 좋아요를 취소합니다.") + @Operation(summary = "댓글 좋아요 취소", description = "특정 댓글의 좋아요를 취소합니다. 클래스 댓글이면 참여 중인 멤버만, 마감 전에만 할 수 있습니다(403 CLASS_403_MEMBER, 409 CLASS_409_CLOSED / CLASS_409_TICKET_ENDED).") @DeleteMapping("/comments/{commentId}/likes") public ApiResponse removeLike(@PathVariable Long commentId, @AuthenticationPrincipal Long userId) { diff --git a/src/main/java/com/swyp/picke/domain/perspective/controller/PerspectiveLikeController.java b/src/main/java/com/swyp/picke/domain/perspective/controller/PerspectiveLikeController.java index 7e090575..9cb781ba 100644 --- a/src/main/java/com/swyp/picke/domain/perspective/controller/PerspectiveLikeController.java +++ b/src/main/java/com/swyp/picke/domain/perspective/controller/PerspectiveLikeController.java @@ -23,13 +23,16 @@ public class PerspectiveLikeController { private final PerspectiveLikeService likeService; - @Operation(summary = "좋아요 수 조회", description = "특정 관점의 좋아요 수를 조회합니다.") + @Operation(summary = "좋아요 수 조회", + description = "특정 관점의 좋아요 수를 조회합니다. 클래스 의견이면 운영자·참여 중인 멤버만 조회할 수 있습니다(403 CLASS_403_MEMBER).") @GetMapping("/perspectives/{perspectiveId}/likes") - public ApiResponse getLikeCount(@PathVariable Long perspectiveId) { - return ApiResponse.onSuccess(likeService.getLikeCount(perspectiveId)); + public ApiResponse getLikeCount( + @PathVariable Long perspectiveId, + @AuthenticationPrincipal Long userId) { + return ApiResponse.onSuccess(likeService.getLikeCount(perspectiveId, userId)); } - @Operation(summary = "좋아요 등록", description = "특정 관점에 좋아요를 등록합니다.") + @Operation(summary = "좋아요 등록", description = "특정 관점에 좋아요를 등록합니다. 클래스 의견이면 참여 중인 멤버만, 마감 전에만 할 수 있습니다(403 CLASS_403_MEMBER, 409 CLASS_409_CLOSED / CLASS_409_TICKET_ENDED).") @PostMapping("/perspectives/{perspectiveId}/likes") public ApiResponse addLike( @PathVariable Long perspectiveId, @@ -37,7 +40,7 @@ public ApiResponse addLike( return ApiResponse.onSuccess(likeService.addLike(perspectiveId, userId)); } - @Operation(summary = "좋아요 취소", description = "특정 관점의 좋아요를 취소합니다.") + @Operation(summary = "좋아요 취소", description = "특정 관점의 좋아요를 취소합니다. 클래스 의견이면 참여 중인 멤버만, 마감 전에만 할 수 있습니다(403 CLASS_403_MEMBER, 409 CLASS_409_CLOSED / CLASS_409_TICKET_ENDED).") @DeleteMapping("/perspectives/{perspectiveId}/likes") public ApiResponse removeLike( @PathVariable Long perspectiveId, diff --git a/src/main/java/com/swyp/picke/domain/perspective/service/CommentLikeService.java b/src/main/java/com/swyp/picke/domain/perspective/service/CommentLikeService.java index 83ca5b6a..baf93a70 100644 --- a/src/main/java/com/swyp/picke/domain/perspective/service/CommentLikeService.java +++ b/src/main/java/com/swyp/picke/domain/perspective/service/CommentLikeService.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.notification.service.NotificationDispatchService; import com.swyp.picke.domain.perspective.dto.response.LikeResponse; import com.swyp.picke.domain.perspective.entity.CommentLike; @@ -8,12 +10,15 @@ import com.swyp.picke.domain.perspective.repository.PerspectiveCommentRepository; import com.swyp.picke.global.common.exception.CustomException; import com.swyp.picke.global.common.exception.ErrorCode; +import java.time.LocalDateTime; +import java.time.ZoneId; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.support.TransactionSynchronization; import org.springframework.transaction.support.TransactionSynchronizationManager; +/** 클래스 댓글이면 좋아요 등록·취소에 클래스 쓰기 권한(참여 중인 멤버 + 마감 전)을 검사한다. */ @Service @RequiredArgsConstructor @Transactional(readOnly = true) @@ -22,10 +27,14 @@ public class CommentLikeService { private final PerspectiveCommentRepository commentRepository; private final CommentLikeRepository commentLikeRepository; private final NotificationDispatchService notificationDispatchService; + private final ClassAccessService classAccessService; + + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); @Transactional public LikeResponse addLike(Long commentId, Long userId) { PerspectiveComment comment = findCommentById(commentId); + requireClassWritable(comment, userId); if (comment.getUser().getId().equals(userId)) { throw new CustomException(ErrorCode.LIKE_SELF_FORBIDDEN); @@ -68,6 +77,7 @@ public void afterCommit() { @Transactional public LikeResponse removeLike(Long commentId, Long userId) { PerspectiveComment comment = findCommentById(commentId); + requireClassWritable(comment, userId); CommentLike like = commentLikeRepository.findByCommentAndUserId(comment, userId) .orElseThrow(() -> new CustomException(ErrorCode.LIKE_NOT_FOUND)); @@ -78,6 +88,13 @@ public LikeResponse removeLike(Long commentId, Long userId) { return new LikeResponse(comment.getId(), comment.getLikeCount(), false); } + private void requireClassWritable(PerspectiveComment comment, Long userId) { + Classroom classroom = comment.getPerspective().getClassroom(); + if (classroom != null) { + classAccessService.requireWritable(classroom, userId, LocalDateTime.now(KST)); + } + } + private PerspectiveComment findCommentById(Long commentId) { return commentRepository.findById(commentId) .orElseThrow(() -> new CustomException(ErrorCode.COMMENT_NOT_FOUND)); diff --git a/src/main/java/com/swyp/picke/domain/perspective/service/PerspectiveLikeService.java b/src/main/java/com/swyp/picke/domain/perspective/service/PerspectiveLikeService.java index 85e51d62..d34ff839 100644 --- a/src/main/java/com/swyp/picke/domain/perspective/service/PerspectiveLikeService.java +++ b/src/main/java/com/swyp/picke/domain/perspective/service/PerspectiveLikeService.java @@ -1,5 +1,6 @@ package com.swyp.picke.domain.perspective.service; +import com.swyp.picke.domain.classroom.service.ClassAccessService; import com.swyp.picke.domain.perspective.dto.response.LikeCountResponse; import com.swyp.picke.domain.perspective.dto.response.LikeResponse; import com.swyp.picke.domain.perspective.entity.Perspective; @@ -10,10 +11,13 @@ import com.swyp.picke.domain.user.repository.UserRepository; import com.swyp.picke.global.common.exception.CustomException; import com.swyp.picke.global.common.exception.ErrorCode; +import java.time.LocalDateTime; +import java.time.ZoneId; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +/** 클래스 의견이면 좋아요 수 조회는 클래스 읽기 권한, 등록·취소는 쓰기 권한(참여 중인 멤버 + 마감 전)을 검사한다. */ @Service @RequiredArgsConstructor @Transactional(readOnly = true) @@ -22,9 +26,15 @@ public class PerspectiveLikeService { private final PerspectiveRepository perspectiveRepository; private final PerspectiveLikeRepository likeRepository; private final UserRepository userRepository; + private final ClassAccessService classAccessService; - public LikeCountResponse getLikeCount(Long perspectiveId) { + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); + + public LikeCountResponse getLikeCount(Long perspectiveId, Long userId) { Perspective perspective = findPerspectiveById(perspectiveId); + if (perspective.getClassroom() != null) { + classAccessService.requireReadable(perspective.getClassroom(), userId); + } long likeCount = likeRepository.countByPerspective(perspective); return new LikeCountResponse(perspective.getId(), likeCount); } @@ -32,6 +42,7 @@ public LikeCountResponse getLikeCount(Long perspectiveId) { @Transactional public LikeResponse addLike(Long perspectiveId, Long userId) { Perspective perspective = findPerspectiveById(perspectiveId); + requireClassWritable(perspective, userId); User user = userRepository.findById(userId) .orElseThrow(() -> new CustomException(ErrorCode.USER_NOT_FOUND)); @@ -55,6 +66,7 @@ public LikeResponse addLike(Long perspectiveId, Long userId) { @Transactional public LikeResponse removeLike(Long perspectiveId, Long userId) { Perspective perspective = findPerspectiveById(perspectiveId); + requireClassWritable(perspective, userId); PerspectiveLike like = likeRepository.findByPerspectiveAndUserId(perspective, userId) .orElseThrow(() -> new CustomException(ErrorCode.LIKE_NOT_FOUND)); @@ -65,6 +77,12 @@ public LikeResponse removeLike(Long perspectiveId, Long userId) { return new LikeResponse(perspective.getId(), perspective.getLikeCount(), false); } + private void requireClassWritable(Perspective perspective, Long userId) { + if (perspective.getClassroom() != null) { + classAccessService.requireWritable(perspective.getClassroom(), userId, LocalDateTime.now(KST)); + } + } + private Perspective findPerspectiveById(Long perspectiveId) { return perspectiveRepository.findById(perspectiveId) .orElseThrow(() -> new CustomException(ErrorCode.PERSPECTIVE_NOT_FOUND)); 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 de6791aa..e7456aa9 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 @@ -14,7 +14,9 @@ import com.swyp.picke.domain.classroom.repository.ClassMemberRepository; 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.PerspectiveCommentRepository; +import com.swyp.picke.domain.perspective.repository.PerspectiveLikeRepository; import com.swyp.picke.domain.perspective.repository.PerspectiveRepository; import com.swyp.picke.domain.ticket.entity.ClassTicket; import com.swyp.picke.domain.ticket.entity.FundingOrder; @@ -101,6 +103,10 @@ class ClassBattleApiIntegrationTest { private PerspectiveRepository perspectiveRepository; @Autowired private PerspectiveCommentRepository perspectiveCommentRepository; + @Autowired + private PerspectiveLikeRepository perspectiveLikeRepository; + @Autowired + private CommentLikeRepository commentLikeRepository; private final List createdBattleIds = new ArrayList<>(); private final List createdUserIds = new ArrayList<>(); @@ -138,6 +144,8 @@ void tearDown() { } private void cleanUp() { + commentLikeRepository.deleteAll(); + perspectiveLikeRepository.deleteAll(); perspectiveCommentRepository.deleteAll(); perspectiveRepository.deleteAll(); battleVoteRepository.deleteAll(); @@ -332,6 +340,79 @@ void closedClass_blocksWritesOnly() throws Exception { .andExpect(jsonPath("$.data.items", hasSize(1))); } + @Test + @DisplayName("클래스 의견·댓글 좋아요: 비멤버 403, 참여하지 않는 운영자는 조회만, 마감 후 등록·취소 409") + void likes_followClassAccess() 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 perspectiveLikes = "/api/v1/perspectives/" + perspectiveId + "/likes"; + String commentLikes = "/api/v1/comments/" + commentId + "/likes"; + + // 클래스 밖 사용자는 조회·등록 모두 403 + String strangerToken = tokenOf(newUser("외부인")); + mockMvc.perform(get(perspectiveLikes).header("Authorization", "Bearer " + strangerToken)) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.error.code").value("CLASS_403_MEMBER")); + mockMvc.perform(post(perspectiveLikes).header("Authorization", "Bearer " + strangerToken)) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.error.code").value("CLASS_403_MEMBER")); + mockMvc.perform(post(commentLikes).header("Authorization", "Bearer " + strangerToken)) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.error.code").value("CLASS_403_MEMBER")); + + // 참여하지 않는 운영자는 조회만 + mockMvc.perform(get(perspectiveLikes).header("Authorization", "Bearer " + ownerToken)) + .andExpect(status().isOk()); + mockMvc.perform(post(perspectiveLikes).header("Authorization", "Bearer " + ownerToken)) + .andExpect(status().isForbidden()) + .andExpect(jsonPath("$.error.code").value("CLASS_403_MEMBER")); + + // 참여 중인 멤버는 마감 전 등록·취소 가능 + mockMvc.perform(post(perspectiveLikes).header("Authorization", "Bearer " + readerToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.likeCount").value(1)); + mockMvc.perform(post(commentLikes).header("Authorization", "Bearer " + readerToken)) + .andExpect(status().isOk()); + mockMvc.perform(delete(commentLikes).header("Authorization", "Bearer " + readerToken)) + .andExpect(status().isOk()); + mockMvc.perform(post(commentLikes).header("Authorization", "Bearer " + readerToken)) + .andExpect(status().isOk()); + + // 마감 후에는 조회만 되고 등록·취소는 409 + closeClass(cls.classId); + mockMvc.perform(get(perspectiveLikes).header("Authorization", "Bearer " + readerToken)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.likeCount").value(1)); + mockMvc.perform(delete(perspectiveLikes).header("Authorization", "Bearer " + readerToken)) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.error.code").value("CLASS_409_CLOSED")); + mockMvc.perform(delete(commentLikes).header("Authorization", "Bearer " + readerToken)) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.error.code").value("CLASS_409_CLOSED")); + assertThat(perspectiveLikeRepository.count()).isEqualTo(1); + assertThat(commentLikeRepository.count()).isEqualTo(1); + } + @Test @DisplayName("투표한 뒤 나가면 좌석은 유지(F15)되고, 재참여하면 진행 상태가 이어진다(F07)") void leaveAfterActivity_keepsSeat_andRejoinKeepsProgress() throws Exception {