From 9f3844cfdd69768d0a69bc340d89ee269e38f2d4 Mon Sep 17 00:00:00 2001 From: dungbik Date: Wed, 27 Aug 2025 22:46:00 +0900 Subject: [PATCH 1/8] =?UTF-8?q?Feat:=20=EC=A2=8B=EC=95=84=EC=9A=94=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EA=B8=B0=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../cardset/entity/CardSetMetadata.java | 29 ++++++++++++ .../listener/CardSetLikeEventHandler.java | 45 +++++++++++++++++++ .../repository/CardSetMetadataRepository.java | 15 +++++++ .../cardset/service/CardSetService.java | 35 +++++++++++++-- .../flipnote/common/entity/LikeType.java | 5 +++ .../common/model/event/LikeEvent.java | 10 +++++ .../like/controller/LikeController.java | 33 ++++++++++++++ .../controller/docs/LikeControllerDocs.java | 16 +++++++ .../project/flipnote/like/entity/Like.java | 44 ++++++++++++++++++ .../like/exception/LikeErrorCode.java | 25 +++++++++++ .../flipnote/like/model/LikeTypeRequest.java | 14 ++++++ .../like/repository/LikeRepository.java | 10 +++++ .../like/service/LikePolicyService.java | 35 +++++++++++++++ .../flipnote/like/service/LikeService.java | 37 +++++++++++++++ 14 files changed, 349 insertions(+), 4 deletions(-) create mode 100644 src/main/java/project/flipnote/cardset/entity/CardSetMetadata.java create mode 100644 src/main/java/project/flipnote/cardset/listener/CardSetLikeEventHandler.java create mode 100644 src/main/java/project/flipnote/cardset/repository/CardSetMetadataRepository.java create mode 100644 src/main/java/project/flipnote/common/entity/LikeType.java create mode 100644 src/main/java/project/flipnote/common/model/event/LikeEvent.java create mode 100644 src/main/java/project/flipnote/like/controller/LikeController.java create mode 100644 src/main/java/project/flipnote/like/controller/docs/LikeControllerDocs.java create mode 100644 src/main/java/project/flipnote/like/entity/Like.java create mode 100644 src/main/java/project/flipnote/like/exception/LikeErrorCode.java create mode 100644 src/main/java/project/flipnote/like/model/LikeTypeRequest.java create mode 100644 src/main/java/project/flipnote/like/repository/LikeRepository.java create mode 100644 src/main/java/project/flipnote/like/service/LikePolicyService.java create mode 100644 src/main/java/project/flipnote/like/service/LikeService.java diff --git a/src/main/java/project/flipnote/cardset/entity/CardSetMetadata.java b/src/main/java/project/flipnote/cardset/entity/CardSetMetadata.java new file mode 100644 index 00000000..c5cbb4f8 --- /dev/null +++ b/src/main/java/project/flipnote/cardset/entity/CardSetMetadata.java @@ -0,0 +1,29 @@ +package project.flipnote.cardset.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "card_set_metadata") +@Entity +public class CardSetMetadata { + + @Id + private Long id; + + @Column(nullable = false) + private Integer likeCount; + + @Builder + public CardSetMetadata(Long id) { + this.id = id; + this.likeCount = 0; + } +} diff --git a/src/main/java/project/flipnote/cardset/listener/CardSetLikeEventHandler.java b/src/main/java/project/flipnote/cardset/listener/CardSetLikeEventHandler.java new file mode 100644 index 00000000..810c7350 --- /dev/null +++ b/src/main/java/project/flipnote/cardset/listener/CardSetLikeEventHandler.java @@ -0,0 +1,45 @@ +package project.flipnote.cardset.listener; + +import org.springframework.retry.annotation.Backoff; +import org.springframework.retry.annotation.Recover; +import org.springframework.retry.annotation.Retryable; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import project.flipnote.cardset.service.CardSetService; +import project.flipnote.common.entity.LikeType; +import project.flipnote.common.model.event.LikeEvent; + +@Slf4j +@RequiredArgsConstructor +@Component +public class CardSetLikeEventHandler { + + private final CardSetService cardSetService; + + @Async + @Retryable( + maxAttempts = 3, + backoff = @Backoff(delay = 2000, multiplier = 2) + ) + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleEmailVerificationSendEvent(LikeEvent event) { + if (event.likeType() != LikeType.CARD_SET) { + return; + } + + cardSetService.incrementLikeCount(event.targetId()); + } + + @Recover + public void recover(Exception ex, LikeEvent event) { + log.error( + "좋아요 수 반영 처리 중 예외 발생 : likeType={}, targetId={}, userId={}", + event.likeType(), event.targetId(), event.userId(), ex + ); + } +} diff --git a/src/main/java/project/flipnote/cardset/repository/CardSetMetadataRepository.java b/src/main/java/project/flipnote/cardset/repository/CardSetMetadataRepository.java new file mode 100644 index 00000000..c785bffb --- /dev/null +++ b/src/main/java/project/flipnote/cardset/repository/CardSetMetadataRepository.java @@ -0,0 +1,15 @@ +package project.flipnote.cardset.repository; + +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 project.flipnote.cardset.entity.CardSetMetadata; + +public interface CardSetMetadataRepository extends JpaRepository { + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query("UPDATE CardSetMetadata m SET m.likeCount = m.likeCount + 1 WHERE m.id = :cardSetId") + void incrementLikeCount(@Param("cardSetId") Long cardSetId); +} diff --git a/src/main/java/project/flipnote/cardset/service/CardSetService.java b/src/main/java/project/flipnote/cardset/service/CardSetService.java index 31732ed3..00338977 100644 --- a/src/main/java/project/flipnote/cardset/service/CardSetService.java +++ b/src/main/java/project/flipnote/cardset/service/CardSetService.java @@ -8,6 +8,7 @@ import lombok.extern.slf4j.Slf4j; import project.flipnote.cardset.entity.CardSet; import project.flipnote.cardset.entity.CardSetManager; +import project.flipnote.cardset.entity.CardSetMetadata; import project.flipnote.cardset.exception.CardSetErrorCode; import project.flipnote.cardset.model.CardSetDetailResponse; import project.flipnote.cardset.model.CardSetSearchRequest; @@ -17,17 +18,16 @@ import project.flipnote.cardset.model.CreateCardSetRequest; import project.flipnote.cardset.model.CreateCardSetResponse; import project.flipnote.cardset.repository.CardSetManagerRepository; +import project.flipnote.cardset.repository.CardSetMetadataRepository; import project.flipnote.cardset.repository.CardSetRepository; import project.flipnote.common.exception.BizException; import project.flipnote.common.model.response.PagingResponse; import project.flipnote.common.security.dto.AuthPrinciple; import project.flipnote.group.entity.Category; import project.flipnote.group.entity.Group; -import project.flipnote.group.entity.GroupPermissionStatus; import project.flipnote.group.exception.GroupErrorCode; import project.flipnote.group.repository.GroupMemberRepository; import project.flipnote.group.repository.GroupRepository; -import project.flipnote.group.service.GroupService; import project.flipnote.user.entity.UserProfile; import project.flipnote.user.entity.UserStatus; import project.flipnote.user.exception.UserErrorCode; @@ -44,8 +44,8 @@ public class CardSetService { private final GroupRepository groupRepository; private final GroupMemberRepository groupMemberRepository; private final CardSetManagerRepository cardSetManagerRepository; - private final GroupService groupService; - private final CardSetPolicyService cardSetPolicyService; + private final CardSetPolicyService cardSetPolicyService; + private final CardSetMetadataRepository cardSetMetadataRepository; private UserProfile validateUser(Long userId) { return userProfileRepository.findByIdAndStatus(userId, UserStatus.ACTIVE).orElseThrow( @@ -93,6 +93,11 @@ public CreateCardSetResponse createCardSet(Long groupId, AuthPrinciple authPrinc cardSetRepository.save(cardSet); + CardSetMetadata metadata = CardSetMetadata.builder() + .id(cardSet.getId()) + .build(); + cardSetMetadataRepository.save(metadata); + //카드셋 매니저도 저장 CardSetManager cardSetManager = CardSetManager.builder() .user(user) @@ -163,4 +168,26 @@ public CardSetDetailResponse updateCardSet(Long userId, Long groupId, Long cardS return CardSetDetailResponse.from(cardSet); } + + /** + * 카드셋 존재 여부 확인 + * + * @param cardSetId 존재하는지 확인할 카드셋 ID + * @return 카드셋 존재 여부 + * @author 윤정환 + */ + public boolean existsById(Long cardSetId) { + return cardSetRepository.existsById(cardSetId); + } + + /** + * 카드셋 좋아요 수를 1 증가 + * + * @param cardSetId 좋아요 수를 증가시킬 카드셋 ID + * @author 윤정환 + */ + @Transactional + public void incrementLikeCount(Long cardSetId) { + cardSetMetadataRepository.incrementLikeCount(cardSetId); + } } diff --git a/src/main/java/project/flipnote/common/entity/LikeType.java b/src/main/java/project/flipnote/common/entity/LikeType.java new file mode 100644 index 00000000..20966372 --- /dev/null +++ b/src/main/java/project/flipnote/common/entity/LikeType.java @@ -0,0 +1,5 @@ +package project.flipnote.common.entity; + +public enum LikeType { + CARD_SET +} diff --git a/src/main/java/project/flipnote/common/model/event/LikeEvent.java b/src/main/java/project/flipnote/common/model/event/LikeEvent.java new file mode 100644 index 00000000..5b80de8a --- /dev/null +++ b/src/main/java/project/flipnote/common/model/event/LikeEvent.java @@ -0,0 +1,10 @@ +package project.flipnote.common.model.event; + +import project.flipnote.common.entity.LikeType; + +public record LikeEvent( + LikeType likeType, + Long targetId, + Long userId +) { +} diff --git a/src/main/java/project/flipnote/like/controller/LikeController.java b/src/main/java/project/flipnote/like/controller/LikeController.java new file mode 100644 index 00000000..8564bd39 --- /dev/null +++ b/src/main/java/project/flipnote/like/controller/LikeController.java @@ -0,0 +1,33 @@ +package project.flipnote.like.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +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; + +import lombok.RequiredArgsConstructor; +import project.flipnote.common.security.dto.AuthPrinciple; +import project.flipnote.like.controller.docs.LikeControllerDocs; +import project.flipnote.like.model.LikeTypeRequest; +import project.flipnote.like.service.LikeService; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/v1/likes") +public class LikeController implements LikeControllerDocs { + + private final LikeService likeService; + + @PostMapping("/{type}/{targetId}") + public ResponseEntity addLike( + @PathVariable("type") LikeTypeRequest likeType, + @PathVariable("targetId") Long targetId, + @AuthenticationPrincipal AuthPrinciple authPrinciple + ) { + likeService.addLike(authPrinciple.userId(), likeType.toDomain(), targetId); + + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/project/flipnote/like/controller/docs/LikeControllerDocs.java b/src/main/java/project/flipnote/like/controller/docs/LikeControllerDocs.java new file mode 100644 index 00000000..042ea6cd --- /dev/null +++ b/src/main/java/project/flipnote/like/controller/docs/LikeControllerDocs.java @@ -0,0 +1,16 @@ +package project.flipnote.like.controller.docs; + +import org.springframework.http.ResponseEntity; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.security.SecurityRequirement; +import io.swagger.v3.oas.annotations.tags.Tag; +import project.flipnote.common.security.dto.AuthPrinciple; +import project.flipnote.like.model.LikeTypeRequest; + +@Tag(name = "Like", description = "Like API") +public interface LikeControllerDocs { + + @Operation(summary = "좋아요 추가", security = {@SecurityRequirement(name = "access-token")}) + ResponseEntity addLike(LikeTypeRequest likeType, Long targetId, AuthPrinciple authPrinciple); +} diff --git a/src/main/java/project/flipnote/like/entity/Like.java b/src/main/java/project/flipnote/like/entity/Like.java new file mode 100644 index 00000000..76481bb1 --- /dev/null +++ b/src/main/java/project/flipnote/like/entity/Like.java @@ -0,0 +1,44 @@ +package project.flipnote.like.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import project.flipnote.common.entity.BaseEntity; +import project.flipnote.common.entity.LikeType; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "likes") +@Entity +public class Like extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private LikeType type; + + @Column(nullable = false) + private Long targetId; + + @Column(nullable = false) + private Long userId; + + @Builder + public Like(LikeType type, Long targetId, Long userId) { + this.type = type; + this.targetId = targetId; + this.userId = userId; + } +} diff --git a/src/main/java/project/flipnote/like/exception/LikeErrorCode.java b/src/main/java/project/flipnote/like/exception/LikeErrorCode.java new file mode 100644 index 00000000..171bff41 --- /dev/null +++ b/src/main/java/project/flipnote/like/exception/LikeErrorCode.java @@ -0,0 +1,25 @@ +package project.flipnote.like.exception; + +import org.springframework.http.HttpStatus; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import project.flipnote.common.exception.ErrorCode; + +@Getter +@RequiredArgsConstructor +public enum LikeErrorCode implements ErrorCode { + + INVALID_LIKE_TYPE(HttpStatus.BAD_REQUEST, "LIKE_001", "유효하지 않은 좋아요 타입입니다."), + LIKE_TARGET_NOT_FOUND(HttpStatus.NOT_FOUND, "LIKE_002", "좋아요 대상이 존재하지 않습니다."), + ALREADY_LIKED(HttpStatus.CONFLICT, "LIKE_003", "이미 좋아요를 눌렀습니다."); + + private final HttpStatus httpStatus; + private final String code; + private final String message; + + @Override + public int getStatus() { + return httpStatus.value(); + } +} diff --git a/src/main/java/project/flipnote/like/model/LikeTypeRequest.java b/src/main/java/project/flipnote/like/model/LikeTypeRequest.java new file mode 100644 index 00000000..361ab895 --- /dev/null +++ b/src/main/java/project/flipnote/like/model/LikeTypeRequest.java @@ -0,0 +1,14 @@ +package project.flipnote.like.model; + +import project.flipnote.common.entity.LikeType; + +public enum LikeTypeRequest { + card_sets; + + public LikeType toDomain() { + switch (this) { + case card_sets: return LikeType.CARD_SET; + default: throw new IllegalArgumentException("Invalid LikeTypeRequest"); + } + } +} diff --git a/src/main/java/project/flipnote/like/repository/LikeRepository.java b/src/main/java/project/flipnote/like/repository/LikeRepository.java new file mode 100644 index 00000000..bc43c100 --- /dev/null +++ b/src/main/java/project/flipnote/like/repository/LikeRepository.java @@ -0,0 +1,10 @@ +package project.flipnote.like.repository; + +import org.springframework.data.jpa.repository.JpaRepository; + +import project.flipnote.like.entity.Like; +import project.flipnote.common.entity.LikeType; + +public interface LikeRepository extends JpaRepository { + boolean existsByTypeAndTargetIdAndUserId(LikeType likeType, Long targetId, Long userId); +} diff --git a/src/main/java/project/flipnote/like/service/LikePolicyService.java b/src/main/java/project/flipnote/like/service/LikePolicyService.java new file mode 100644 index 00000000..a9162714 --- /dev/null +++ b/src/main/java/project/flipnote/like/service/LikePolicyService.java @@ -0,0 +1,35 @@ +package project.flipnote.like.service; + +import org.springframework.stereotype.Service; + +import lombok.RequiredArgsConstructor; +import project.flipnote.cardset.service.CardSetService; +import project.flipnote.common.entity.LikeType; +import project.flipnote.common.exception.BizException; +import project.flipnote.like.exception.LikeErrorCode; +import project.flipnote.like.repository.LikeRepository; + +@RequiredArgsConstructor +@Service +public class LikePolicyService { + + private final CardSetService cardSetService; + private final LikeRepository likeRepository; + + public void validateTargetExists(LikeType likeType, Long targetId) { + boolean targetExists = false; + switch (likeType) { + case CARD_SET -> targetExists = cardSetService.existsById(targetId); + } + + if (!targetExists) { + throw new BizException(LikeErrorCode.LIKE_TARGET_NOT_FOUND); + } + } + + public void validateNotAlreadyLiked(LikeType likeType, Long targetId, Long userId) { + if (likeRepository.existsByTypeAndTargetIdAndUserId(likeType, targetId, userId)) { + throw new BizException(LikeErrorCode.ALREADY_LIKED); + } + } +} diff --git a/src/main/java/project/flipnote/like/service/LikeService.java b/src/main/java/project/flipnote/like/service/LikeService.java new file mode 100644 index 00000000..5c5964d8 --- /dev/null +++ b/src/main/java/project/flipnote/like/service/LikeService.java @@ -0,0 +1,37 @@ +package project.flipnote.like.service; + +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import project.flipnote.common.entity.LikeType; +import project.flipnote.common.model.event.LikeEvent; +import project.flipnote.like.entity.Like; +import project.flipnote.like.repository.LikeRepository; + +@Slf4j +@RequiredArgsConstructor +@Service +public class LikeService { + + private final LikeRepository likeRepository; + private final ApplicationEventPublisher eventPublisher; + private final LikePolicyService likePolicyService; + + @Transactional + public void addLike(Long userId, LikeType likeType, Long targetId) { + likePolicyService.validateTargetExists(likeType, targetId); + likePolicyService.validateNotAlreadyLiked(likeType, targetId, userId); + + Like like = Like.builder() + .type(likeType) + .targetId(targetId) + .userId(userId) + .build(); + likeRepository.save(like); + + eventPublisher.publishEvent(new LikeEvent(likeType, targetId, userId)); + } +} From 5e499837a5f6bdf8b5e87480fe4bb77dbb98ab41 Mon Sep 17 00:00:00 2001 From: dungbik Date: Wed, 27 Aug 2025 23:02:24 +0900 Subject: [PATCH 2/8] =?UTF-8?q?Feat:=20=EC=A2=8B=EC=95=84=EC=9A=94=20?= =?UTF-8?q?=EC=B7=A8=EC=86=8C=20=EA=B8=B0=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../listener/CardSetLikeEventHandler.java | 2 +- .../listener/CardSetUnlikeEventHandler.java | 45 +++++++++++++++++++ .../repository/CardSetMetadataRepository.java | 5 +++ .../cardset/service/CardSetService.java | 11 +++++ .../common/model/event/UnlikeEvent.java | 10 +++++ .../like/controller/LikeController.java | 12 +++++ .../controller/docs/LikeControllerDocs.java | 3 ++ .../like/exception/LikeErrorCode.java | 3 +- .../like/repository/LikeRepository.java | 4 ++ .../flipnote/like/service/LikeService.java | 30 +++++++++++++ 10 files changed, 123 insertions(+), 2 deletions(-) create mode 100644 src/main/java/project/flipnote/cardset/listener/CardSetUnlikeEventHandler.java create mode 100644 src/main/java/project/flipnote/common/model/event/UnlikeEvent.java diff --git a/src/main/java/project/flipnote/cardset/listener/CardSetLikeEventHandler.java b/src/main/java/project/flipnote/cardset/listener/CardSetLikeEventHandler.java index 810c7350..e7e46f70 100644 --- a/src/main/java/project/flipnote/cardset/listener/CardSetLikeEventHandler.java +++ b/src/main/java/project/flipnote/cardset/listener/CardSetLikeEventHandler.java @@ -27,7 +27,7 @@ public class CardSetLikeEventHandler { backoff = @Backoff(delay = 2000, multiplier = 2) ) @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) - public void handleEmailVerificationSendEvent(LikeEvent event) { + public void handleLikeEvent(LikeEvent event) { if (event.likeType() != LikeType.CARD_SET) { return; } diff --git a/src/main/java/project/flipnote/cardset/listener/CardSetUnlikeEventHandler.java b/src/main/java/project/flipnote/cardset/listener/CardSetUnlikeEventHandler.java new file mode 100644 index 00000000..888d2b44 --- /dev/null +++ b/src/main/java/project/flipnote/cardset/listener/CardSetUnlikeEventHandler.java @@ -0,0 +1,45 @@ +package project.flipnote.cardset.listener; + +import org.springframework.retry.annotation.Backoff; +import org.springframework.retry.annotation.Recover; +import org.springframework.retry.annotation.Retryable; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import project.flipnote.cardset.service.CardSetService; +import project.flipnote.common.entity.LikeType; +import project.flipnote.common.model.event.UnlikeEvent; + +@Slf4j +@RequiredArgsConstructor +@Component +public class CardSetUnlikeEventHandler { + + private final CardSetService cardSetService; + + @Async + @Retryable( + maxAttempts = 3, + backoff = @Backoff(delay = 2000, multiplier = 2) + ) + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleUnlikeEvent(UnlikeEvent event) { + if (event.likeType() != LikeType.CARD_SET) { + return; + } + + cardSetService.decrementLikeCount(event.targetId()); + } + + @Recover + public void recover(Exception ex, UnlikeEvent event) { + log.error( + "좋아요 취소 수 반영 처리 중 예외 발생 : likeType={}, targetId={}, userId={}", + event.likeType(), event.targetId(), event.userId(), ex + ); + } +} diff --git a/src/main/java/project/flipnote/cardset/repository/CardSetMetadataRepository.java b/src/main/java/project/flipnote/cardset/repository/CardSetMetadataRepository.java index c785bffb..f8e3291b 100644 --- a/src/main/java/project/flipnote/cardset/repository/CardSetMetadataRepository.java +++ b/src/main/java/project/flipnote/cardset/repository/CardSetMetadataRepository.java @@ -12,4 +12,9 @@ public interface CardSetMetadataRepository extends JpaRepository addLike( return ResponseEntity.ok().build(); } + + @DeleteMapping("/{type}/{targetId}") + public ResponseEntity removeLike( + @PathVariable("type") LikeTypeRequest likeType, + @PathVariable("targetId") Long targetId, + @AuthenticationPrincipal AuthPrinciple authPrinciple + ) { + likeService.removeLike(authPrinciple.userId(), likeType.toDomain(), targetId); + + return ResponseEntity.ok().build(); + } } diff --git a/src/main/java/project/flipnote/like/controller/docs/LikeControllerDocs.java b/src/main/java/project/flipnote/like/controller/docs/LikeControllerDocs.java index 042ea6cd..b819d97d 100644 --- a/src/main/java/project/flipnote/like/controller/docs/LikeControllerDocs.java +++ b/src/main/java/project/flipnote/like/controller/docs/LikeControllerDocs.java @@ -13,4 +13,7 @@ public interface LikeControllerDocs { @Operation(summary = "좋아요 추가", security = {@SecurityRequirement(name = "access-token")}) ResponseEntity addLike(LikeTypeRequest likeType, Long targetId, AuthPrinciple authPrinciple); + + @Operation(summary = "좋아요 취소", security = {@SecurityRequirement(name = "access-token")}) + ResponseEntity removeLike(LikeTypeRequest likeType, Long targetId, AuthPrinciple authPrinciple); } diff --git a/src/main/java/project/flipnote/like/exception/LikeErrorCode.java b/src/main/java/project/flipnote/like/exception/LikeErrorCode.java index 171bff41..e1c22611 100644 --- a/src/main/java/project/flipnote/like/exception/LikeErrorCode.java +++ b/src/main/java/project/flipnote/like/exception/LikeErrorCode.java @@ -12,7 +12,8 @@ public enum LikeErrorCode implements ErrorCode { INVALID_LIKE_TYPE(HttpStatus.BAD_REQUEST, "LIKE_001", "유효하지 않은 좋아요 타입입니다."), LIKE_TARGET_NOT_FOUND(HttpStatus.NOT_FOUND, "LIKE_002", "좋아요 대상이 존재하지 않습니다."), - ALREADY_LIKED(HttpStatus.CONFLICT, "LIKE_003", "이미 좋아요를 눌렀습니다."); + ALREADY_LIKED(HttpStatus.CONFLICT, "LIKE_003", "이미 좋아요를 눌렀습니다."), + LIKE_NOT_FOUND(HttpStatus.NOT_FOUND, "LIKE_004", "좋아요가 존재하지 않습니다."); private final HttpStatus httpStatus; private final String code; diff --git a/src/main/java/project/flipnote/like/repository/LikeRepository.java b/src/main/java/project/flipnote/like/repository/LikeRepository.java index bc43c100..67ab4f03 100644 --- a/src/main/java/project/flipnote/like/repository/LikeRepository.java +++ b/src/main/java/project/flipnote/like/repository/LikeRepository.java @@ -1,5 +1,7 @@ package project.flipnote.like.repository; +import java.util.Optional; + import org.springframework.data.jpa.repository.JpaRepository; import project.flipnote.like.entity.Like; @@ -7,4 +9,6 @@ public interface LikeRepository extends JpaRepository { boolean existsByTypeAndTargetIdAndUserId(LikeType likeType, Long targetId, Long userId); + + Optional findByTypeAndTargetIdAndUserId(LikeType likeType, Long targetId, Long userId); } diff --git a/src/main/java/project/flipnote/like/service/LikeService.java b/src/main/java/project/flipnote/like/service/LikeService.java index 5c5964d8..2e0b840e 100644 --- a/src/main/java/project/flipnote/like/service/LikeService.java +++ b/src/main/java/project/flipnote/like/service/LikeService.java @@ -7,12 +7,16 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import project.flipnote.common.entity.LikeType; +import project.flipnote.common.exception.BizException; import project.flipnote.common.model.event.LikeEvent; +import project.flipnote.common.model.event.UnlikeEvent; import project.flipnote.like.entity.Like; +import project.flipnote.like.exception.LikeErrorCode; import project.flipnote.like.repository.LikeRepository; @Slf4j @RequiredArgsConstructor +@Transactional(readOnly = true) @Service public class LikeService { @@ -20,6 +24,14 @@ public class LikeService { private final ApplicationEventPublisher eventPublisher; private final LikePolicyService likePolicyService; + /** + * 좋아요 추가 + * + * @param userId 좋아요 누른 회원 ID + * @param likeType 좋아요 대상 타입 + * @param targetId 좋아요 대상 ID + * @author 윤정환 + */ @Transactional public void addLike(Long userId, LikeType likeType, Long targetId) { likePolicyService.validateTargetExists(likeType, targetId); @@ -34,4 +46,22 @@ public void addLike(Long userId, LikeType likeType, Long targetId) { eventPublisher.publishEvent(new LikeEvent(likeType, targetId, userId)); } + + /** + * 좋아요 취소 + * + * @param userId 좋아요 취소 누른 회원 ID + * @param likeType 좋아요 취소 대상 타입 + * @param targetId 좋아요 취소 대상 ID + * @author 윤정환 + */ + @Transactional + public void removeLike(Long userId, LikeType likeType, Long targetId) { + Like like = likeRepository.findByTypeAndTargetIdAndUserId(likeType, targetId, userId) + .orElseThrow(() -> new BizException(LikeErrorCode.LIKE_NOT_FOUND)); + + likeRepository.delete(like); + + eventPublisher.publishEvent(new UnlikeEvent(likeType, targetId, userId)); + } } From 94832367741860f976a98c4ab4e79f3dc090afa0 Mon Sep 17 00:00:00 2001 From: dungbik Date: Thu, 28 Aug 2025 22:38:36 +0900 Subject: [PATCH 3/8] =?UTF-8?q?Feat:=20=EC=A2=8B=EC=95=84=EC=9A=94=20?= =?UTF-8?q?=EB=88=84=EB=A5=B8=20=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../cardset/service/CardSetService.java | 17 ++++++ .../like/controller/LikeController.java | 19 ++++++ .../controller/docs/LikeControllerDocs.java | 11 ++++ .../like/model/CardSetLikeResponse.java | 18 ++++++ .../flipnote/like/model/LikeResponse.java | 17 ++++++ .../like/model/LikeSearchRequest.java | 18 ++++++ .../like/model/LikeTargetResponse.java | 5 ++ .../flipnote/like/model/LikeTypeRequest.java | 4 +- .../like/repository/LikeRepository.java | 4 ++ .../flipnote/like/service/LikeService.java | 60 +++++++++++++++++++ .../like/service/fetcher/CardSetFetcher.java | 29 +++++++++ .../service/fetcher/LikeTargetFetcher.java | 11 ++++ 12 files changed, 211 insertions(+), 2 deletions(-) create mode 100644 src/main/java/project/flipnote/like/model/CardSetLikeResponse.java create mode 100644 src/main/java/project/flipnote/like/model/LikeResponse.java create mode 100644 src/main/java/project/flipnote/like/model/LikeSearchRequest.java create mode 100644 src/main/java/project/flipnote/like/model/LikeTargetResponse.java create mode 100644 src/main/java/project/flipnote/like/service/fetcher/CardSetFetcher.java create mode 100644 src/main/java/project/flipnote/like/service/fetcher/LikeTargetFetcher.java diff --git a/src/main/java/project/flipnote/cardset/service/CardSetService.java b/src/main/java/project/flipnote/cardset/service/CardSetService.java index 2f80b5dc..c1a5d80a 100644 --- a/src/main/java/project/flipnote/cardset/service/CardSetService.java +++ b/src/main/java/project/flipnote/cardset/service/CardSetService.java @@ -1,5 +1,7 @@ package project.flipnote.cardset.service; +import java.util.List; + import org.springframework.data.domain.Page; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -201,4 +203,19 @@ public void incrementLikeCount(Long cardSetId) { public void decrementLikeCount(Long cardSetId) { cardSetMetadataRepository.decrementLikeCount(cardSetId); } + + /** + * 카드셋 ID 목록에 해당하는 카드셋 목록 조회 + * + * @param targetIds 조회할 카드셋 ID 목록 + * @return 조회된 카드셋 목록 + * @author 윤정환 + */ + @Transactional + public List getCardSetsByIds(List targetIds) { + // TODO: MSA로 전환시 전용 DTO로 변경 필요 + return cardSetRepository.findAllById(targetIds).stream() + .map(CardSetSummaryResponse::from) + .toList(); + } } diff --git a/src/main/java/project/flipnote/like/controller/LikeController.java b/src/main/java/project/flipnote/like/controller/LikeController.java index a08b4326..733668c0 100644 --- a/src/main/java/project/flipnote/like/controller/LikeController.java +++ b/src/main/java/project/flipnote/like/controller/LikeController.java @@ -3,14 +3,21 @@ import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; 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; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; +import project.flipnote.common.model.response.PagingResponse; import project.flipnote.common.security.dto.AuthPrinciple; import project.flipnote.like.controller.docs.LikeControllerDocs; +import project.flipnote.like.model.LikeResponse; +import project.flipnote.like.model.LikeSearchRequest; +import project.flipnote.like.model.LikeTargetResponse; import project.flipnote.like.model.LikeTypeRequest; import project.flipnote.like.service.LikeService; @@ -42,4 +49,16 @@ public ResponseEntity removeLike( return ResponseEntity.ok().build(); } + + @GetMapping("/{type}") + public ResponseEntity>> getLikes( + @PathVariable("type") LikeTypeRequest likeType, + @Valid @ModelAttribute LikeSearchRequest req, + @AuthenticationPrincipal AuthPrinciple authPrinciple + ) { + PagingResponse> res + = likeService.getLikes(authPrinciple.userId(), likeType.toDomain(), req); + + return ResponseEntity.ok(res); + } } diff --git a/src/main/java/project/flipnote/like/controller/docs/LikeControllerDocs.java b/src/main/java/project/flipnote/like/controller/docs/LikeControllerDocs.java index b819d97d..f90e37b9 100644 --- a/src/main/java/project/flipnote/like/controller/docs/LikeControllerDocs.java +++ b/src/main/java/project/flipnote/like/controller/docs/LikeControllerDocs.java @@ -5,7 +5,11 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.security.SecurityRequirement; import io.swagger.v3.oas.annotations.tags.Tag; +import project.flipnote.common.model.response.PagingResponse; import project.flipnote.common.security.dto.AuthPrinciple; +import project.flipnote.like.model.LikeResponse; +import project.flipnote.like.model.LikeSearchRequest; +import project.flipnote.like.model.LikeTargetResponse; import project.flipnote.like.model.LikeTypeRequest; @Tag(name = "Like", description = "Like API") @@ -16,4 +20,11 @@ public interface LikeControllerDocs { @Operation(summary = "좋아요 취소", security = {@SecurityRequirement(name = "access-token")}) ResponseEntity removeLike(LikeTypeRequest likeType, Long targetId, AuthPrinciple authPrinciple); + + @Operation(summary = "좋아요 누른 목록 조회", security = {@SecurityRequirement(name = "access-token")}) + ResponseEntity>> getLikes( + LikeTypeRequest likeType, + LikeSearchRequest req, + AuthPrinciple authPrinciple + ); } diff --git a/src/main/java/project/flipnote/like/model/CardSetLikeResponse.java b/src/main/java/project/flipnote/like/model/CardSetLikeResponse.java new file mode 100644 index 00000000..ece0dfac --- /dev/null +++ b/src/main/java/project/flipnote/like/model/CardSetLikeResponse.java @@ -0,0 +1,18 @@ +package project.flipnote.like.model; + +import lombok.AllArgsConstructor; +import lombok.Data; +import lombok.EqualsAndHashCode; +import project.flipnote.cardset.model.CardSetSummaryResponse; + +@EqualsAndHashCode(callSuper = true) +@AllArgsConstructor +@Data +public class CardSetLikeResponse extends LikeTargetResponse { + private Long id; + private String name; + + public static CardSetLikeResponse from(CardSetSummaryResponse res) { + return new CardSetLikeResponse(res.cardSetId(), res.name()); + } +} diff --git a/src/main/java/project/flipnote/like/model/LikeResponse.java b/src/main/java/project/flipnote/like/model/LikeResponse.java new file mode 100644 index 00000000..af287dc5 --- /dev/null +++ b/src/main/java/project/flipnote/like/model/LikeResponse.java @@ -0,0 +1,17 @@ +package project.flipnote.like.model; + +import java.time.LocalDateTime; + +import com.fasterxml.jackson.annotation.JsonFormat; + +import lombok.AllArgsConstructor; +import lombok.Data; + +@AllArgsConstructor +@Data +public class LikeResponse { + private T target; + + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + private LocalDateTime likedAt; +} diff --git a/src/main/java/project/flipnote/like/model/LikeSearchRequest.java b/src/main/java/project/flipnote/like/model/LikeSearchRequest.java new file mode 100644 index 00000000..1c87f0c6 --- /dev/null +++ b/src/main/java/project/flipnote/like/model/LikeSearchRequest.java @@ -0,0 +1,18 @@ +package project.flipnote.like.model; + +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; + +import lombok.Getter; +import lombok.Setter; +import project.flipnote.common.model.request.PagingRequest; + +@Getter +@Setter +public class LikeSearchRequest extends PagingRequest { + + @Override + public PageRequest getPageRequest() { + return PageRequest.of(getPage() - 1, getSize() + 1, Sort.by(Sort.Direction.DESC, "id")); + } +} diff --git a/src/main/java/project/flipnote/like/model/LikeTargetResponse.java b/src/main/java/project/flipnote/like/model/LikeTargetResponse.java new file mode 100644 index 00000000..2f6b70ff --- /dev/null +++ b/src/main/java/project/flipnote/like/model/LikeTargetResponse.java @@ -0,0 +1,5 @@ +package project.flipnote.like.model; + +public abstract class LikeTargetResponse { + public abstract Long getId(); +} diff --git a/src/main/java/project/flipnote/like/model/LikeTypeRequest.java b/src/main/java/project/flipnote/like/model/LikeTypeRequest.java index 361ab895..3ae960dc 100644 --- a/src/main/java/project/flipnote/like/model/LikeTypeRequest.java +++ b/src/main/java/project/flipnote/like/model/LikeTypeRequest.java @@ -3,11 +3,11 @@ import project.flipnote.common.entity.LikeType; public enum LikeTypeRequest { - card_sets; + card_set; public LikeType toDomain() { switch (this) { - case card_sets: return LikeType.CARD_SET; + case card_set: return LikeType.CARD_SET; default: throw new IllegalArgumentException("Invalid LikeTypeRequest"); } } diff --git a/src/main/java/project/flipnote/like/repository/LikeRepository.java b/src/main/java/project/flipnote/like/repository/LikeRepository.java index 67ab4f03..4ab5fd32 100644 --- a/src/main/java/project/flipnote/like/repository/LikeRepository.java +++ b/src/main/java/project/flipnote/like/repository/LikeRepository.java @@ -2,6 +2,8 @@ import java.util.Optional; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import project.flipnote.like.entity.Like; @@ -11,4 +13,6 @@ public interface LikeRepository extends JpaRepository { boolean existsByTypeAndTargetIdAndUserId(LikeType likeType, Long targetId, Long userId); Optional findByTypeAndTargetIdAndUserId(LikeType likeType, Long targetId, Long userId); + + Page findByTypeAndUserId(LikeType likeType, Long userId, Pageable pageable); } diff --git a/src/main/java/project/flipnote/like/service/LikeService.java b/src/main/java/project/flipnote/like/service/LikeService.java index 2e0b840e..6c7409b0 100644 --- a/src/main/java/project/flipnote/like/service/LikeService.java +++ b/src/main/java/project/flipnote/like/service/LikeService.java @@ -1,18 +1,31 @@ package project.flipnote.like.service; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + import org.springframework.context.ApplicationEventPublisher; +import org.springframework.data.domain.Page; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import jakarta.annotation.PostConstruct; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import project.flipnote.common.entity.LikeType; import project.flipnote.common.exception.BizException; import project.flipnote.common.model.event.LikeEvent; import project.flipnote.common.model.event.UnlikeEvent; +import project.flipnote.common.model.response.PagingResponse; import project.flipnote.like.entity.Like; import project.flipnote.like.exception.LikeErrorCode; +import project.flipnote.like.model.LikeResponse; +import project.flipnote.like.model.LikeSearchRequest; +import project.flipnote.like.model.LikeTargetResponse; import project.flipnote.like.repository.LikeRepository; +import project.flipnote.like.service.fetcher.LikeTargetFetcher; @Slf4j @RequiredArgsConstructor @@ -23,6 +36,15 @@ public class LikeService { private final LikeRepository likeRepository; private final ApplicationEventPublisher eventPublisher; private final LikePolicyService likePolicyService; + private final List> fetchers; + + private Map> fetcherMap; + + @PostConstruct + public void init() { + this.fetcherMap = this.fetchers.stream() + .collect(Collectors.toMap(LikeTargetFetcher::getLikeType, Function.identity())); + } /** * 좋아요 추가 @@ -64,4 +86,42 @@ public void removeLike(Long userId, LikeType likeType, Long targetId) { eventPublisher.publishEvent(new UnlikeEvent(likeType, targetId, userId)); } + + /** + * 좋아요 누른 목록을 페이징하여 조회합니다. + * + * @param userId 좋아요 누른 목록을 조회하는 회원의 ID + * @param likeType 조회할 좋아요 대상 타입 + * @param req 페이징 및 검색 조건이 포함된 요청 정보 + * @param 좋아요 대상의 상세 정보를 담은 DTO 타입 (LikeTargetResponse 하위 타입) + * @return 페이징된 좋아요 누른 목록 + * @author 윤정환 + */ + public PagingResponse> getLikes( + Long userId, + LikeType likeType, + LikeSearchRequest req + ) { + Page likePage = likeRepository.findByTypeAndUserId(likeType, userId, req.getPageRequest()); + Map likedAtMap = likePage.stream() + .collect(Collectors.toMap(Like::getTargetId, Like::getCreatedAt)); + List targetIds = likePage.stream() + .map(Like::getTargetId) + .toList(); + + // TODO: 제네릭이 아닌 타입 별로 엔드포인트를 따로 만드는게 좋으려나 고민중, 현재 방법을 유지하면서 더 나은 구조 알고싶음... + LikeTargetFetcher fetcher = (LikeTargetFetcher)fetcherMap.get(likeType); + if (fetcher == null) { + throw new BizException(LikeErrorCode.INVALID_LIKE_TYPE); + } + + List targets = fetcher.fetchByIds(targetIds); + Map targetMap = targets.stream() + .collect(Collectors.toMap(LikeTargetResponse::getId, Function.identity())); + + Page> content = likePage + .map(like -> new LikeResponse<>(targetMap.get(like.getTargetId()), likedAtMap.get(like.getTargetId()))); + + return PagingResponse.from(content); + } } diff --git a/src/main/java/project/flipnote/like/service/fetcher/CardSetFetcher.java b/src/main/java/project/flipnote/like/service/fetcher/CardSetFetcher.java new file mode 100644 index 00000000..8eb917cd --- /dev/null +++ b/src/main/java/project/flipnote/like/service/fetcher/CardSetFetcher.java @@ -0,0 +1,29 @@ +package project.flipnote.like.service.fetcher; + +import java.util.List; + +import org.springframework.stereotype.Component; + +import lombok.RequiredArgsConstructor; +import project.flipnote.cardset.service.CardSetService; +import project.flipnote.common.entity.LikeType; +import project.flipnote.like.model.CardSetLikeResponse; + +@RequiredArgsConstructor +@Component +public class CardSetFetcher implements LikeTargetFetcher { + + private final CardSetService cardSetService; + + @Override + public LikeType getLikeType() { + return LikeType.CARD_SET; + } + + @Override + public List fetchByIds(List ids) { + return cardSetService.getCardSetsByIds(ids).stream() + .map(CardSetLikeResponse::from) + .toList(); + } +} diff --git a/src/main/java/project/flipnote/like/service/fetcher/LikeTargetFetcher.java b/src/main/java/project/flipnote/like/service/fetcher/LikeTargetFetcher.java new file mode 100644 index 00000000..a825ce6c --- /dev/null +++ b/src/main/java/project/flipnote/like/service/fetcher/LikeTargetFetcher.java @@ -0,0 +1,11 @@ +package project.flipnote.like.service.fetcher; + +import java.util.List; + +import project.flipnote.common.entity.LikeType; +import project.flipnote.like.model.LikeTargetResponse; + +public interface LikeTargetFetcher { + LikeType getLikeType(); + List fetchByIds(List ids); +} \ No newline at end of file From d14bb881df4a48c5c773d76a6179d0816e77e979 Mon Sep 17 00:00:00 2001 From: dungbik Date: Thu, 28 Aug 2025 22:41:05 +0900 Subject: [PATCH 4/8] =?UTF-8?q?Feat:=20=EC=A2=8B=EC=95=84=EC=9A=94=20?= =?UTF-8?q?=EC=9D=B4=EB=A0=A5=20=EC=97=94=ED=8B=B0=ED=8B=B0=20=EC=9D=B8?= =?UTF-8?q?=EB=8D=B1=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/project/flipnote/like/entity/Like.java | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/main/java/project/flipnote/like/entity/Like.java b/src/main/java/project/flipnote/like/entity/Like.java index 76481bb1..ff86f910 100644 --- a/src/main/java/project/flipnote/like/entity/Like.java +++ b/src/main/java/project/flipnote/like/entity/Like.java @@ -7,6 +7,7 @@ import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; +import jakarta.persistence.Index; import jakarta.persistence.Table; import lombok.AccessLevel; import lombok.Builder; @@ -17,7 +18,12 @@ @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) -@Table(name = "likes") +@Table( + name = "likes", + indexes = { + @Index(name = "idx_type_target_user", columnList = "type, target_id, user_id") + } +) @Entity public class Like extends BaseEntity { From dbc0d4de36021f3f60e7447d9b40225ef47a760b Mon Sep 17 00:00:00 2001 From: dungbik Date: Thu, 28 Aug 2025 22:43:23 +0900 Subject: [PATCH 5/8] =?UTF-8?q?Chore:=20LikeTargetFetcher=20=EB=A7=88?= =?UTF-8?q?=EC=A7=80=EB=A7=89=20=EB=9D=BC=EC=9D=B8=20=EA=B3=B5=EB=B0=B1=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../flipnote/like/service/fetcher/LikeTargetFetcher.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/project/flipnote/like/service/fetcher/LikeTargetFetcher.java b/src/main/java/project/flipnote/like/service/fetcher/LikeTargetFetcher.java index a825ce6c..042abeb4 100644 --- a/src/main/java/project/flipnote/like/service/fetcher/LikeTargetFetcher.java +++ b/src/main/java/project/flipnote/like/service/fetcher/LikeTargetFetcher.java @@ -8,4 +8,4 @@ public interface LikeTargetFetcher { LikeType getLikeType(); List fetchByIds(List ids); -} \ No newline at end of file +} From 23f69285f760f5e331ecc41800f7bd23fb033ac7 Mon Sep 17 00:00:00 2001 From: dungbik Date: Thu, 28 Aug 2025 22:45:29 +0900 Subject: [PATCH 6/8] =?UTF-8?q?Style:=20=EC=BD=94=EB=93=9C=20=EC=8A=A4?= =?UTF-8?q?=ED=83=80=EC=9D=BC=20=EC=95=88=EB=A7=9E=EB=8A=94=20=EB=B6=80?= =?UTF-8?q?=EB=B6=84=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/project/flipnote/cardset/service/CardSetService.java | 4 ++-- .../java/project/flipnote/like/repository/LikeRepository.java | 2 +- .../flipnote/like/service/fetcher/LikeTargetFetcher.java | 1 + 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/main/java/project/flipnote/cardset/service/CardSetService.java b/src/main/java/project/flipnote/cardset/service/CardSetService.java index c1a5d80a..583dcf46 100644 --- a/src/main/java/project/flipnote/cardset/service/CardSetService.java +++ b/src/main/java/project/flipnote/cardset/service/CardSetService.java @@ -121,11 +121,11 @@ public CreateCardSetResponse createCardSet(Long groupId, AuthPrinciple authPrinc public PagingResponse getCardSets(CardSetSearchRequest req) { // TODO: Projection 및 카운트 쿼리 튜닝 필요, 좋아요 수 및 즐겨찾기 수 등 다양한 정렬 조건 추가 필요 - Page CardSetPage = cardSetRepository.findByNameContainingAndCategory( + Page cardSetPage = cardSetRepository.findByNameContainingAndCategory( req.getKeyword(), Category.from(req.getCategory()), req.getPageRequest() ); - Page res = CardSetPage.map(CardSetSummaryResponse::from); + Page res = cardSetPage.map(CardSetSummaryResponse::from); return PagingResponse.from(res); } diff --git a/src/main/java/project/flipnote/like/repository/LikeRepository.java b/src/main/java/project/flipnote/like/repository/LikeRepository.java index 4ab5fd32..63dddc6f 100644 --- a/src/main/java/project/flipnote/like/repository/LikeRepository.java +++ b/src/main/java/project/flipnote/like/repository/LikeRepository.java @@ -6,8 +6,8 @@ import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; -import project.flipnote.like.entity.Like; import project.flipnote.common.entity.LikeType; +import project.flipnote.like.entity.Like; public interface LikeRepository extends JpaRepository { boolean existsByTypeAndTargetIdAndUserId(LikeType likeType, Long targetId, Long userId); diff --git a/src/main/java/project/flipnote/like/service/fetcher/LikeTargetFetcher.java b/src/main/java/project/flipnote/like/service/fetcher/LikeTargetFetcher.java index 042abeb4..edcaeced 100644 --- a/src/main/java/project/flipnote/like/service/fetcher/LikeTargetFetcher.java +++ b/src/main/java/project/flipnote/like/service/fetcher/LikeTargetFetcher.java @@ -7,5 +7,6 @@ public interface LikeTargetFetcher { LikeType getLikeType(); + List fetchByIds(List ids); } From f2a0f664876a669f90caec94a6c8b8551b08c982 Mon Sep 17 00:00:00 2001 From: dungbik Date: Thu, 28 Aug 2025 22:58:47 +0900 Subject: [PATCH 7/8] =?UTF-8?q?Test:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=8B=A4=ED=8C=A8=20=EC=9B=90=EC=9D=B8=20=ED=95=B4=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../flipnote/common/config/S3Config.java | 4 +- .../flipnote/FlipnoteApplicationTests.java | 4 ++ .../cardset/service/CardSetServiceTest.java | 53 ++++++++++--------- .../flipnote/common/config/S3TestConfig.java | 22 ++++++++ 4 files changed, 56 insertions(+), 27 deletions(-) create mode 100644 src/test/java/project/flipnote/common/config/S3TestConfig.java diff --git a/src/main/java/project/flipnote/common/config/S3Config.java b/src/main/java/project/flipnote/common/config/S3Config.java index 12f09e7f..d11adc83 100644 --- a/src/main/java/project/flipnote/common/config/S3Config.java +++ b/src/main/java/project/flipnote/common/config/S3Config.java @@ -3,6 +3,7 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; @@ -24,6 +25,7 @@ public class S3Config { /* 리전과 자격 증명한 객체 생성 */ + @Profile("!test") @Bean public S3Client s3Client() { return S3Client.builder() @@ -36,6 +38,7 @@ public S3Client s3Client() { .build(); } + @Profile("!test") @Bean public S3Presigner s3Presigner() { return S3Presigner.builder() @@ -47,5 +50,4 @@ public S3Presigner s3Presigner() { ) .build(); } - } diff --git a/src/test/java/project/flipnote/FlipnoteApplicationTests.java b/src/test/java/project/flipnote/FlipnoteApplicationTests.java index aeb2d7f3..2fa1bc77 100644 --- a/src/test/java/project/flipnote/FlipnoteApplicationTests.java +++ b/src/test/java/project/flipnote/FlipnoteApplicationTests.java @@ -4,9 +4,13 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; import org.springframework.test.context.ActiveProfiles; +import project.flipnote.common.config.S3TestConfig; + @ActiveProfiles("test") +@Import(S3TestConfig.class) @SpringBootTest class FlipnoteApplicationTests { diff --git a/src/test/java/project/flipnote/cardset/service/CardSetServiceTest.java b/src/test/java/project/flipnote/cardset/service/CardSetServiceTest.java index b6c8ffdc..1d3f6e49 100644 --- a/src/test/java/project/flipnote/cardset/service/CardSetServiceTest.java +++ b/src/test/java/project/flipnote/cardset/service/CardSetServiceTest.java @@ -8,7 +8,6 @@ import java.util.List; import java.util.Optional; -import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -22,16 +21,15 @@ import project.flipnote.cardset.model.CreateCardSetRequest; import project.flipnote.cardset.model.CreateCardSetResponse; import project.flipnote.cardset.repository.CardSetManagerRepository; +import project.flipnote.cardset.repository.CardSetMetadataRepository; import project.flipnote.cardset.repository.CardSetRepository; import project.flipnote.common.exception.BizException; import project.flipnote.common.security.dto.AuthPrinciple; import project.flipnote.fixture.UserFixture; import project.flipnote.group.entity.Category; import project.flipnote.group.entity.Group; -import project.flipnote.group.exception.GroupErrorCode; import project.flipnote.group.repository.GroupMemberRepository; import project.flipnote.group.repository.GroupRepository; -import project.flipnote.groupjoin.exception.GroupJoinErrorCode; import project.flipnote.user.entity.UserProfile; import project.flipnote.user.entity.UserStatus; import project.flipnote.user.repository.UserProfileRepository; @@ -57,6 +55,9 @@ class CardSetServiceTest { @Mock CardSetManagerRepository cardSetManagerRepository; + @Mock + CardSetMetadataRepository cardSetMetadataRepository; + UserProfile user; AuthPrinciple authPrinciple; Group group; @@ -66,14 +67,14 @@ void before() { user = UserFixture.createActiveUser(); authPrinciple = new AuthPrinciple(1L, user.getId(), user.getEmail(), AccountRole.USER, 1L); group = Group.builder() - .name("aa") - .imageUrl("wwww.~~~") - .publicVisible(true) - .applicationRequired(true) - .description("dsfad") - .maxMember(100) - .category(Category.IT) - .build(); + .name("aa") + .imageUrl("wwww.~~~") + .publicVisible(true) + .applicationRequired(true) + .description("dsfad") + .maxMember(100) + .category(Category.IT) + .build(); given(userProfileRepository.findByIdAndStatus(user.getId(), UserStatus.ACTIVE)).willReturn(Optional.of(user)); given(groupRepository.findById(any())).willReturn(Optional.of(group)); @@ -81,23 +82,23 @@ void before() { @Test public void 카드_생성_성공() throws Exception { - //given + //given CreateCardSetRequest req = new CreateCardSetRequest("1233", true, Category.IT, new ArrayList<>( - List.of("123", "456")),"www.aab.com"); + List.of("123", "456")), "www.aab.com"); - when(cardSetRepository.save(any())).thenAnswer(invocation -> { - CardSet cardSet = invocation.getArgument(0); + when(cardSetRepository.save(any())).thenAnswer(invocation -> { + CardSet cardSet = invocation.getArgument(0); - // reflection으로 id 필드 설정 - Field idField = CardSet.class.getDeclaredField("id"); - idField.setAccessible(true); - idField.set(cardSet, 1L); + // reflection으로 id 필드 설정 + Field idField = CardSet.class.getDeclaredField("id"); + idField.setAccessible(true); + idField.set(cardSet, 1L); - return cardSet; - }); + return cardSet; + }); given(groupMemberRepository.existsByGroup_idAndUser_id((group.getId()), user.getId())).willReturn(true); - //when + //when CreateCardSetResponse res = cardSetService.createCardSet(1L, authPrinciple, req); //then assertEquals(1L, res.cardSetId()); @@ -107,15 +108,15 @@ void before() { public void 카드_생성_실패_내가_가입한_그룹이_아닌경우() throws Exception { //given CreateCardSetRequest req = new CreateCardSetRequest("1233", true, Category.IT, new ArrayList<>( - List.of("123", "456")),"www.aab.com"); + List.of("123", "456")), "www.aab.com"); given(groupMemberRepository.existsByGroup_idAndUser_id((group.getId()), user.getId())).willReturn(false); //when BizException exception = assertThrows( - BizException.class, - () -> cardSetService.createCardSet(1L, authPrinciple, req) - ); + BizException.class, + () -> cardSetService.createCardSet(1L, authPrinciple, req) + ); //then assertEquals(CardSetErrorCode.GROUP_MEMBER_NOT_FOUND, exception.getErrorCode()); diff --git a/src/test/java/project/flipnote/common/config/S3TestConfig.java b/src/test/java/project/flipnote/common/config/S3TestConfig.java new file mode 100644 index 00000000..1bb13506 --- /dev/null +++ b/src/test/java/project/flipnote/common/config/S3TestConfig.java @@ -0,0 +1,22 @@ +package project.flipnote.common.config; + +import org.mockito.Mockito; +import org.springframework.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; + +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.presigner.S3Presigner; + +@TestConfiguration +public class S3TestConfig { + + @Bean + public S3Client s3Client() { + return Mockito.mock(S3Client.class); + } + + @Bean + public S3Presigner s3Presigner() { + return Mockito.mock(S3Presigner.class); + } +} \ No newline at end of file From 3a4f5717c92dc3413f8ad77903bb025ecafb9869 Mon Sep 17 00:00:00 2001 From: dungbik Date: Sun, 31 Aug 2025 16:01:17 +0900 Subject: [PATCH 8/8] =?UTF-8?q?Refactor:=20=EC=BD=94=EB=93=9C=20=EB=A6=AC?= =?UTF-8?q?=EB=B7=B0=20=EB=82=B4=EC=9A=A9=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 1 + .../flipnote/cardset/entity/CardSetMetadata.java | 3 +-- .../cardset/listener/CardSetLikeEventHandler.java | 2 ++ .../cardset/listener/CardSetUnlikeEventHandler.java | 2 ++ .../repository/CardSetMetadataRepository.java | 11 +++++++---- .../flipnote/like/controller/LikeController.java | 2 +- .../java/project/flipnote/like/entity/Like.java | 7 +++++++ .../project/flipnote/like/service/LikeService.java | 13 ++++++++----- .../like/service/fetcher/CardSetFetcher.java | 8 ++++++-- .../like/service/fetcher/LikeTargetFetcher.java | 3 ++- .../flipnote/common/config/S3TestConfig.java | 2 +- src/test/resources/application-test.yml | 9 +++++++++ 12 files changed, 47 insertions(+), 16 deletions(-) diff --git a/build.gradle b/build.gradle index 39f95277..8887f185 100644 --- a/build.gradle +++ b/build.gradle @@ -47,6 +47,7 @@ dependencies { annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' + testImplementation 'org.mockito:mockito-inline:5.2.0' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' testRuntimeOnly 'com.h2database:h2' implementation platform('software.amazon.awssdk:bom:2.20.56') diff --git a/src/main/java/project/flipnote/cardset/entity/CardSetMetadata.java b/src/main/java/project/flipnote/cardset/entity/CardSetMetadata.java index c5cbb4f8..25114f67 100644 --- a/src/main/java/project/flipnote/cardset/entity/CardSetMetadata.java +++ b/src/main/java/project/flipnote/cardset/entity/CardSetMetadata.java @@ -19,11 +19,10 @@ public class CardSetMetadata { private Long id; @Column(nullable = false) - private Integer likeCount; + private int likeCount; @Builder public CardSetMetadata(Long id) { this.id = id; - this.likeCount = 0; } } diff --git a/src/main/java/project/flipnote/cardset/listener/CardSetLikeEventHandler.java b/src/main/java/project/flipnote/cardset/listener/CardSetLikeEventHandler.java index e7e46f70..521ac8a7 100644 --- a/src/main/java/project/flipnote/cardset/listener/CardSetLikeEventHandler.java +++ b/src/main/java/project/flipnote/cardset/listener/CardSetLikeEventHandler.java @@ -1,5 +1,6 @@ package project.flipnote.cardset.listener; +import org.springframework.dao.DataAccessException; import org.springframework.retry.annotation.Backoff; import org.springframework.retry.annotation.Recover; import org.springframework.retry.annotation.Retryable; @@ -24,6 +25,7 @@ public class CardSetLikeEventHandler { @Async @Retryable( maxAttempts = 3, + retryFor = DataAccessException.class, backoff = @Backoff(delay = 2000, multiplier = 2) ) @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) diff --git a/src/main/java/project/flipnote/cardset/listener/CardSetUnlikeEventHandler.java b/src/main/java/project/flipnote/cardset/listener/CardSetUnlikeEventHandler.java index 888d2b44..6e018106 100644 --- a/src/main/java/project/flipnote/cardset/listener/CardSetUnlikeEventHandler.java +++ b/src/main/java/project/flipnote/cardset/listener/CardSetUnlikeEventHandler.java @@ -1,5 +1,6 @@ package project.flipnote.cardset.listener; +import org.springframework.dao.DataAccessException; import org.springframework.retry.annotation.Backoff; import org.springframework.retry.annotation.Recover; import org.springframework.retry.annotation.Retryable; @@ -24,6 +25,7 @@ public class CardSetUnlikeEventHandler { @Async @Retryable( maxAttempts = 3, + retryFor = DataAccessException.class, backoff = @Backoff(delay = 2000, multiplier = 2) ) @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) diff --git a/src/main/java/project/flipnote/cardset/repository/CardSetMetadataRepository.java b/src/main/java/project/flipnote/cardset/repository/CardSetMetadataRepository.java index f8e3291b..f39ec1d6 100644 --- a/src/main/java/project/flipnote/cardset/repository/CardSetMetadataRepository.java +++ b/src/main/java/project/flipnote/cardset/repository/CardSetMetadataRepository.java @@ -11,10 +11,13 @@ public interface CardSetMetadataRepository extends JpaRepository 0 THEN m.likeCount - 1 ELSE 0 END + WHERE m.id = :cardSetId + """) + int decrementLikeCount(@Param("cardSetId") Long cardSetId); } diff --git a/src/main/java/project/flipnote/like/controller/LikeController.java b/src/main/java/project/flipnote/like/controller/LikeController.java index 733668c0..b9fb37bf 100644 --- a/src/main/java/project/flipnote/like/controller/LikeController.java +++ b/src/main/java/project/flipnote/like/controller/LikeController.java @@ -52,7 +52,7 @@ public ResponseEntity removeLike( @GetMapping("/{type}") public ResponseEntity>> getLikes( - @PathVariable("type") LikeTypeRequest likeType, + @PathVariable(name = "type") LikeTypeRequest likeType, @Valid @ModelAttribute LikeSearchRequest req, @AuthenticationPrincipal AuthPrinciple authPrinciple ) { diff --git a/src/main/java/project/flipnote/like/entity/Like.java b/src/main/java/project/flipnote/like/entity/Like.java index ff86f910..d13f3275 100644 --- a/src/main/java/project/flipnote/like/entity/Like.java +++ b/src/main/java/project/flipnote/like/entity/Like.java @@ -9,6 +9,7 @@ import jakarta.persistence.Id; import jakarta.persistence.Index; import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; import lombok.AccessLevel; import lombok.Builder; import lombok.Getter; @@ -22,6 +23,12 @@ name = "likes", indexes = { @Index(name = "idx_type_target_user", columnList = "type, target_id, user_id") + }, + uniqueConstraints = { + @UniqueConstraint( + name = "uk_type_target_user", + columnNames = {"type", "target_id", "user_id"} + ) } ) @Entity diff --git a/src/main/java/project/flipnote/like/service/LikeService.java b/src/main/java/project/flipnote/like/service/LikeService.java index 6c7409b0..c824c6b6 100644 --- a/src/main/java/project/flipnote/like/service/LikeService.java +++ b/src/main/java/project/flipnote/like/service/LikeService.java @@ -7,6 +7,7 @@ import java.util.stream.Collectors; import org.springframework.context.ApplicationEventPublisher; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.data.domain.Page; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -64,7 +65,12 @@ public void addLike(Long userId, LikeType likeType, Long targetId) { .targetId(targetId) .userId(userId) .build(); - likeRepository.save(like); + + try { + likeRepository.save(like); + } catch (DataIntegrityViolationException e) { + throw new BizException(LikeErrorCode.ALREADY_LIKED); + } eventPublisher.publishEvent(new LikeEvent(likeType, targetId, userId)); } @@ -115,10 +121,7 @@ public PagingResponse> getLikes( throw new BizException(LikeErrorCode.INVALID_LIKE_TYPE); } - List targets = fetcher.fetchByIds(targetIds); - Map targetMap = targets.stream() - .collect(Collectors.toMap(LikeTargetResponse::getId, Function.identity())); - + Map targetMap = fetcher.fetchByIds(targetIds); Page> content = likePage .map(like -> new LikeResponse<>(targetMap.get(like.getTargetId()), likedAtMap.get(like.getTargetId()))); diff --git a/src/main/java/project/flipnote/like/service/fetcher/CardSetFetcher.java b/src/main/java/project/flipnote/like/service/fetcher/CardSetFetcher.java index 8eb917cd..3fbe4f23 100644 --- a/src/main/java/project/flipnote/like/service/fetcher/CardSetFetcher.java +++ b/src/main/java/project/flipnote/like/service/fetcher/CardSetFetcher.java @@ -1,6 +1,9 @@ package project.flipnote.like.service.fetcher; import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; import org.springframework.stereotype.Component; @@ -8,6 +11,7 @@ import project.flipnote.cardset.service.CardSetService; import project.flipnote.common.entity.LikeType; import project.flipnote.like.model.CardSetLikeResponse; +import project.flipnote.like.model.LikeTargetResponse; @RequiredArgsConstructor @Component @@ -21,9 +25,9 @@ public LikeType getLikeType() { } @Override - public List fetchByIds(List ids) { + public Map fetchByIds(List ids) { return cardSetService.getCardSetsByIds(ids).stream() .map(CardSetLikeResponse::from) - .toList(); + .collect(Collectors.toMap(LikeTargetResponse::getId, Function.identity())); } } diff --git a/src/main/java/project/flipnote/like/service/fetcher/LikeTargetFetcher.java b/src/main/java/project/flipnote/like/service/fetcher/LikeTargetFetcher.java index edcaeced..0dbfd171 100644 --- a/src/main/java/project/flipnote/like/service/fetcher/LikeTargetFetcher.java +++ b/src/main/java/project/flipnote/like/service/fetcher/LikeTargetFetcher.java @@ -1,6 +1,7 @@ package project.flipnote.like.service.fetcher; import java.util.List; +import java.util.Map; import project.flipnote.common.entity.LikeType; import project.flipnote.like.model.LikeTargetResponse; @@ -8,5 +9,5 @@ public interface LikeTargetFetcher { LikeType getLikeType(); - List fetchByIds(List ids); + Map fetchByIds(List ids); } diff --git a/src/test/java/project/flipnote/common/config/S3TestConfig.java b/src/test/java/project/flipnote/common/config/S3TestConfig.java index 1bb13506..91ce9f87 100644 --- a/src/test/java/project/flipnote/common/config/S3TestConfig.java +++ b/src/test/java/project/flipnote/common/config/S3TestConfig.java @@ -19,4 +19,4 @@ public S3Client s3Client() { public S3Presigner s3Presigner() { return Mockito.mock(S3Presigner.class); } -} \ No newline at end of file +} diff --git a/src/test/resources/application-test.yml b/src/test/resources/application-test.yml index 1618bf1e..00c2d015 100644 --- a/src/test/resources/application-test.yml +++ b/src/test/resources/application-test.yml @@ -29,3 +29,12 @@ app: encryption: key: 49015f426db2b8477d5251fd3de971ae + +cloud: + aws: + region: dummy_bucket_region + credentials: + access-key: dummy_access_key + secret-key: dummy_secret_key + s3: + bucket: dummy_bucket