diff --git a/src/main/java/com/back/domain/mybar/controller/MyBarController.java b/src/main/java/com/back/domain/mybar/controller/MyBarController.java index d8047ffd..808dbb77 100644 --- a/src/main/java/com/back/domain/mybar/controller/MyBarController.java +++ b/src/main/java/com/back/domain/mybar/controller/MyBarController.java @@ -1,5 +1,6 @@ package com.back.domain.mybar.controller; +import com.back.domain.mybar.dto.MyBarIdResponseDto; import com.back.domain.mybar.dto.MyBarListResponseDto; import com.back.domain.mybar.service.MyBarService; import com.back.global.rsData.RsData; @@ -7,14 +8,20 @@ import io.swagger.v3.oas.annotations.Operation; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; +import java.time.LocalDateTime; +import java.util.List; import lombok.RequiredArgsConstructor; import org.springframework.format.annotation.DateTimeFormat; import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.validation.annotation.Validated; -import org.springframework.web.bind.annotation.*; - -import java.time.LocalDateTime; +import org.springframework.web.bind.annotation.DeleteMapping; +import org.springframework.web.bind.annotation.GetMapping; +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.RequestParam; +import org.springframework.web.bind.annotation.RestController; @RestController @RequestMapping("/me/bar") @@ -23,41 +30,32 @@ @PreAuthorize("isAuthenticated()") public class MyBarController { - /** - * 내 바(킵) API 컨트롤러. - * 내가 킵한 칵테일 목록 조회, 킵 추가/복원, 킵 해제를 제공합니다. - */ - private final MyBarService myBarService; - /** - * 내 바 목록 조회(무한스크롤) - * @param userId 인증된 사용자 ID - * @param lastKeptAt 이전 페이지 마지막 keptAt (옵션) - * @param lastId 이전 페이지 마지막 id (옵션) - * @param limit 페이지 크기(1~100) - * @return 킵 아이템 목록과 다음 페이지 커서 - */ @GetMapping - @Operation(summary = "내 바 목록", description = "내가 킵한 칵테일 목록 조회. 무한 스크롤 커서 지원") - public RsData getMyBarList( + @Operation(summary = "내 바 경량 목록", description = "찜 ID 목록을 반환합니다.") + public RsData> getMyBarIds( + @AuthenticationPrincipal SecurityUser principal + ) { + Long userId = principal.getId(); + List body = myBarService.getMyBarIds(userId); + return RsData.successOf(body); + } + + @GetMapping("/detail") + @Operation(summary = "내 바 상세 목록", description = "커서 기반으로 상세 찜 정보를 반환합니다.") + public RsData getMyBarDetail( @AuthenticationPrincipal SecurityUser principal, @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime lastKeptAt, @RequestParam(required = false) Long lastId, - @RequestParam(defaultValue = "20") @Min(1) @Max(100) int limit + @RequestParam(defaultValue = "50") @Min(1) @Max(100) int limit ) { Long userId = principal.getId(); - MyBarListResponseDto body = myBarService.getMyBar(userId, lastKeptAt, lastId, limit); + MyBarListResponseDto body = myBarService.getMyBarDetail(userId, lastKeptAt, lastId, limit); return RsData.successOf(body); } - /** - * 킵 추가(생성/복원/재킵) - * @param userId 인증된 사용자 ID - * @param cocktailId 칵테일 ID - * @return 201 kept - */ @PostMapping("/{cocktailId}/keep") @Operation(summary = "킵 추가/복원", description = "해당 칵테일을 내 바에 킵합니다. 이미 삭제 상태면 복원") public RsData keep( @@ -66,15 +64,9 @@ public RsData keep( ) { Long userId = principal.getId(); myBarService.keep(userId, cocktailId); - return RsData.of(201, "kept"); // Aspect가 HTTP 201로 설정 + return RsData.of(201, "kept"); } - /** - * 킵 해제(소프트 삭제) — 멱등 - * @param userId 인증된 사용자 ID - * @param cocktailId 칵테일 ID - * @return 200 deleted - */ @DeleteMapping("/{cocktailId}/keep") @Operation(summary = "킵 해제", description = "내 바에서 해당 칵테일을 삭제(소프트 삭제, 멱등)") public RsData unkeep( diff --git a/src/main/java/com/back/domain/mybar/dto/MyBarIdResponseDto.java b/src/main/java/com/back/domain/mybar/dto/MyBarIdResponseDto.java new file mode 100644 index 00000000..90bc0634 --- /dev/null +++ b/src/main/java/com/back/domain/mybar/dto/MyBarIdResponseDto.java @@ -0,0 +1,22 @@ +package com.back.domain.mybar.dto; + +import com.back.domain.mybar.entity.MyBar; +import java.time.LocalDateTime; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class MyBarIdResponseDto { + private Long id; + private Long cocktailId; + private LocalDateTime keptAt; + + public static MyBarIdResponseDto from(MyBar myBar) { + return MyBarIdResponseDto.builder() + .id(myBar.getId()) + .cocktailId(myBar.getCocktail().getId()) + .keptAt(myBar.getKeptAt()) + .build(); + } +} diff --git a/src/main/java/com/back/domain/mybar/repository/MyBarRepository.java b/src/main/java/com/back/domain/mybar/repository/MyBarRepository.java index 84c45b96..f4c858b3 100644 --- a/src/main/java/com/back/domain/mybar/repository/MyBarRepository.java +++ b/src/main/java/com/back/domain/mybar/repository/MyBarRepository.java @@ -15,9 +15,11 @@ @Repository public interface MyBarRepository extends JpaRepository { - /** 나만의 bar(킵) 목록: ACTIVE만, id desc */ + /** 나만의 bar(킵) 목록: ACTIVE만, keptAt desc + id desc */ Page findByUser_IdAndStatusOrderByKeptAtDescIdDesc(Long userId, KeepStatus status, Pageable pageable); + List findByUser_IdAndStatusOrderByKeptAtDescIdDesc(Long userId, KeepStatus status); + @Query(""" select m from MyBar m where m.user.id = :userId diff --git a/src/main/java/com/back/domain/mybar/service/MyBarService.java b/src/main/java/com/back/domain/mybar/service/MyBarService.java index c93d22b9..6f7ced4a 100644 --- a/src/main/java/com/back/domain/mybar/service/MyBarService.java +++ b/src/main/java/com/back/domain/mybar/service/MyBarService.java @@ -1,6 +1,7 @@ package com.back.domain.mybar.service; import com.back.domain.cocktail.repository.CocktailRepository; +import com.back.domain.mybar.dto.MyBarIdResponseDto; import com.back.domain.mybar.dto.MyBarItemResponseDto; import com.back.domain.mybar.dto.MyBarListResponseDto; import com.back.domain.mybar.entity.MyBar; @@ -8,6 +9,10 @@ import com.back.domain.mybar.repository.MyBarRepository; import com.back.domain.user.repository.UserRepository; import com.back.domain.user.service.AbvScoreService; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; +import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; @@ -15,11 +20,6 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.time.LocalDateTime; -import java.util.ArrayList; -import java.util.List; -import java.util.Optional; - @Service @RequiredArgsConstructor public class MyBarService { @@ -33,7 +33,15 @@ public class MyBarService { // - 커서: lastKeptAt + lastId 조합으로 안정적인 정렬/페이지네이션 // - 첫 페이지: 가장 최근 keptAt 기준으로 최신순 @Transactional(readOnly = true) - public MyBarListResponseDto getMyBar(Long userId, LocalDateTime lastKeptAt, Long lastId, int limit) { + public List getMyBarIds(Long userId) { + List rows = myBarRepository.findByUser_IdAndStatusOrderByKeptAtDescIdDesc(userId, KeepStatus.ACTIVE); + return rows.stream() + .map(MyBarIdResponseDto::from) + .collect(Collectors.toList()); + } + + @Transactional(readOnly = true) + public MyBarListResponseDto getMyBarDetail(Long userId, LocalDateTime lastKeptAt, Long lastId, int limit) { int safeLimit = Math.max(1, Math.min(limit, 100)); int fetchSize = safeLimit + 1; // 다음 페이지 여부 판단용으로 1개 더 조회 @@ -50,10 +58,13 @@ public MyBarListResponseDto getMyBar(Long userId, LocalDateTime lastKeptAt, Long // +1 로우가 있으면 다음 페이지가 존재 boolean hasNext = rows.size() > safeLimit; - if (hasNext) rows = rows.subList(0, safeLimit); + if (hasNext) { + rows = rows.subList(0, safeLimit); + } - List items = new ArrayList<>(); - for (MyBar myBar : rows) items.add(MyBarItemResponseDto.from(myBar)); + List items = rows.stream() + .map(MyBarItemResponseDto::from) + .collect(Collectors.toList()); LocalDateTime nextKeptAt = null; Long nextId = null; diff --git a/src/test/java/com/back/domain/mybar/controller/MyBarControllerTest.java b/src/test/java/com/back/domain/mybar/controller/MyBarControllerTest.java index b006e39a..8c1a8854 100644 --- a/src/test/java/com/back/domain/mybar/controller/MyBarControllerTest.java +++ b/src/test/java/com/back/domain/mybar/controller/MyBarControllerTest.java @@ -1,6 +1,7 @@ package com.back.domain.mybar.controller; import com.back.domain.cocktail.enums.AlcoholStrength; +import com.back.domain.mybar.dto.MyBarIdResponseDto; import com.back.domain.mybar.dto.MyBarItemResponseDto; import com.back.domain.mybar.dto.MyBarListResponseDto; import com.back.domain.mybar.service.MyBarService; @@ -96,36 +97,24 @@ private RequestPostProcessor withPrincipal(SecurityUser principal) { } @Test - @DisplayName("Get my bar list - first page") - void getMyBarList_withoutCursor() throws Exception { - SecurityUser principal = createPrincipal(1L); - LocalDateTime keptAt = LocalDateTime.of(2025, 1, 1, 10, 0); - LocalDateTime createdAt = keptAt.minusDays(1); - - MyBarItemResponseDto item = MyBarItemResponseDto.builder() - .id(3L) - .cocktailId(10L) - .cocktailName("Margarita") - .cocktailNameKo("留덇?由ы?") - .alcoholStrength(AlcoholStrength.LIGHT) - .imageUrl("https://example.com/margarita.jpg") - .createdAt(createdAt) - .keptAt(keptAt) - .build(); - - MyBarListResponseDto responseDto = new MyBarListResponseDto( - List.of(item), - true, - keptAt.minusMinutes(5), - 2L + @DisplayName("경량 내 바 목록을 조회한다") + void getMyBarIds() throws Exception { + SecurityUser principal = createPrincipal(5L); + + List response = List.of( + MyBarIdResponseDto.builder() + .id(123L) + .cocktailId(1L) + .keptAt(LocalDateTime.of(2025, 10, 10, 12, 0)) + .build(), + MyBarIdResponseDto.builder() + .id(124L) + .cocktailId(5L) + .keptAt(LocalDateTime.of(2025, 10, 9, 15, 30)) + .build() ); - given(myBarService.getMyBar( - eq(principal.getId()), - isNull(LocalDateTime.class), - isNull(Long.class), - eq(20) - )).willReturn(responseDto); + given(myBarService.getMyBarIds(principal.getId())).willReturn(response); mockMvc.perform(get("/me/bar") .with(withPrincipal(principal)) @@ -133,85 +122,69 @@ void getMyBarList_withoutCursor() throws Exception { .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(200)) .andExpect(jsonPath("$.message").value("success")) - .andExpect(jsonPath("$.data.items[0].id").value(3L)) - .andExpect(jsonPath("$.data.items[0].cocktailId").value(10L)) - .andExpect(jsonPath("$.data.items[0].cocktailName").value("Margarita")) - .andExpect(jsonPath("$.data.items[0].cocktailNameKo").value("留덇?由ы?")) - .andExpect(jsonPath("$.data.items[0].alcoholStrength").value("LIGHT")) - .andExpect(jsonPath("$.data.items[0].imageUrl").value("https://example.com/margarita.jpg")) - .andExpect(jsonPath("$.data.items[0].createdAt").value(ISO_WITH_SECONDS.format(createdAt))) - .andExpect(jsonPath("$.data.items[0].keptAt").value(ISO_WITH_SECONDS.format(keptAt))) - .andExpect(jsonPath("$.data.hasNext").value(true)) - .andExpect(jsonPath("$.data.nextKeptAt").value(ISO_WITH_SECONDS.format(keptAt.minusMinutes(5)))) - .andExpect(jsonPath("$.data.nextId").value(2L)); + .andExpect(jsonPath("$.data[0].id").value(123L)) + .andExpect(jsonPath("$.data[0].cocktailId").value(1L)) + .andExpect(jsonPath("$.data[0].keptAt").value("2025-10-10T12:00:00")) + .andExpect(jsonPath("$.data[1].cocktailId").value(5L)); - verify(myBarService).getMyBar( - eq(principal.getId()), - isNull(LocalDateTime.class), - isNull(Long.class), - eq(20) - ); + verify(myBarService).getMyBarIds(principal.getId()); } @Test - @DisplayName("Get my bar list - next page") - void getMyBarList_withCursor() throws Exception { - SecurityUser principal = createPrincipal(7L); - LocalDateTime cursorKeptAt = LocalDateTime.of(2025, 2, 10, 9, 30, 15); - LocalDateTime itemKeptAt = cursorKeptAt.minusMinutes(1); - LocalDateTime itemCreatedAt = itemKeptAt.minusDays(2); + @DisplayName("상세 내 바 목록을 조회한다") + void getMyBarDetail() throws Exception { + SecurityUser principal = createPrincipal(9L); + LocalDateTime keptAt = LocalDateTime.of(2025, 10, 1, 10, 0); + LocalDateTime createdAt = keptAt.minusDays(1); MyBarItemResponseDto item = MyBarItemResponseDto.builder() - .id(20L) - .cocktailId(33L) - .cocktailName("Negroni") - .cocktailNameKo("?ㅺ렇濡쒕땲") - .alcoholStrength(AlcoholStrength.STRONG) - .imageUrl("https://example.com/negroni.jpg") - .createdAt(itemCreatedAt) - .keptAt(itemKeptAt) + .id(123L) + .cocktailId(1L) + .cocktailName("Mojito") + .cocktailNameKo("모히또") + .alcoholStrength(AlcoholStrength.MEDIUM) + .imageUrl("https://example.com/mojito.jpg") + .createdAt(createdAt) + .keptAt(keptAt) .build(); - MyBarListResponseDto responseDto = new MyBarListResponseDto( + MyBarListResponseDto response = new MyBarListResponseDto( List.of(item), false, null, null ); - given(myBarService.getMyBar( + given(myBarService.getMyBarDetail( eq(principal.getId()), - eq(cursorKeptAt), - eq(99L), - eq(5) - )).willReturn(responseDto); + isNull(LocalDateTime.class), + isNull(Long.class), + eq(50) + )).willReturn(response); - mockMvc.perform(get("/me/bar") + mockMvc.perform(get("/me/bar/detail") .with(withPrincipal(principal)) - .param("lastKeptAt", cursorKeptAt.toString()) - .param("lastId", "99") - .param("limit", "5") + .param("limit", "50") .accept(MediaType.APPLICATION_JSON)) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value(200)) .andExpect(jsonPath("$.message").value("success")) - .andExpect(jsonPath("$.data.items[0].id").value(20L)) - .andExpect(jsonPath("$.data.items[0].cocktailName").value("Negroni")) - .andExpect(jsonPath("$.data.items[0].cocktailNameKo").value("?ㅺ렇濡쒕땲")) - .andExpect(jsonPath("$.data.items[0].alcoholStrength").value("STRONG")) + .andExpect(jsonPath("$.data.items[0].cocktailName").value("Mojito")) + .andExpect(jsonPath("$.data.items[0].cocktailNameKo").value("모히또")) + .andExpect(jsonPath("$.data.items[0].keptAt").value(ISO_WITH_SECONDS.format(keptAt))) .andExpect(jsonPath("$.data.hasNext").value(false)) .andExpect(jsonPath("$.data.nextKeptAt").doesNotExist()); - verify(myBarService).getMyBar( + verify(myBarService).getMyBarDetail( eq(principal.getId()), - eq(cursorKeptAt), - eq(99L), - eq(5) + isNull(LocalDateTime.class), + isNull(Long.class), + eq(50) ); } @Test - @DisplayName("Keep cocktail") + @DisplayName("킵 추가") void keepCocktail() throws Exception { SecurityUser principal = createPrincipal(11L); Long cocktailId = 42L; @@ -230,7 +203,7 @@ void keepCocktail() throws Exception { } @Test - @DisplayName("Unkeep cocktail") + @DisplayName("킵 해제") void unkeepCocktail() throws Exception { SecurityUser principal = createPrincipal(12L); Long cocktailId = 77L; @@ -247,4 +220,4 @@ void unkeepCocktail() throws Exception { verify(myBarService).unkeep(principal.getId(), cocktailId); } -} \ No newline at end of file +}