diff --git a/src/main/java/com/jiwon/mylog/domain/comment/service/CommentService.java b/src/main/java/com/jiwon/mylog/domain/comment/service/CommentService.java index e7cf953..1fd9442 100644 --- a/src/main/java/com/jiwon/mylog/domain/comment/service/CommentService.java +++ b/src/main/java/com/jiwon/mylog/domain/comment/service/CommentService.java @@ -6,8 +6,6 @@ import com.jiwon.mylog.domain.comment.entity.Comment; import com.jiwon.mylog.domain.comment.repository.CommentRepository; import com.jiwon.mylog.domain.event.dto.CommentCreatedEvent; -import com.jiwon.mylog.domain.notification.repository.NotificationRepository; -import com.jiwon.mylog.domain.notification.service.NotificationService; import com.jiwon.mylog.domain.post.entity.Post; import com.jiwon.mylog.domain.user.entity.User; import com.jiwon.mylog.global.common.error.ErrorCode; diff --git a/src/main/java/com/jiwon/mylog/domain/event/NotificationEventListener.java b/src/main/java/com/jiwon/mylog/domain/event/NotificationEventListener.java index 7ebcbb3..5a080e3 100644 --- a/src/main/java/com/jiwon/mylog/domain/event/NotificationEventListener.java +++ b/src/main/java/com/jiwon/mylog/domain/event/NotificationEventListener.java @@ -1,6 +1,7 @@ package com.jiwon.mylog.domain.event; import com.jiwon.mylog.domain.event.dto.CommentCreatedEvent; +import com.jiwon.mylog.domain.event.dto.LikeCreatedEvent; import com.jiwon.mylog.domain.notification.entity.Notification; import com.jiwon.mylog.domain.notification.repository.NotificationRepository; import com.jiwon.mylog.domain.notification.service.NotificationService; @@ -33,7 +34,7 @@ public void handleCommentCreated(CommentCreatedEvent event) { Notification notification = Notification.create( receiver, - event.getCommentWriterName() + "왹이 외계 수집물에 발사를 남겼습니다.", + event.getCommentWriterName() + "왹이 외계 수집물에 발 사를 남기다!", "/posts/" + event.getPostId(), NotificationType.COMMENT ); @@ -45,4 +46,26 @@ public void handleCommentCreated(CommentCreatedEvent event) { log.warn("SSE 전송 실패"); } } + + @Transactional + @EventListener + public void handleLikeCreated(LikeCreatedEvent event) { + Long receiverId = event.getPostWriterId(); + User receiver = userRepository.findById(receiverId) + .orElseThrow(() -> new NotFoundException(ErrorCode.NOT_FOUND_USER)); + + Notification notification = Notification.create( + receiver, + event.getLikeWriterName() + "왹이 외계 수집물에 푸 딩을 달았다!", + "/posts/" + event.getPostId(), + NotificationType.LIKE + ); + notificationRepository.save(notification); + + try { + notificationService.sendNotification(receiverId, notification); + } catch(Exception e) { + log.warn("SSE 전송 실패"); + } + } } diff --git a/src/main/java/com/jiwon/mylog/domain/event/dto/LikeCreatedEvent.java b/src/main/java/com/jiwon/mylog/domain/event/dto/LikeCreatedEvent.java new file mode 100644 index 0000000..b827288 --- /dev/null +++ b/src/main/java/com/jiwon/mylog/domain/event/dto/LikeCreatedEvent.java @@ -0,0 +1,13 @@ +package com.jiwon.mylog.domain.event.dto; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public class LikeCreatedEvent { + private final Long postId; + private final Long postWriterId; + private final Long likeWriterId; + private final String likeWriterName; +} diff --git a/src/main/java/com/jiwon/mylog/domain/like/Like.java b/src/main/java/com/jiwon/mylog/domain/like/Like.java new file mode 100644 index 0000000..2d63aba --- /dev/null +++ b/src/main/java/com/jiwon/mylog/domain/like/Like.java @@ -0,0 +1,47 @@ +package com.jiwon.mylog.domain.like; + +import com.jiwon.mylog.domain.post.entity.Post; +import com.jiwon.mylog.domain.user.entity.User; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EntityListeners; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@EntityListeners(AuditingEntityListener.class) +@Entity +@Table( + name = "likes", + uniqueConstraints = @UniqueConstraint( + name = "like_uk", + columnNames = {"user_id", "post_id"} + ) +) +public class Like { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "post_id", nullable = false) + private Post post; + + @CreatedDate + @Column(nullable = false, updatable = false) + private LocalDateTime createdAt; +} diff --git a/src/main/java/com/jiwon/mylog/domain/like/LikeController.java b/src/main/java/com/jiwon/mylog/domain/like/LikeController.java new file mode 100644 index 0000000..ae19949 --- /dev/null +++ b/src/main/java/com/jiwon/mylog/domain/like/LikeController.java @@ -0,0 +1,56 @@ +package com.jiwon.mylog.domain.like; + +import com.jiwon.mylog.global.common.entity.SliceResponse; +import com.jiwon.mylog.global.security.auth.annotation.LoginUser; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RequiredArgsConstructor +@RequestMapping("/api") +@RestController +public class LikeController { + + private final LikeService likeService; + + @PostMapping("/posts/{postId}/likes") + public ResponseEntity createLike(@LoginUser Long userId, @PathVariable Long postId) { + likeService.createLike(userId, postId); + return new ResponseEntity<>(HttpStatus.CREATED); + } + + @DeleteMapping("/posts/{postId}/likes") + public ResponseEntity deleteLike(@LoginUser Long userId, @PathVariable Long postId) { + likeService.deleteLike(userId, postId); + return new ResponseEntity<>(HttpStatus.NO_CONTENT); + } + + @GetMapping("/posts/{postId}/likes/count") + public ResponseEntity getLikeCount(@PathVariable Long postId) { + long likeCount = likeService.getLikeCount(postId); + return ResponseEntity.ok(likeCount); + } + + @GetMapping("/posts/{postId}/likes") + public ResponseEntity getLikeStatus(@LoginUser Long userId, @PathVariable Long postId) { + boolean likeStatus = likeService.getLikeStatus(userId, postId); + return ResponseEntity.ok(likeStatus); + } + + @GetMapping("/users/{userId}/likes") + public ResponseEntity getUserLikes( + @PathVariable Long userId, + @PageableDefault(sort="createdAt", direction = Sort.Direction.DESC) Pageable pageable) { + SliceResponse response = likeService.getUserLikes(userId, pageable); + return ResponseEntity.ok(response); + } +} diff --git a/src/main/java/com/jiwon/mylog/domain/like/LikeRepository.java b/src/main/java/com/jiwon/mylog/domain/like/LikeRepository.java new file mode 100644 index 0000000..06f9217 --- /dev/null +++ b/src/main/java/com/jiwon/mylog/domain/like/LikeRepository.java @@ -0,0 +1,27 @@ +package com.jiwon.mylog.domain.like; + +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +@Repository +public interface LikeRepository extends JpaRepository { + boolean existsByUserIdAndPostId(Long userId, Long postId); + + @Modifying + @Query(value = "insert ignore into likes(user_id, post_id) values(:userId, :postId)", nativeQuery = true) + void saveLike(@Param("userId") Long userId, @Param("postId") Long postId); + + @Modifying + @Query(value = "delete from likes where user_id = :userId and post_id = :postId", nativeQuery = true) + void deleteLike(@Param("userId") Long userId, @Param("postId") Long postId); + + Slice findAllByUserId(Long userId, Pageable pageable); + + @Query(value = "select count(*) from likes where post_id = :postId", nativeQuery = true) + long countByPostId(@Param("postId") Long postId); +} diff --git a/src/main/java/com/jiwon/mylog/domain/like/LikeService.java b/src/main/java/com/jiwon/mylog/domain/like/LikeService.java new file mode 100644 index 0000000..13ff88b --- /dev/null +++ b/src/main/java/com/jiwon/mylog/domain/like/LikeService.java @@ -0,0 +1,97 @@ +package com.jiwon.mylog.domain.like; + +import com.jiwon.mylog.domain.event.dto.LikeCreatedEvent; +import com.jiwon.mylog.domain.post.entity.Post; +import com.jiwon.mylog.domain.post.repository.PostRepository; +import com.jiwon.mylog.domain.user.entity.User; +import com.jiwon.mylog.domain.user.repository.UserRepository; +import com.jiwon.mylog.global.common.entity.SliceResponse; +import com.jiwon.mylog.global.common.error.ErrorCode; +import com.jiwon.mylog.global.common.error.exception.NotFoundException; +import lombok.RequiredArgsConstructor; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Slice; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@RequiredArgsConstructor +@Service +public class LikeService { + + private final ApplicationEventPublisher eventPublisher; + private final LikeRepository likeRepository; + private final UserRepository userRepository; + private final PostRepository postRepository; + + @CacheEvict(value = "like::count", key="'postId:' + #postId", condition = "#postId != null") + @Transactional + public void createLike(Long userId, Long postId) { + User user = userRepository.findById(userId) + .orElseThrow(() -> new NotFoundException(ErrorCode.NOT_FOUND_USER)); + Post post = postRepository.findById(postId) + .orElseThrow(() -> new NotFoundException(ErrorCode.NOT_FOUND_POST)); + Long receiverId = post.getUser().getId(); + + likeRepository.saveLike(userId, postId); + + if (!receiverId.equals(userId)) { + eventPublisher.publishEvent( + new LikeCreatedEvent( + postId, + receiverId, + userId, + user.getUsername()) + ); + } + } + + @CacheEvict(value = "like::count", key="'postId:' + #postId", condition = "#postId != null") + @Transactional + public void deleteLike(Long userId, Long postId) { + validateUserExists(userId); + validatePostExists(postId); + likeRepository.deleteLike(userId, postId); + } + + @Cacheable(value = "like::count", key = "'postId:' + #postId", condition = "#postId != null") + @Transactional(readOnly = true) + public long getLikeCount(Long postId) { + validatePostExists(postId); + return likeRepository.countByPostId(postId); + } + + @Transactional(readOnly = true) + public boolean getLikeStatus(Long userId, Long postId) { + validateUserExists(userId); + validatePostExists(postId); + return likeRepository.existsByUserIdAndPostId(userId, postId); + } + + @Transactional(readOnly = true) + public SliceResponse getUserLikes(Long userId, Pageable pageable) { + validateUserExists(userId); + Slice likeSlice = likeRepository.findAllByUserId(userId, pageable); + return SliceResponse.from( + likeSlice.getContent(), + likeSlice.getNumber(), + likeSlice.getSize(), + likeSlice.isFirst(), + likeSlice.isLast() + ); + } + + private void validateUserExists(Long userId) { + if(!userRepository.existsById(userId)) { + throw new NotFoundException(ErrorCode.NOT_FOUND_USER); + } + } + + private void validatePostExists(Long postId) { + if(!postRepository.existsById(postId)) { + throw new NotFoundException(ErrorCode.NOT_FOUND_POST); + } + } +} diff --git a/src/main/java/com/jiwon/mylog/domain/notification/entity/NotificationType.java b/src/main/java/com/jiwon/mylog/domain/notification/entity/NotificationType.java index faea851..8406a8f 100644 --- a/src/main/java/com/jiwon/mylog/domain/notification/entity/NotificationType.java +++ b/src/main/java/com/jiwon/mylog/domain/notification/entity/NotificationType.java @@ -6,5 +6,6 @@ public enum NotificationType { FOLLOW, UNFOLLOW, SERVER, + LIKE, ETC } diff --git a/src/main/java/com/jiwon/mylog/domain/notification/service/NotificationService.java b/src/main/java/com/jiwon/mylog/domain/notification/service/NotificationService.java index 2fd1172..2d7f5d8 100644 --- a/src/main/java/com/jiwon/mylog/domain/notification/service/NotificationService.java +++ b/src/main/java/com/jiwon/mylog/domain/notification/service/NotificationService.java @@ -8,6 +8,8 @@ import com.jiwon.mylog.global.common.entity.PageResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; @@ -27,6 +29,7 @@ public class NotificationService { private final SseEmitterRepository emitterRepository; private final NotificationRepository notificationRepository; + @CacheEvict(value = "notification::count", key="'userId:' + #userId", condition = "#userId != null") @Transactional public void updateNotificationRead(Long userId) { notificationRepository.updateReadStateByReceiverId(userId); @@ -48,6 +51,7 @@ public PageResponse getAllNotifications(Long userId, Pageable pageable) { ); } + @Cacheable(value = "notification::count", key = "'userId:' + #userId", condition = "#userId != null") @Transactional(readOnly = true) public NotificationCountResponse countUnreadNotifications(Long userId) { long countedNotification = notificationRepository.countByReceiverIdAndReadIsFalse(userId); diff --git a/src/main/java/com/jiwon/mylog/domain/post/dto/response/PostDetailResponse.java b/src/main/java/com/jiwon/mylog/domain/post/dto/response/PostDetailResponse.java index 3c54e8b..d288df4 100644 --- a/src/main/java/com/jiwon/mylog/domain/post/dto/response/PostDetailResponse.java +++ b/src/main/java/com/jiwon/mylog/domain/post/dto/response/PostDetailResponse.java @@ -14,8 +14,6 @@ import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.RequiredArgsConstructor; @Getter @Builder diff --git a/src/main/java/com/jiwon/mylog/global/common/config/SecurityConfig.java b/src/main/java/com/jiwon/mylog/global/common/config/SecurityConfig.java index 78324be..219ef1a 100644 --- a/src/main/java/com/jiwon/mylog/global/common/config/SecurityConfig.java +++ b/src/main/java/com/jiwon/mylog/global/common/config/SecurityConfig.java @@ -86,9 +86,9 @@ public SecurityFilterChain filterChain(HttpSecurity http, JwtService jwtService) .requestMatchers("/v3/api-docs/**", "/swagger-ui/**", "/swagger-resources/**").permitAll() .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() // 단순 조회 (권한X) - .requestMatchers(HttpMethod.GET, "/api/users/**", "/api/posts/**", "/api/categories/**", "/api/images/**", "/api/points/**", "/api/items/**", "/api/sse/**").permitAll() + .requestMatchers(HttpMethod.GET, "/api/users/**", "/api/posts/**", "/api/categories/**", "/api/images/**", "/api/points/**", "/api/items/**", "/api/sse/**", "/api/likes/**").permitAll() // 블로그 사용자 - .requestMatchers("/api/users/**", "/api/posts/**", "/api/categories/**", "/api/comments/**", "/api/images/**", "/api/notifications/**").authenticated() + .requestMatchers("/api/users/**", "/api/posts/**", "/api/categories/**", "/api/comments/**", "/api/images/**", "/api/notifications/**", "/api/likes/**").authenticated() // 관리자 전용 .requestMatchers("/api/admin/**").hasRole("ADMIN")); diff --git a/src/main/java/com/jiwon/mylog/global/common/entity/SliceResponse.java b/src/main/java/com/jiwon/mylog/global/common/entity/SliceResponse.java new file mode 100644 index 0000000..75e9062 --- /dev/null +++ b/src/main/java/com/jiwon/mylog/global/common/entity/SliceResponse.java @@ -0,0 +1,30 @@ +package com.jiwon.mylog.global.common.entity; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +import java.util.List; + +@Builder +@AllArgsConstructor +@Getter +public class SliceResponse { + private final List objects; + private final int currentPage; + private final int size; + private final boolean first; + private final boolean last; + + public static SliceResponse from( + List objects, + int currentPage, int size, boolean first, boolean last) { + return SliceResponse.builder() + .objects(objects) + .currentPage(currentPage) + .size(size) + .first(first) + .last(last) + .build(); + } +}