Skip to content
7 changes: 7 additions & 0 deletions back/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,13 @@ dependencies {
// AI Services - WebFlux for non-blocking HTTP clients
implementation("org.springframework.boot:spring-boot-starter-webflux")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")

// macOS Netty 네이티브 DNS 리졸버 (WebFlux 필요)
val isMacOS: Boolean = System.getProperty("os.name").startsWith("Mac OS X")
val architecture = System.getProperty("os.arch").lowercase()
if (isMacOS && architecture == "aarch64") {
developmentOnly("io.netty:netty-resolver-dns-native-macos:4.1.68.Final:osx-aarch_64")
}
}

tasks.withType<Test> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import com.back.domain.comment.dto.CommentResponse;
import com.back.domain.comment.enums.CommentSortType;
import com.back.domain.comment.service.CommentService;
import com.back.domain.user.entity.User;
import com.back.global.common.PageResponse;
import com.back.global.security.CustomUserDetails;
import io.swagger.v3.oas.annotations.Operation;
Expand All @@ -20,72 +21,62 @@
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.*;

@Tag(name = "Comment", description = "댓글 관련 API")
@RestController
@RequestMapping("/api/v1/posts/{postId}/comments")
@RequiredArgsConstructor
@Tag(name = "Comment", description = "댓글 관련 API")
public class CommentController {

private final CommentService commentService;

// 댓글 생성
@PostMapping
@Operation(summary = "댓글 생성", description = "새 댓글을 생성합니다.")
public ResponseEntity<CommentResponse> createPost(
@io.swagger.v3.oas.annotations.parameters.RequestBody(
description = "생성할 댓글 정보",
required = true
)
public ResponseEntity<CommentResponse> createComment(
@RequestBody @Valid CommentRequest request,
@Parameter(description = "조회할 게시글 ID", required = true) @PathVariable("postId") Long postId,
@PathVariable("postId") Long postId,
@AuthenticationPrincipal CustomUserDetails cs
) {
CommentResponse response = commentService.createComment(cs.getUser().getId(), postId, request);
User user = cs.getUser();
CommentResponse response = commentService.createComment(user, postId, request);
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}

@GetMapping
@Operation(summary = "댓글 목록 조회", description = "게시글 목록을 조회합니다.")
public ResponseEntity<PageResponse<CommentResponse>> getPosts(
@Parameter(description = "페이지 정보") Pageable pageable,
@Parameter(description = "조회할 게시글 ID", required = true) @PathVariable("postId") Long postId,
@Parameter(description = "정렬 조건 LATEST or LIKES") @RequestParam(defaultValue = "LATEST") CommentSortType sortType,
public ResponseEntity<PageResponse<CommentResponse>> getComments(
Pageable pageable,
@PathVariable("postId") Long postId,
@RequestParam(defaultValue = "LATEST") CommentSortType sortType,
@AuthenticationPrincipal CustomUserDetails cs) {

Sort sort = Sort.by(Sort.Direction.DESC, sortType.getProperty());

Pageable sortedPageable = PageRequest.of(
pageable.getPageNumber(),
pageable.getPageSize(),
sort
);
User user = cs != null ? cs.getUser() : null;

Long userId = (cs != null && cs.getUser() != null) ? cs.getUser().getId() : null;
Sort sort = Sort.by(Sort.Direction.DESC, sortType.getProperty());
Pageable sortedPageable = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), sort);

Page<CommentResponse> responses = commentService.getComments(userId, postId, sortedPageable);
Page<CommentResponse> responses = commentService.getComments(user, postId, sortedPageable);
return ResponseEntity.ok(PageResponse.of(responses));
}


@PutMapping("/{commentId}")
@Operation(summary = "댓글 수정", description = "자신의 댓글을 수정합니다.")
public ResponseEntity<Long> updateComment(
@Parameter(description = "수정할 댓글 ID", required = true) @PathVariable Long commentId,
@io.swagger.v3.oas.annotations.parameters.RequestBody(
description = "수정할 댓글 정보",
required = true
)
@PathVariable Long commentId,
@RequestBody @Valid CommentRequest request,
@AuthenticationPrincipal CustomUserDetails cs) {
return ResponseEntity.ok(commentService.updateComment(cs.getUser().getId(), commentId, request));

User user = cs.getUser();
return ResponseEntity.ok(commentService.updateComment(user, commentId, request));
}

@DeleteMapping("/{commentId}")
@Operation(summary = "댓글 삭제", description = "자신의 댓글을 삭제합니다.")
public ResponseEntity<Void> deletePost(
@Parameter(description = "삭제할 댓글 ID", required = true) @PathVariable Long commentId,
public ResponseEntity<Void> deleteComment(
@PathVariable Long commentId,
@AuthenticationPrincipal CustomUserDetails cs) {
commentService.deleteComment(cs.getUser().getId(), commentId);
return ResponseEntity.ok(null);

User user = cs.getUser();
commentService.deleteComment(user, commentId);
return ResponseEntity.ok().build();
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -50,8 +50,8 @@ public class Comment extends BaseEntity {
@LastModifiedDate
private LocalDateTime updatedAt;

public void checkUser(Long userId) {
if (!user.getId().equals(userId))
public void checkUser(Long targetUserId) {
if (!targetUserId.equals(user.getId()))
throw new ApiException(ErrorCode.UNAUTHORIZED_USER);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,9 +24,9 @@ public interface CommentRepository extends JpaRepository<Comment, Long> {

int countByUserId(Long userId);

@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT c FROM Comment c WHERE c.id = :commentId")
Optional<Comment> findByIdWithLock(@Param("commentId") Long commentId);
// @Lock(LockModeType.PESSIMISTIC_WRITE)
// @Query("SELECT c FROM Comment c WHERE c.id = :commentId")
// Optional<Comment> findByIdWithLock(@Param("commentId") Long commentId);

@EntityGraph(attributePaths = {"post"})
Page<Comment> findByUserIdOrderByCreatedDateDesc(Long userId, Pageable pageable);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,33 +31,27 @@
@Transactional(readOnly = true)
public class CommentService {

private final UserRepository userRepository;
private final PostRepository postRepository;
private final CommentRepository commentRepository;
private final CommentLikeRepository commentLikeRepository;

public CommentResponse createComment(Long userId, Long postId, CommentRequest request) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new ApiException(ErrorCode.USER_NOT_FOUND));

public CommentResponse createComment(User user, Long postId, CommentRequest request) {
Post post = postRepository.findById(postId)
.orElseThrow(() -> new ApiException(ErrorCode.POST_NOT_FOUND));

CommentMappers.CommentCtxMapper ctxMapper = new CommentMappers.CommentCtxMapper(user, post);
Comment savedComment = commentRepository.save(ctxMapper.toEntity(request));
return ctxMapper.toResponse(savedComment);
}

public Page<CommentResponse> getComments(Long userId, Long postId, Pageable pageable) {
User user = userId != null
? userRepository.findById(userId).orElse(null)
: null;

public Page<CommentResponse> getComments(User user, Long postId, Pageable pageable) {
Post post = postRepository.findById(postId)
.orElseThrow(() -> new ApiException(ErrorCode.POST_NOT_FOUND));

Page<Comment> commentsPage = commentRepository.findCommentsByPostId(postId, pageable);

Set<Long> userLikedComments = userId != null
? getUserLikedComments(userId, commentsPage)
Set<Long> userLikedComments = user != null
? getUserLikedComments(user, commentsPage)
: Collections.emptySet();

return commentsPage.map(comment -> CommentMappers.toCommentResponse(
Expand All @@ -68,29 +62,30 @@ public Page<CommentResponse> getComments(Long userId, Long postId, Pageable page
}

@Transactional
public Long updateComment(Long userId, Long commentId, CommentRequest request) {
public Long updateComment(User user, Long commentId, CommentRequest request) {
Comment comment = commentRepository.findById(commentId)
.orElseThrow(() -> new ApiException(ErrorCode.COMMENT_NOT_FOUND));
comment.checkUser(userId);

comment.checkUser(user.getId());
comment.updateContent(request.content());
return comment.getId();
}

@Transactional
public void deleteComment(Long userId, Long commentId) {
public void deleteComment(User user, Long commentId) {
Comment comment = commentRepository.findById(commentId)
.orElseThrow(() -> new ApiException(ErrorCode.COMMENT_NOT_FOUND));
comment.checkUser(userId);

comment.checkUser(user.getId());
commentRepository.delete(comment);
}

// 특정 사용자가 한 게시글 내 댓글에서 좋아요를 누른 댓글 ID 집합 조회
private Set<Long> getUserLikedComments(Long userId, Page<Comment> comments) {
private Set<Long> getUserLikedComments(User user, Page<Comment> comments) {
Set<Long> commentIds = comments.getContent()
.stream()
.map(Comment::getId)
.collect(Collectors.toSet());

return commentLikeRepository.findLikedCommentsIdsByUserAndCommentIds(userId, commentIds);
return commentLikeRepository.findLikedCommentsIdsByUserAndCommentIds(user.getId(), commentIds);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,33 +15,34 @@
@RequiredArgsConstructor
@RequestMapping("/api/v1/posts")
public class LikeController {

private final LikeService likeService;

@PostMapping("/{postId}/likes")
public ResponseEntity<Void> addLike(@PathVariable Long postId, @AuthenticationPrincipal CustomUserDetails cs) {
likeService.addLike(cs.getUser().getId(), postId);
return ResponseEntity.status(HttpStatus.CREATED).body(null);
likeService.addLike(cs.getUser(), postId);
return ResponseEntity.status(HttpStatus.CREATED).build();
}

@DeleteMapping("/{postId}/likes")
public ResponseEntity<Void> removeLike(@PathVariable Long postId, @AuthenticationPrincipal CustomUserDetails cs) {
likeService.removeLike(postId, cs.getUser().getId());
return ResponseEntity.ok(null);
likeService.removeLike(cs.getUser(), postId);
return ResponseEntity.ok().build();
}

@PostMapping("/{postId}/comments/{commentId}/likes")
public ResponseEntity<Void> addCommentLike(@PathVariable Long postId,
@PathVariable Long commentId,
@AuthenticationPrincipal CustomUserDetails cs) {
likeService.addCommentLike(cs.getUser().getId(), postId, commentId);
return ResponseEntity.status(HttpStatus.CREATED).body(null);
likeService.addCommentLike(cs.getUser(), postId, commentId);
return ResponseEntity.status(HttpStatus.CREATED).build();
}

@DeleteMapping("/{postId}/comments/{commentId}/likes")
public ResponseEntity<Void> removeCommentLike(@PathVariable Long postId,
@PathVariable Long commentId,
@AuthenticationPrincipal CustomUserDetails cs) {
likeService.removeCommentLike(cs.getUser().getId(), postId, commentId);
return ResponseEntity.ok(null);
likeService.removeCommentLike(cs.getUser(), postId, commentId);
return ResponseEntity.ok().build();
}
}
59 changes: 28 additions & 31 deletions back/src/main/java/com/back/domain/like/service/LikeService.java
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,15 @@
import com.back.domain.post.repository.PostRepository;
import com.back.domain.user.entity.User;
import com.back.domain.user.repository.UserRepository;
import com.back.global.common.WithLock;
import com.back.global.exception.ApiException;
import com.back.global.exception.ErrorCode;
import lombok.RequiredArgsConstructor;
import org.springframework.core.Ordered;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.interceptor.TransactionAspectSupport;
import org.springframework.transaction.interceptor.TransactionInterceptor;

/**
* 좋아요 관련 비즈니스 로직을 처리하는 서비스.
Expand All @@ -28,29 +32,33 @@ public class LikeService {
private final CommentLikeRepository commentLikeRepository;
private final CommentRepository commentRepository;
private final PostRepository postRepository;
private final UserRepository userRepository;

@Transactional
public void addLike(Long userId, Long postId) {
Post post = postRepository.findByIdWithLock(postId)
@WithLock(key = "'post:' + #postId")
public void addLike(User user, Long postId) {
Post post = postRepository.findById(postId)
.orElseThrow(() -> new ApiException(ErrorCode.POST_NOT_FOUND));

if (postLikeRepository.existsByPostIdAndUserId(postId, userId)) {
if (postLikeRepository.existsByPostIdAndUserId(postId, user.getId())) {
throw new ApiException(ErrorCode.POST_ALREADY_LIKED);
}

PostLike postLike = createPostLike(post, userId);
PostLike postLike = PostLike.builder()
.post(post)
.user(user)
.build();

postLikeRepository.save(postLike);
post.incrementLikeCount();
}

@Transactional
public void removeLike(Long postId, Long userId) {
Post post = postRepository.findByIdWithLock(postId)
@WithLock(key = "'post:' + #postId")
public void removeLike(User user, Long postId) {
Post post = postRepository.findById(postId)
.orElseThrow(() -> new ApiException(ErrorCode.POST_NOT_FOUND));

boolean deleted = postLikeRepository.deleteByPostIdAndUserId(postId, userId) > 0;
boolean deleted = postLikeRepository.deleteByPostIdAndUserId(postId, user.getId()) > 0;

if (!deleted) {
throw new ApiException(ErrorCode.LIKE_NOT_FOUND);
Expand All @@ -60,47 +68,36 @@ public void removeLike(Long postId, Long userId) {
}

@Transactional
public void addCommentLike(Long userId, Long postId, Long commentId) {
Comment comment = commentRepository.findByIdWithLock(commentId)
@WithLock(key = "'comment:' + #commentId")
public void addCommentLike(User user, Long postId, Long commentId) {
Comment comment = commentRepository.findById(commentId)
.orElseThrow(() -> new ApiException(ErrorCode.COMMENT_NOT_FOUND));

if (commentLikeRepository.existsByCommentIdAndUserId(commentId, userId)) {
if (commentLikeRepository.existsByCommentIdAndUserId(commentId, user.getId())) {
throw new ApiException(ErrorCode.COMMENT_ALREADY_LIKED);
}

CommentLike commentLike = createCommentLike(comment, userId);
CommentLike commentLike = CommentLike.builder()
.comment(comment)
.user(user)
.build();

commentLikeRepository.save(commentLike);
comment.incrementLikeCount();
}

@Transactional
public void removeCommentLike(Long userId, Long postId, Long commentId) {
Comment comment = commentRepository.findByIdWithLock(commentId)
@WithLock(key = "'comment:' + #commentId")
public void removeCommentLike(User user, Long postId, Long commentId) {
Comment comment = commentRepository.findById(commentId)
.orElseThrow(() -> new ApiException(ErrorCode.COMMENT_NOT_FOUND));

boolean deleted = commentLikeRepository.deleteByCommentIdAndUserId(commentId, userId) > 0;
boolean deleted = commentLikeRepository.deleteByCommentIdAndUserId(commentId, user.getId()) > 0;

if (!deleted) {
throw new ApiException(ErrorCode.LIKE_NOT_FOUND);
}

comment.decrementLikeCount();
}

private PostLike createPostLike(Post post, Long userId) {
User userReference = userRepository.getReferenceById(userId);
return PostLike.builder()
.post(post)
.user(userReference)
.build();
}

private CommentLike createCommentLike(Comment comment, Long userId) {
User userReference = userRepository.getReferenceById(userId);
return CommentLike.builder()
.comment(comment)
.user(userReference)
.build();
}
}
Loading