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
4 changes: 3 additions & 1 deletion docs/api-specs/class-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` | 쓰기 규칙 |

---

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<LikeResponse> 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<LikeResponse> removeLike(@PathVariable Long commentId,
@AuthenticationPrincipal Long userId) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,21 +23,24 @@ public class PerspectiveLikeController {

private final PerspectiveLikeService likeService;

@Operation(summary = "좋아요 수 조회", description = "특정 관점의 좋아요 수를 조회합니다.")
@Operation(summary = "좋아요 수 조회",
description = "특정 관점의 좋아요 수를 조회합니다. 클래스 의견이면 운영자·참여 중인 멤버만 조회할 수 있습니다(403 CLASS_403_MEMBER).")
@GetMapping("/perspectives/{perspectiveId}/likes")
public ApiResponse<LikeCountResponse> getLikeCount(@PathVariable Long perspectiveId) {
return ApiResponse.onSuccess(likeService.getLikeCount(perspectiveId));
public ApiResponse<LikeCountResponse> 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<LikeResponse> addLike(
@PathVariable Long perspectiveId,
@AuthenticationPrincipal Long userId) {
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<LikeResponse> removeLike(
@PathVariable Long perspectiveId,
Expand Down
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.notification.service.NotificationDispatchService;
import com.swyp.picke.domain.perspective.dto.response.LikeResponse;
import com.swyp.picke.domain.perspective.entity.CommentLike;
Expand All @@ -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)
Expand All @@ -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);
Expand Down Expand Up @@ -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));
Expand All @@ -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));
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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)
Expand All @@ -22,16 +26,23 @@ 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);
}

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

Expand All @@ -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));
Expand All @@ -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));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -101,6 +103,10 @@ class ClassBattleApiIntegrationTest {
private PerspectiveRepository perspectiveRepository;
@Autowired
private PerspectiveCommentRepository perspectiveCommentRepository;
@Autowired
private PerspectiveLikeRepository perspectiveLikeRepository;
@Autowired
private CommentLikeRepository commentLikeRepository;

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

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