diff --git a/clokey-api/src/main/java/org/clokey/domain/history/repository/HistoryImageRepository.java b/clokey-api/src/main/java/org/clokey/domain/history/repository/HistoryImageRepository.java index 75f9e4dc..6f2a2524 100644 --- a/clokey-api/src/main/java/org/clokey/domain/history/repository/HistoryImageRepository.java +++ b/clokey-api/src/main/java/org/clokey/domain/history/repository/HistoryImageRepository.java @@ -3,7 +3,22 @@ import java.util.List; import org.clokey.history.entity.HistoryImage; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface HistoryImageRepository extends JpaRepository { + @Query( + """ + SELECT hi.history.id, hi.imageUrl + FROM HistoryImage hi + WHERE hi.history.id IN :historyIds + AND hi.id = ( + SELECT MIN(h2.id) + FROM HistoryImage h2 + WHERE h2.history.id = hi.history.id + ) + """) + List getFirstImageUrlsWithHistoryId(@Param("historyIds") List historyIds); + List findByHistoryId(Long historyId); } diff --git a/clokey-api/src/main/java/org/clokey/domain/like/controller/LikeController.java b/clokey-api/src/main/java/org/clokey/domain/like/controller/LikeController.java new file mode 100644 index 00000000..0c18eed5 --- /dev/null +++ b/clokey-api/src/main/java/org/clokey/domain/like/controller/LikeController.java @@ -0,0 +1,41 @@ +package org.clokey.domain.like.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.clokey.code.GlobalBaseSuccessCode; +import org.clokey.domain.like.dto.response.LikedHistoriesResponse; +import org.clokey.domain.like.service.LikeService; +import org.clokey.global.annotation.PageSize; +import org.clokey.response.BaseResponse; +import org.clokey.response.SliceResponse; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/likes") +@RequiredArgsConstructor +@Tag(name = "9. 좋아요 API", description = "좋아요 관련 API입니다.") +@Validated +public class LikeController { + + private final LikeService likeService; + + @GetMapping("/histories") + @Operation(summary = "좋아요한 기록 조회", description = "사용자가 좋아요한 기록을 조회합니다.") + public BaseResponse> + getLikedHistories( + @Parameter(description = "이전 페이지의 좋아요 ID (첫 요청 시 생략)") + @RequestParam(required = false) + Long lastLikeId, + @Parameter(description = "페이지당 조회할 개수") @RequestParam @PageSize Integer size) { + SliceResponse response = + likeService.getLikedHistories(lastLikeId, size); + + return BaseResponse.onSuccess(GlobalBaseSuccessCode.OK, response); + } +} diff --git a/clokey-api/src/main/java/org/clokey/domain/like/dto/response/LikedHistoriesResponse.java b/clokey-api/src/main/java/org/clokey/domain/like/dto/response/LikedHistoriesResponse.java new file mode 100644 index 00000000..b567d8b0 --- /dev/null +++ b/clokey-api/src/main/java/org/clokey/domain/like/dto/response/LikedHistoriesResponse.java @@ -0,0 +1,19 @@ +package org.clokey.domain.like.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; + +@Schema(description = "좋아요 히스토리 조회 결과 (Slice 기반)") +public record LikedHistoriesResponse( + @Schema(description = "히스토리 미리보기 목록") List historyPreviews, + @Schema(description = "마지막 페이지 여부", example = "false") boolean isLast) { + + @Schema(description = "히스토리 미리보기 DTO") + public record LikedHistoryPreview( + @Schema(description = "히스토리 ID", example = "30") Long id, + @Schema( + description = "히스토리 대표 이미지 URL", + example = + "https://clokeybucket.s3.ap-northeast-2.amazonaws.com/example.jpg") + String imageUrl) {} +} diff --git a/clokey-api/src/main/java/org/clokey/domain/like/repository/MemberLikeRepository.java b/clokey-api/src/main/java/org/clokey/domain/like/repository/MemberLikeRepository.java index a645167e..0dc9520f 100644 --- a/clokey-api/src/main/java/org/clokey/domain/like/repository/MemberLikeRepository.java +++ b/clokey-api/src/main/java/org/clokey/domain/like/repository/MemberLikeRepository.java @@ -1,6 +1,21 @@ package org.clokey.domain.like.repository; +import java.util.List; import org.clokey.like.entity.MemberLike; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; -public interface MemberLikeRepository extends JpaRepository {} +public interface MemberLikeRepository extends JpaRepository { + + @Query( + """ + SELECT ml + FROM MemberLike ml + WHERE ml.member.id = :memberId + AND (:lastLikeId IS NULL OR ml.id < :lastLikeId) + ORDER BY ml.id DESC + """) + List findLikedHistoriesByMemberId( + Long memberId, Long lastLikeId, Pageable pageable); +} diff --git a/clokey-api/src/main/java/org/clokey/domain/like/service/LikeService.java b/clokey-api/src/main/java/org/clokey/domain/like/service/LikeService.java new file mode 100644 index 00000000..53f1ecf4 --- /dev/null +++ b/clokey-api/src/main/java/org/clokey/domain/like/service/LikeService.java @@ -0,0 +1,9 @@ +package org.clokey.domain.like.service; + +import org.clokey.domain.like.dto.response.LikedHistoriesResponse; +import org.clokey.response.SliceResponse; + +public interface LikeService { + SliceResponse getLikedHistories( + Long lastLikedId, Integer size); +} diff --git a/clokey-api/src/main/java/org/clokey/domain/like/service/LikeServiceImpl.java b/clokey-api/src/main/java/org/clokey/domain/like/service/LikeServiceImpl.java new file mode 100644 index 00000000..e60465b7 --- /dev/null +++ b/clokey-api/src/main/java/org/clokey/domain/like/service/LikeServiceImpl.java @@ -0,0 +1,77 @@ +package org.clokey.domain.like.service; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; +import lombok.RequiredArgsConstructor; +import org.clokey.domain.history.repository.HistoryImageRepository; +import org.clokey.domain.like.dto.response.LikedHistoriesResponse; +import org.clokey.domain.like.repository.MemberLikeRepository; +import org.clokey.global.util.MemberUtil; +import org.clokey.like.entity.MemberLike; +import org.clokey.member.entity.Member; +import org.clokey.response.SliceResponse; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class LikeServiceImpl implements LikeService { + + private final MemberUtil memberUtil; + private final MemberLikeRepository memberLikeRepository; + private final HistoryImageRepository historyImageRepository; + + @Override + public SliceResponse getLikedHistories( + Long lastLikeId, Integer size) { + + Member currentMember = memberUtil.getCurrentMember(); + + // limit + 1 조회 + Pageable pageable = PageRequest.of(0, size + 1); + + List likes = + memberLikeRepository.findLikedHistoriesByMemberId( + currentMember.getId(), lastLikeId, pageable); + + boolean isLast = likes.size() <= size; + + if (!isLast) { + likes = likes.subList(0, size); + } + + if (likes.isEmpty()) { + return new SliceResponse<>(List.of(), true); + } + + List historyIds = likes.stream().map(like -> like.getHistory().getId()).toList(); + + Map imageMap = findFirstImagesByHistoryIds(historyIds); + + List previews = + likes.stream() + .map( + like -> + new LikedHistoriesResponse.LikedHistoryPreview( + like.getHistory().getId(), + imageMap.get(like.getHistory().getId()))) + .toList(); + + return new SliceResponse<>(previews, isLast); + } + + private Map findFirstImagesByHistoryIds(List historyIds) { + if (historyIds.isEmpty()) return Map.of(); + + List rows = historyImageRepository.getFirstImageUrlsWithHistoryId(historyIds); + + return rows.stream() + .collect( + Collectors.toMap( + row -> ((Number) row[0]).longValue(), row -> (String) row[1])); + } +} diff --git a/clokey-api/src/test/java/org/clokey/domain/like/controller/LikeControllerTest.java b/clokey-api/src/test/java/org/clokey/domain/like/controller/LikeControllerTest.java new file mode 100644 index 00000000..47253c99 --- /dev/null +++ b/clokey-api/src/test/java/org/clokey/domain/like/controller/LikeControllerTest.java @@ -0,0 +1,108 @@ +package org.clokey.domain.like.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.BDDMockito.given; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.List; +import org.clokey.domain.like.dto.response.LikedHistoriesResponse; +import org.clokey.domain.like.service.LikeService; +import org.clokey.response.SliceResponse; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; + +@WebMvcTest(LikeController.class) +@AutoConfigureMockMvc(addFilters = false) +public class LikeControllerTest { + @Autowired private MockMvc mockMvc; + @Autowired private ObjectMapper objectMapper; + + @MockitoBean private LikeService likeService; + + @Nested + class 좋아요한_기록_조회_시 { + @Test + void 유효한_요청이면_좋아요한_기록을_반환한다() throws Exception { + // given + List previews = + List.of( + new LikedHistoriesResponse.LikedHistoryPreview( + 1L, "https://img.com/img1.jpg"), + new LikedHistoriesResponse.LikedHistoryPreview( + 2L, "https://img.com/img2.jpg")); + + SliceResponse sliceResponse = + new SliceResponse<>(previews, true); + + given(likeService.getLikedHistories(any(), anyInt())).willReturn(sliceResponse); + + ResultActions perform = + mockMvc.perform( + get("/likes/histories") + .param("size", "10") + .contentType(MediaType.APPLICATION_JSON)); + + // then + perform.andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("COMMON200")) + .andExpect(jsonPath("$.message").value("성공입니다.")) + .andExpect(jsonPath("$.result.content[0].id").value(1L)) + .andExpect( + jsonPath("$.result.content[0].imageUrl") + .value("https://img.com/img1.jpg")) + .andExpect(jsonPath("$.result.content[1].id").value(2L)) + .andExpect( + jsonPath("$.result.content[1].imageUrl") + .value("https://img.com/img2.jpg")) + .andExpect(jsonPath("$.result.isLast").value(true)); + } + + @Test + void 마지막_페이지가_아닌_경우_isLast를_false로_응답한다() throws Exception { + // given + List previews = + List.of( + new LikedHistoriesResponse.LikedHistoryPreview( + 1L, "https://img.com/img1.jpg"), + new LikedHistoriesResponse.LikedHistoryPreview( + 2L, "https://img.com/img2.jpg")); + + SliceResponse sliceResponse = + new SliceResponse<>(previews, false); + + given(likeService.getLikedHistories(any(), anyInt())).willReturn(sliceResponse); + + // when + ResultActions perform = + mockMvc.perform( + get("/likes/histories") + .param("size", "10") + .contentType(MediaType.APPLICATION_JSON)); + + // then + perform.andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("COMMON200")) + .andExpect(jsonPath("$.message").value("성공입니다.")) + .andExpect(jsonPath("$.result.content[0].id").value(1L)) + .andExpect( + jsonPath("$.result.content[0].imageUrl") + .value("https://img.com/img1.jpg")) + .andExpect(jsonPath("$.result.content[1].id").value(2L)) + .andExpect( + jsonPath("$.result.content[1].imageUrl") + .value("https://img.com/img2.jpg")) + .andExpect(jsonPath("$.result.isLast").value(false)); + } + } +} diff --git a/clokey-api/src/test/java/org/clokey/domain/like/service/LikeServiceTest.java b/clokey-api/src/test/java/org/clokey/domain/like/service/LikeServiceTest.java new file mode 100644 index 00000000..d47693c3 --- /dev/null +++ b/clokey-api/src/test/java/org/clokey/domain/like/service/LikeServiceTest.java @@ -0,0 +1,119 @@ +package org.clokey.domain.like.service; + +import static org.assertj.core.api.Assertions.*; +import static org.mockito.BDDMockito.given; + +import java.time.LocalDate; +import java.util.List; +import org.clokey.IntegrationTest; +import org.clokey.TransactionUtil; +import org.clokey.domain.history.repository.HistoryImageRepository; +import org.clokey.domain.history.repository.HistoryRepository; +import org.clokey.domain.history.repository.SituationRepository; +import org.clokey.domain.like.dto.response.LikedHistoriesResponse; +import org.clokey.domain.like.repository.MemberLikeRepository; +import org.clokey.domain.member.repository.MemberRepository; +import org.clokey.global.util.MemberUtil; +import org.clokey.history.entity.History; +import org.clokey.history.entity.HistoryImage; +import org.clokey.history.entity.Situation; +import org.clokey.like.entity.MemberLike; +import org.clokey.member.entity.Member; +import org.clokey.member.entity.OauthInfo; +import org.clokey.member.enums.OauthProvider; +import org.clokey.response.SliceResponse; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.test.context.bean.override.mockito.MockitoBean; + +public class LikeServiceTest extends IntegrationTest { + + @Autowired private LikeService likeService; + @Autowired private MemberLikeRepository memberLikeRepository; + @Autowired private MemberRepository memberRepository; + @Autowired private SituationRepository situationRepository; + @Autowired private HistoryRepository historyRepository; + @Autowired private HistoryImageRepository historyImageRepository; + + @Autowired private TransactionUtil transactionUtil; + @MockitoBean private MemberUtil memberUtil; + + @Nested + class 좋아요한_기록을_조회할_때 { + + @BeforeEach + void setUp() { + Member member1 = + Member.createMember( + "testEmail1", + "testClokeyId1", + "testNickName1", + OauthInfo.createOauthInfo("testOauthId1", OauthProvider.KAKAO)); + + memberRepository.saveAll(List.of(member1)); + given(memberUtil.getCurrentMember()).willReturn(member1); + + Situation situation1 = Situation.createSituation("testSituation1"); + situationRepository.save(situation1); + + History history1 = + History.createHistory( + LocalDate.of(2024, 12, 25), + "content1", + memberRepository.findById(1L).orElseThrow(), + situationRepository.findById(1L).orElseThrow()); + History history2 = + History.createHistory( + LocalDate.of(2024, 12, 26), + "content2", + memberRepository.findById(1L).orElseThrow(), + situationRepository.findById(1L).orElseThrow()); + historyRepository.saveAll(List.of(history1, history2)); + + HistoryImage historyImage1 = + HistoryImage.createHistoryImage("http://image1.url", history1); + HistoryImage historyImage2 = + HistoryImage.createHistoryImage("http://image2.url", history2); + historyImageRepository.saveAll(List.of(historyImage1, historyImage2)); + } + + @Test + void 좋아요한_기록이_있으면_기록을_반환한다() { + // given + memberLikeRepository.saveAll( + List.of( + MemberLike.createMemberLike( + memberUtil.getCurrentMember(), + historyRepository.findById(1L).orElseThrow()), + MemberLike.createMemberLike( + memberUtil.getCurrentMember(), + historyRepository.findById(2L).orElseThrow()))); + + // when + SliceResponse response = + likeService.getLikedHistories(null, 10); + + // then + assertThat(response.content()).hasSize(2); + assertThat(response.isLast()).isTrue(); + + assertThat(response.content()) + .extracting("id", "imageUrl") + .containsExactly( + tuple(2L, "http://image2.url"), tuple(1L, "http://image1.url")); + } + + @Test + void 좋아요한_기록이_없으면_빈_리스트를_반환한다() { + // when + SliceResponse response = + likeService.getLikedHistories(null, 10); + + // then + assertThat(response.content()).isEmpty(); + assertThat(response.isLast()).isTrue(); + } + } +}