diff --git a/src/main/java/com/creatorhub/constant/ErrorCode.java b/src/main/java/com/creatorhub/constant/ErrorCode.java index 06712ce..e3764a8 100644 --- a/src/main/java/com/creatorhub/constant/ErrorCode.java +++ b/src/main/java/com/creatorhub/constant/ErrorCode.java @@ -17,7 +17,14 @@ public enum ErrorCode { // Creation 관련 에러 ALREADY_CREATION(HttpStatus.CONFLICT, "C001", "이미 존재하는 작품입니다."), CREATION_NOT_FOUND(HttpStatus.NOT_FOUND, "C002", "존재하는 않는 작품입니다."), - CREATION_FAVORITE_NOT_FOUND(HttpStatus.NOT_FOUND, "C003", "관심 작품으로 등록되어있지 않습니다."), + ALREADY_CREATION_FAVORITE(HttpStatus.CONFLICT, "C003", "이미 관심작품으로 등록한 작품입니다."), + CREATION_FAVORITE_NOT_FOUND(HttpStatus.NOT_FOUND, "C004", "관심 작품으로 등록되어있지 않습니다."), + + // Episode 관련 에러 + ALREADY_EPISODE(HttpStatus.CONFLICT, "E001", "이미 존재하는 회차입니다."), + EPISODE_NOT_FOUND(HttpStatus.NOT_FOUND, "E002", "존재하는 않는 회차입니다."), + ALREADY_EPISODE_LIKE(HttpStatus.CONFLICT, "E003", "이미 '좋아요'한 회차입니다."), + EPISODE_LIKE_NOT_FOUND(HttpStatus.NOT_FOUND, "E004", "'좋아요'를 하지 않은 회차입니다."), // Authorization 관련 에러 ACCESS_DENIED(HttpStatus.FORBIDDEN, "A001", "접근이 제한되었습니다."), diff --git a/src/main/java/com/creatorhub/controller/EpisodeLikeController.java b/src/main/java/com/creatorhub/controller/EpisodeLikeController.java new file mode 100644 index 0000000..1c60204 --- /dev/null +++ b/src/main/java/com/creatorhub/controller/EpisodeLikeController.java @@ -0,0 +1,40 @@ +package com.creatorhub.controller; + +import com.creatorhub.dto.EpisodeLikeRequest; +import com.creatorhub.dto.EpisodeLikeResponse; +import com.creatorhub.security.auth.CustomUserPrincipal; +import com.creatorhub.service.EpisodeLikeService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/episodes/likes") +public class EpisodeLikeController { + private final EpisodeLikeService episodeLikeService; + + @PostMapping("/create") + public ResponseEntity like( + @AuthenticationPrincipal CustomUserPrincipal principal, + @Valid @RequestBody EpisodeLikeRequest req + ) { + EpisodeLikeResponse episodeLikeResponse + = episodeLikeService.like(principal.id(), req.episodeId()); + + return ResponseEntity.ok(episodeLikeResponse); + } + + @DeleteMapping("/delete/{episodeId}") + public ResponseEntity unlike( + @AuthenticationPrincipal CustomUserPrincipal principal, + @PathVariable Long episodeId + ) { + EpisodeLikeResponse episodeLikeResponse + = episodeLikeService.unlike(principal.id(), episodeId); + + return ResponseEntity.ok(episodeLikeResponse); + } +} diff --git a/src/main/java/com/creatorhub/dto/EpisodeLikeRequest.java b/src/main/java/com/creatorhub/dto/EpisodeLikeRequest.java new file mode 100644 index 0000000..e0d310e --- /dev/null +++ b/src/main/java/com/creatorhub/dto/EpisodeLikeRequest.java @@ -0,0 +1,9 @@ +package com.creatorhub.dto; + +import jakarta.validation.constraints.NotNull; + +public record EpisodeLikeRequest( + @NotNull(message = "episodeId가 존재하지 않습니다.") + Long episodeId +) { +} diff --git a/src/main/java/com/creatorhub/dto/EpisodeLikeResponse.java b/src/main/java/com/creatorhub/dto/EpisodeLikeResponse.java new file mode 100644 index 0000000..b418795 --- /dev/null +++ b/src/main/java/com/creatorhub/dto/EpisodeLikeResponse.java @@ -0,0 +1,11 @@ +package com.creatorhub.dto; + +public record EpisodeLikeResponse( + Long episodeId, + Integer likeCount, + boolean liked +) { + public static EpisodeLikeResponse of(Long episodeId, Integer likeCount, boolean liked) { + return new EpisodeLikeResponse(episodeId, likeCount, liked); + } +} \ No newline at end of file diff --git a/src/main/java/com/creatorhub/entity/EpisodeLike.java b/src/main/java/com/creatorhub/entity/EpisodeLike.java new file mode 100644 index 0000000..297e898 --- /dev/null +++ b/src/main/java/com/creatorhub/entity/EpisodeLike.java @@ -0,0 +1,49 @@ +package com.creatorhub.entity; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.SQLRestriction; + +@Entity +@Table( + name = "episode_like", + indexes = { + @Index(name = "idx_episode_like_episode", columnList = "episode_id"), + @Index(name = "idx_episode_like_member_created_at", columnList = "member_id, created_at") + } +) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@SQLDelete(sql = "UPDATE episode_like SET deleted_at = NOW() WHERE id = ?") +@SQLRestriction("deleted_at IS NULL") +public class EpisodeLike extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "member_id", nullable = false) + private Member member; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "episode_id", nullable = false) + private Episode episode; + + @Builder(access = AccessLevel.PRIVATE) + private EpisodeLike(Member member, Episode episode) { + this.member = member; + this.episode = episode; + } + + public static EpisodeLike like(Member member, Episode episode) { + return EpisodeLike.builder() + .member(member) + .episode(episode) + .build(); + } +} diff --git a/src/main/java/com/creatorhub/exception/AlreadyEpisodeLikeException.java b/src/main/java/com/creatorhub/exception/AlreadyEpisodeLikeException.java new file mode 100644 index 0000000..cfce68f --- /dev/null +++ b/src/main/java/com/creatorhub/exception/AlreadyEpisodeLikeException.java @@ -0,0 +1,14 @@ +package com.creatorhub.exception; + +import com.creatorhub.constant.ErrorCode; + +public class AlreadyEpisodeLikeException extends CreatorException { + + public AlreadyEpisodeLikeException() { + super(ErrorCode.ALREADY_EPISODE_LIKE); + } + + public AlreadyEpisodeLikeException(String message) { + super(ErrorCode.ALREADY_EPISODE_LIKE, message); + } +} diff --git a/src/main/java/com/creatorhub/exception/EpisodeException.java b/src/main/java/com/creatorhub/exception/EpisodeException.java new file mode 100644 index 0000000..aa60a7f --- /dev/null +++ b/src/main/java/com/creatorhub/exception/EpisodeException.java @@ -0,0 +1,19 @@ +package com.creatorhub.exception; + +import com.creatorhub.constant.ErrorCode; +import lombok.Getter; + +@Getter +public class EpisodeException extends RuntimeException { + private final ErrorCode errorCode; + + public EpisodeException(ErrorCode errorCode) { + super(errorCode.getMessage()); + this.errorCode = errorCode; + } + + public EpisodeException(ErrorCode errorCode, String message) { + super(message); + this.errorCode = errorCode; + } +} diff --git a/src/main/java/com/creatorhub/exception/EpisodeLikeNotFoundException.java b/src/main/java/com/creatorhub/exception/EpisodeLikeNotFoundException.java new file mode 100644 index 0000000..ccb7f24 --- /dev/null +++ b/src/main/java/com/creatorhub/exception/EpisodeLikeNotFoundException.java @@ -0,0 +1,14 @@ +package com.creatorhub.exception; + +import com.creatorhub.constant.ErrorCode; + +public class EpisodeLikeNotFoundException extends CreatorException { + + public EpisodeLikeNotFoundException() { + super(ErrorCode.EPISODE_LIKE_NOT_FOUND); + } + + public EpisodeLikeNotFoundException(String message) { + super(ErrorCode.EPISODE_LIKE_NOT_FOUND, message); + } +} diff --git a/src/main/java/com/creatorhub/exception/EpisodeNotFoundException.java b/src/main/java/com/creatorhub/exception/EpisodeNotFoundException.java new file mode 100644 index 0000000..85e4a85 --- /dev/null +++ b/src/main/java/com/creatorhub/exception/EpisodeNotFoundException.java @@ -0,0 +1,14 @@ +package com.creatorhub.exception; + +import com.creatorhub.constant.ErrorCode; + +public class EpisodeNotFoundException extends CreatorException { + + public EpisodeNotFoundException() { + super(ErrorCode.EPISODE_NOT_FOUND); + } + + public EpisodeNotFoundException(String message) { + super(ErrorCode.EPISODE_NOT_FOUND, message); + } +} diff --git a/src/main/java/com/creatorhub/exception/GlobalExceptionHandler.java b/src/main/java/com/creatorhub/exception/GlobalExceptionHandler.java index 521ec5f..90e4ca3 100644 --- a/src/main/java/com/creatorhub/exception/GlobalExceptionHandler.java +++ b/src/main/java/com/creatorhub/exception/GlobalExceptionHandler.java @@ -53,7 +53,7 @@ public ResponseEntity handleCreatorException( } /** - * Creator 관련 예외 처리 + * Creation 관련 예외 처리 */ @ExceptionHandler(CreationException.class) public ResponseEntity handleCreationException( @@ -70,6 +70,24 @@ public ResponseEntity handleCreationException( .body(errorResponse); } + /** + * Episode 관련 예외 처리 + */ + @ExceptionHandler(EpisodeException.class) + public ResponseEntity handleEpisodeException( + EpisodeException ex, + HttpServletRequest request) { + + log.warn("EpisodeException occurred - Message: {}", ex.getMessage()); + + ErrorResponse errorResponse = + ErrorResponse.of(ex.getErrorCode(), request.getRequestURI()); + + return ResponseEntity + .status(ex.getErrorCode().getHttpStatus()) + .body(errorResponse); + } + /** * 요청 데이터 검증 오류 처리 (@Valid 실패) diff --git a/src/main/java/com/creatorhub/repository/EpisodeLikeRepository.java b/src/main/java/com/creatorhub/repository/EpisodeLikeRepository.java new file mode 100644 index 0000000..82ea6ca --- /dev/null +++ b/src/main/java/com/creatorhub/repository/EpisodeLikeRepository.java @@ -0,0 +1,13 @@ +package com.creatorhub.repository; + +import com.creatorhub.entity.EpisodeLike; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface EpisodeLikeRepository extends JpaRepository { + + boolean existsByMemberIdAndEpisodeId(Long memberId, Long episodeId); + + Optional findByMemberIdAndEpisodeId(Long memberId, Long episodeId); +} diff --git a/src/main/java/com/creatorhub/repository/EpisodeRepository.java b/src/main/java/com/creatorhub/repository/EpisodeRepository.java index ea05c35..6dc95fe 100644 --- a/src/main/java/com/creatorhub/repository/EpisodeRepository.java +++ b/src/main/java/com/creatorhub/repository/EpisodeRepository.java @@ -2,7 +2,24 @@ import com.creatorhub.entity.Episode; 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; public interface EpisodeRepository extends JpaRepository { boolean existsByCreationIdAndEpisodeNum(Long creationId, Integer episodeNum); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + UPDATE Episode e + SET e.likeCount = + CASE + WHEN :delta < 0 AND COALESCE(e.likeCount, 0) <= 0 + THEN 0 + ELSE COALESCE(e.likeCount, 0) + :delta + END + WHERE e.id = :episodeId + """) + void updateLikeCountSafely(@Param("episodeId") Long episodeId, + @Param("delta") int delta); } \ No newline at end of file diff --git a/src/main/java/com/creatorhub/service/EpisodeLikeService.java b/src/main/java/com/creatorhub/service/EpisodeLikeService.java new file mode 100644 index 0000000..6beb22c --- /dev/null +++ b/src/main/java/com/creatorhub/service/EpisodeLikeService.java @@ -0,0 +1,64 @@ +package com.creatorhub.service; + +import com.creatorhub.dto.EpisodeLikeResponse; +import com.creatorhub.entity.Episode; +import com.creatorhub.entity.EpisodeLike; +import com.creatorhub.entity.Member; +import com.creatorhub.exception.AlreadyEpisodeLikeException; +import com.creatorhub.exception.EpisodeLikeNotFoundException; +import com.creatorhub.exception.EpisodeNotFoundException; +import com.creatorhub.exception.MemberNotFoundException; +import com.creatorhub.repository.EpisodeLikeRepository; +import com.creatorhub.repository.EpisodeRepository; +import com.creatorhub.repository.MemberRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class EpisodeLikeService { + private final EpisodeLikeRepository episodeLikeRepository; + private final EpisodeRepository episodeRepository; + private final MemberRepository memberRepository; + + @Transactional + public EpisodeLikeResponse like(Long memberId, Long episodeId) { + Member member = memberRepository.findById(memberId) + .orElseThrow(MemberNotFoundException::new); + + Episode episode = episodeRepository.findById(episodeId) + .orElseThrow(EpisodeNotFoundException::new); + + // 이미 좋아요 + if (episodeLikeRepository.existsByMemberIdAndEpisodeId(memberId, episodeId)) { + throw new AlreadyEpisodeLikeException(); + } + + episodeLikeRepository.save(EpisodeLike.like(member, episode)); + + episodeRepository.updateLikeCountSafely(episodeId, +1); + + Integer newCount = episodeRepository.findById(episodeId) + .map(ep -> ep.getLikeCount() == null ? 0 : ep.getLikeCount()) + .orElse(0); + + return EpisodeLikeResponse.of(episodeId, newCount, true); + } + + @Transactional + public EpisodeLikeResponse unlike(Long memberId, Long episodeId) { + EpisodeLike like = episodeLikeRepository.findByMemberIdAndEpisodeId(memberId, episodeId) + .orElseThrow(EpisodeLikeNotFoundException::new); + + episodeLikeRepository.delete(like); + + episodeRepository.updateLikeCountSafely(episodeId, -1); + + Integer newCount = episodeRepository.findById(episodeId) + .map(ep -> ep.getLikeCount() == null ? 0 : ep.getLikeCount()) + .orElse(0); + + return EpisodeLikeResponse.of(episodeId, newCount, false); + } +}