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 index 554bfee0..f69ae9c6 100644 --- 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 @@ -1,13 +1,21 @@ 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.*; +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") @@ -18,6 +26,20 @@ 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); + } + @PostMapping @Operation(operationId = "Like_toggleLike", summary = "좋아요 생성", description = "기록에 좋아요를 추가합니다") public BaseResponse toggleLike(@RequestParam("historyId") Long historyId) { 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 b96c90da..76ec5b67 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 @@ -9,8 +9,11 @@ public interface MemberLikeRepository extends JpaRepository { +<<<<<<< HEAD +======= long countByHistoryId(Long historyId); +>>>>>>> 707263d5afbbd10030259d946f7748710380a71c @Query( """ SELECT ml @@ -21,6 +24,8 @@ public interface MemberLikeRepository extends JpaRepository { """) List findLikedHistoriesByMemberId( Long memberId, Long lastLikeId, Pageable pageable); +<<<<<<< HEAD +======= @Query( """ @@ -33,4 +38,5 @@ List findLikedHistoriesByMemberId( List findLikeMembersByHistoryId(Long historyId, Long lastLikeId, Pageable pageable); Optional findByMemberIdAndHistoryId(Long memberId, Long historyId); +>>>>>>> 707263d5afbbd10030259d946f7748710380a71c } 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 index 7163e909..2e2ebea4 100644 --- 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 @@ -1,5 +1,11 @@ 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); + void toggleLike(Long historyId); } 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 index f218c66a..d74868ad 100644 --- 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 @@ -1,9 +1,14 @@ package org.clokey.domain.like.service; +import java.util.List; +import java.util.Map; import java.util.Optional; +import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import org.clokey.domain.history.exception.HistoryErrorCode; +import org.clokey.domain.history.repository.HistoryImageRepository; import org.clokey.domain.history.repository.HistoryRepository; +import org.clokey.domain.like.dto.response.LikedHistoriesResponse; import org.clokey.domain.like.repository.MemberLikeRepository; import org.clokey.domain.member.repository.BlockRepository; import org.clokey.exception.BaseCustomException; @@ -11,6 +16,9 @@ import org.clokey.history.entity.History; 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; @@ -20,11 +28,61 @@ public class LikeServiceImpl implements LikeService { private final MemberUtil memberUtil; - private final MemberLikeRepository memberLikeRepository; + private final HistoryImageRepository historyImageRepository; private final HistoryRepository historyRepository; private final BlockRepository blockRepository; + @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])); + } + @Override @Transactional public void toggleLike(Long historyId) { 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 index 7025d99e..82ebe94e 100644 --- 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 @@ -1,12 +1,18 @@ 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.mockito.BDDMockito.willDoNothing; 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; @@ -48,4 +54,80 @@ class 좋아요_요청_시 { .andExpect(jsonPath("$.message").value("요청 성공 및 반환값 없음")); } } + + @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 index 3d527e78..4cad615f 100644 --- 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 @@ -10,18 +10,21 @@ 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.BlockRepository; import org.clokey.domain.member.repository.FollowRepository; 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.Block; 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; @@ -44,7 +47,6 @@ public class LikeServiceTest extends IntegrationTest { @Nested class 기록에_좋아요를_할_때 { - @BeforeEach void setUp() { Member member1 = @@ -117,4 +119,81 @@ void setUp() { .isFalse(); } } + + @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(); + } + } }