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 new file mode 100644 index 00000000..25114f67 --- /dev/null +++ b/src/main/java/project/flipnote/cardset/entity/CardSetMetadata.java @@ -0,0 +1,28 @@ +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 int likeCount; + + @Builder + public CardSetMetadata(Long id) { + this.id = id; + } +} 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..521ac8a7 --- /dev/null +++ b/src/main/java/project/flipnote/cardset/listener/CardSetLikeEventHandler.java @@ -0,0 +1,47 @@ +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; +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, + retryFor = DataAccessException.class, + backoff = @Backoff(delay = 2000, multiplier = 2) + ) + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handleLikeEvent(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/listener/CardSetUnlikeEventHandler.java b/src/main/java/project/flipnote/cardset/listener/CardSetUnlikeEventHandler.java new file mode 100644 index 00000000..6e018106 --- /dev/null +++ b/src/main/java/project/flipnote/cardset/listener/CardSetUnlikeEventHandler.java @@ -0,0 +1,47 @@ +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; +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, + retryFor = DataAccessException.class, + 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 new file mode 100644 index 00000000..f39ec1d6 --- /dev/null +++ b/src/main/java/project/flipnote/cardset/repository/CardSetMetadataRepository.java @@ -0,0 +1,23 @@ +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") + int incrementLikeCount(@Param("cardSetId") Long cardSetId); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + UPDATE CardSetMetadata m + SET m.likeCount = CASE WHEN m.likeCount > 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/cardset/service/CardSetService.java b/src/main/java/project/flipnote/cardset/service/CardSetService.java index 31732ed3..583dcf46 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; @@ -8,6 +10,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 +20,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 +46,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 +95,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) @@ -114,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); } @@ -163,4 +170,52 @@ 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); + } + + /** + * 카드셋 좋아요 수를 1 감소 + * + * @param cardSetId 좋아요 수를 감소시킬 카드셋 ID + * @author 윤정환 + */ + @Transactional + 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/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/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/common/model/event/UnlikeEvent.java b/src/main/java/project/flipnote/common/model/event/UnlikeEvent.java new file mode 100644 index 00000000..e3ba6f24 --- /dev/null +++ b/src/main/java/project/flipnote/common/model/event/UnlikeEvent.java @@ -0,0 +1,10 @@ +package project.flipnote.common.model.event; + +import project.flipnote.common.entity.LikeType; + +public record UnlikeEvent( + 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..b9fb37bf --- /dev/null +++ b/src/main/java/project/flipnote/like/controller/LikeController.java @@ -0,0 +1,64 @@ +package project.flipnote.like.controller; + +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; + +@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(); + } + + @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(); + } + + @GetMapping("/{type}") + public ResponseEntity>> getLikes( + @PathVariable(name = "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 new file mode 100644 index 00000000..f90e37b9 --- /dev/null +++ b/src/main/java/project/flipnote/like/controller/docs/LikeControllerDocs.java @@ -0,0 +1,30 @@ +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.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") +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); + + @Operation(summary = "좋아요 누른 목록 조회", security = {@SecurityRequirement(name = "access-token")}) + ResponseEntity>> getLikes( + LikeTypeRequest likeType, + LikeSearchRequest req, + 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..d13f3275 --- /dev/null +++ b/src/main/java/project/flipnote/like/entity/Like.java @@ -0,0 +1,57 @@ +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.Index; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +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", + 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 +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..e1c22611 --- /dev/null +++ b/src/main/java/project/flipnote/like/exception/LikeErrorCode.java @@ -0,0 +1,26 @@ +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", "이미 좋아요를 눌렀습니다."), + LIKE_NOT_FOUND(HttpStatus.NOT_FOUND, "LIKE_004", "좋아요가 존재하지 않습니다."); + + 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/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 new file mode 100644 index 00000000..3ae960dc --- /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_set; + + public LikeType toDomain() { + switch (this) { + 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 new file mode 100644 index 00000000..63dddc6f --- /dev/null +++ b/src/main/java/project/flipnote/like/repository/LikeRepository.java @@ -0,0 +1,18 @@ +package project.flipnote.like.repository; + +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.common.entity.LikeType; +import project.flipnote.like.entity.Like; + +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/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..c824c6b6 --- /dev/null +++ b/src/main/java/project/flipnote/like/service/LikeService.java @@ -0,0 +1,130 @@ +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.dao.DataIntegrityViolationException; +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 +@Transactional(readOnly = true) +@Service +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())); + } + + /** + * 좋아요 추가 + * + * @param userId 좋아요 누른 회원 ID + * @param likeType 좋아요 대상 타입 + * @param targetId 좋아요 대상 ID + * @author 윤정환 + */ + @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(); + + try { + likeRepository.save(like); + } catch (DataIntegrityViolationException e) { + throw new BizException(LikeErrorCode.ALREADY_LIKED); + } + + 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)); + } + + /** + * 좋아요 누른 목록을 페이징하여 조회합니다. + * + * @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); + } + + Map targetMap = fetcher.fetchByIds(targetIds); + 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..3fbe4f23 --- /dev/null +++ b/src/main/java/project/flipnote/like/service/fetcher/CardSetFetcher.java @@ -0,0 +1,33 @@ +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; + +import lombok.RequiredArgsConstructor; +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 +public class CardSetFetcher implements LikeTargetFetcher { + + private final CardSetService cardSetService; + + @Override + public LikeType getLikeType() { + return LikeType.CARD_SET; + } + + @Override + public Map fetchByIds(List ids) { + return cardSetService.getCardSetsByIds(ids).stream() + .map(CardSetLikeResponse::from) + .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 new file mode 100644 index 00000000..0dbfd171 --- /dev/null +++ b/src/main/java/project/flipnote/like/service/fetcher/LikeTargetFetcher.java @@ -0,0 +1,13 @@ +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; + +public interface LikeTargetFetcher { + LikeType getLikeType(); + + Map fetchByIds(List ids); +} 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..91ce9f87 --- /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); + } +} 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