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
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -33,7 +34,7 @@ public void handleCommentCreated(CommentCreatedEvent event) {

Notification notification = Notification.create(
receiver,
event.getCommentWriterName() + "왹이 외계 수집물에 발사를 남겼습니다.",
event.getCommentWriterName() + "왹이 외계 수집물에 발 사를 남기다!",
"/posts/" + event.getPostId(),
NotificationType.COMMENT
);
Expand All @@ -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 전송 실패");
}
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
47 changes: 47 additions & 0 deletions src/main/java/com/jiwon/mylog/domain/like/Like.java
Original file line number Diff line number Diff line change
@@ -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;
}
56 changes: 56 additions & 0 deletions src/main/java/com/jiwon/mylog/domain/like/LikeController.java
Original file line number Diff line number Diff line change
@@ -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<Void> createLike(@LoginUser Long userId, @PathVariable Long postId) {
likeService.createLike(userId, postId);
return new ResponseEntity<>(HttpStatus.CREATED);
}

@DeleteMapping("/posts/{postId}/likes")
public ResponseEntity<Void> deleteLike(@LoginUser Long userId, @PathVariable Long postId) {
likeService.deleteLike(userId, postId);
return new ResponseEntity<>(HttpStatus.NO_CONTENT);
}

@GetMapping("/posts/{postId}/likes/count")
public ResponseEntity<Long> getLikeCount(@PathVariable Long postId) {
long likeCount = likeService.getLikeCount(postId);
return ResponseEntity.ok(likeCount);
}

@GetMapping("/posts/{postId}/likes")
public ResponseEntity<Boolean> 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);
}
}
27 changes: 27 additions & 0 deletions src/main/java/com/jiwon/mylog/domain/like/LikeRepository.java
Original file line number Diff line number Diff line change
@@ -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<Like, Long> {
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<Like> findAllByUserId(Long userId, Pageable pageable);

@Query(value = "select count(*) from likes where post_id = :postId", nativeQuery = true)
long countByPostId(@Param("postId") Long postId);
}
97 changes: 97 additions & 0 deletions src/main/java/com/jiwon/mylog/domain/like/LikeService.java
Original file line number Diff line number Diff line change
@@ -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<Like> 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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,6 @@ public enum NotificationType {
FOLLOW,
UNFOLLOW,
SERVER,
LIKE,
ETC
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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);
Expand All @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,6 @@
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.RequiredArgsConstructor;

@Getter
@Builder
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"));

Expand Down
Original file line number Diff line number Diff line change
@@ -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<T> {
private final List<T> objects;
private final int currentPage;
private final int size;
private final boolean first;
private final boolean last;

public static <T> SliceResponse<T> from(
List<T> objects,
int currentPage, int size, boolean first, boolean last) {
return SliceResponse.<T>builder()
.objects(objects)
.currentPage(currentPage)
.size(size)
.first(first)
.last(last)
.build();
}
}