Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package com.devpick.domain.content.controller;

import com.devpick.domain.content.dto.ScrapListResponse;
import com.devpick.domain.content.service.ScrapService;
import com.devpick.global.common.response.ApiResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.responses.ApiResponses;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.PageRequest;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
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;

import java.util.UUID;

@Tag(name = "Scrap", description = "스크랩 목록 조회")
@RestController
@RequestMapping("/users/me/scraps")
@RequiredArgsConstructor
public class ScrapController {

private final ScrapService scrapService;

@Operation(summary = "스크랩 목록 조회", description = "인증된 사용자의 스크랩 목록을 페이징/검색/정렬하여 반환합니다.")
@ApiResponses({

Check warning on line 29 in src/main/java/com/devpick/domain/content/controller/ScrapController.java

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Remove the 'ApiResponses' wrapper from this annotation group

See more on https://sonarcloud.io/project/issues?id=Devpick-Org_devpick-backend&issues=AZ2_JG-Os73dMpxK0y6d&open=AZ2_JG-Os73dMpxK0y6d&pullRequest=133
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "조회 성공"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "401", description = "인증 필요")
})
@GetMapping
public ApiResponse<ScrapListResponse> getScraps(
@AuthenticationPrincipal UUID userId,
@Parameter(description = "검색어 (title, sourceName, summary 기준)") @RequestParam(required = false) String q,
@Parameter(description = "정렬 순서", example = "newest") @RequestParam(defaultValue = "newest") String sort,
@Parameter(description = "페이지 번호 (0부터 시작)", example = "0") @RequestParam(defaultValue = "0") int page,
@Parameter(description = "페이지 크기", example = "10") @RequestParam(defaultValue = "10") int size) {
return ApiResponse.ok(scrapService.getScraps(userId, q, sort, PageRequest.of(page, size)));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package com.devpick.domain.content.dto;

import com.devpick.domain.content.entity.Scrap;

import java.time.Instant;
import java.time.ZoneOffset;
import java.util.Map;
import java.util.UUID;

public record ScrapItemResponse(
UUID id,
UUID contentId,
String title,
String sourceName,
String thumbnail,
String summary,
Instant createdAt
) {
public static ScrapItemResponse of(Scrap scrap, Map<UUID, String> summaryMap) {
UUID contentId = scrap.getContent().getId();
String raw = summaryMap.getOrDefault(contentId, scrap.getContent().getPreview());
String summary = (raw != null && !raw.isBlank()) ? raw : null;
return new ScrapItemResponse(
scrap.getId(),
contentId,
scrap.getContent().getTitle(),
scrap.getContent().getSource().getName(),
scrap.getContent().getThumbnailUrl(),
summary,
scrap.getCreatedAt().toInstant(ZoneOffset.UTC)
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package com.devpick.domain.content.dto;

import java.util.List;

public record ScrapListResponse(
List<ScrapItemResponse> content,
int page,
int size,
long totalElements,
int totalPages
) {}
Original file line number Diff line number Diff line change
@@ -1,7 +1,11 @@
package com.devpick.domain.content.repository;

import com.devpick.domain.content.entity.Scrap;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import java.util.Optional;
import java.util.UUID;
Expand All @@ -11,4 +15,16 @@ public interface ScrapRepository extends JpaRepository<Scrap, UUID> {
boolean existsByUser_IdAndContent_Id(UUID userId, UUID contentId);

Optional<Scrap> findByUser_IdAndContent_Id(UUID userId, UUID contentId);

@Query(value = "SELECT s FROM Scrap s JOIN FETCH s.content c JOIN FETCH c.source src " +
"WHERE s.user.id = :userId AND c.isAvailable = true " +
"AND (:q IS NULL OR LOWER(c.title) LIKE LOWER(CONCAT('%', :q, '%')) " +
"OR LOWER(src.name) LIKE LOWER(CONCAT('%', :q, '%')) " +
"OR LOWER(c.preview) LIKE LOWER(CONCAT('%', :q, '%')))",
countQuery = "SELECT COUNT(s) FROM Scrap s JOIN s.content c JOIN c.source src " +
"WHERE s.user.id = :userId AND c.isAvailable = true " +
"AND (:q IS NULL OR LOWER(c.title) LIKE LOWER(CONCAT('%', :q, '%')) " +
"OR LOWER(src.name) LIKE LOWER(CONCAT('%', :q, '%')) " +
"OR LOWER(c.preview) LIKE LOWER(CONCAT('%', :q, '%')))")
Page<Scrap> findScrapsWithSearch(@Param("userId") UUID userId, @Param("q") String q, Pageable pageable);
}
73 changes: 73 additions & 0 deletions src/main/java/com/devpick/domain/content/service/ScrapService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,73 @@
package com.devpick.domain.content.service;

import com.devpick.domain.content.dto.ScrapItemResponse;
import com.devpick.domain.content.dto.ScrapListResponse;
import com.devpick.domain.content.entity.Scrap;
import com.devpick.domain.content.repository.AiSummaryRepository;
import com.devpick.domain.content.repository.ScrapRepository;
import com.devpick.domain.user.entity.User;
import com.devpick.domain.user.repository.UserRepository;
import com.devpick.global.common.exception.DevpickException;
import com.devpick.global.common.exception.ErrorCode;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;
import java.util.Map;
import java.util.UUID;

@Slf4j
@Service
@RequiredArgsConstructor
public class ScrapService {

private final ScrapRepository scrapRepository;
private final UserRepository userRepository;
private final AiSummaryRepository aiSummaryRepository;

@Transactional(readOnly = true)
public ScrapListResponse getScraps(UUID userId, String q, String sort, Pageable pageable) {
User user = userRepository.findByIdAndIsActiveTrue(userId)
.orElseThrow(() -> new DevpickException(ErrorCode.USER_NOT_FOUND));

String normalizedQ = (q != null && !q.isBlank()) ? q.trim() : null;
Sort jpaSort = "oldest".equalsIgnoreCase(sort)
? Sort.by(Sort.Direction.ASC, "createdAt")
: Sort.by(Sort.Direction.DESC, "createdAt");
Pageable sortedPageable = PageRequest.of(pageable.getPageNumber(), pageable.getPageSize(), jpaSort);

Page<Scrap> scrapPage = scrapRepository.findScrapsWithSearch(userId, normalizedQ, sortedPageable);

if (scrapPage.isEmpty()) {
return new ScrapListResponse(List.of(), scrapPage.getNumber(), scrapPage.getSize(), 0L, 0);
}

List<String> contentIds = scrapPage.getContent().stream()
.map(s -> s.getContent().getId().toString())
.distinct()
.toList();

Map<UUID, String> summaryMapTemp;
try {
String aiLevel = AiSummaryService.toAiServerLevel(user.getLevel().name());
summaryMapTemp = aiSummaryRepository.batchFindCoreSummaries(contentIds, aiLevel);
} catch (Exception e) {
log.warn("AI 요약 배치 조회 실패 — 원문 미리보기로 fallback: {}", e.getMessage());
summaryMapTemp = Map.of();
}
final Map<UUID, String> summaryMap = summaryMapTemp;

List<ScrapItemResponse> items = scrapPage.getContent().stream()
.map(s -> ScrapItemResponse.of(s, summaryMap))
.toList();

return new ScrapListResponse(items, scrapPage.getNumber(), scrapPage.getSize(),
scrapPage.getTotalElements(), scrapPage.getTotalPages());
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package com.devpick.domain.content.controller;

import com.devpick.domain.content.dto.ScrapItemResponse;
import com.devpick.domain.content.dto.ScrapListResponse;
import com.devpick.domain.content.service.ScrapService;
import com.devpick.global.common.exception.GlobalExceptionHandler;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.method.annotation.AuthenticationPrincipalArgumentResolver;
import org.springframework.test.web.servlet.MockMvc;
import org.springframework.test.web.servlet.setup.MockMvcBuilders;

import java.time.Instant;
import java.util.List;
import java.util.UUID;

import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.ArgumentMatchers.isNull;
import static org.mockito.BDDMockito.given;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@ExtendWith(MockitoExtension.class)
class ScrapControllerTest {

private MockMvc mockMvc;

@Mock private ScrapService scrapService;
@InjectMocks private ScrapController scrapController;

private UUID userId;

@BeforeEach
void setUp() {
mockMvc = MockMvcBuilders
.standaloneSetup(scrapController)
.setControllerAdvice(new GlobalExceptionHandler())
.setCustomArgumentResolvers(new AuthenticationPrincipalArgumentResolver())
.build();

userId = UUID.randomUUID();
SecurityContextHolder.getContext().setAuthentication(
new UsernamePasswordAuthenticationToken(
userId, null, List.of(new SimpleGrantedAuthority("ROLE_USER")))
);
}

@AfterEach
void tearDown() {
SecurityContextHolder.clearContext();
}

@Test
@DisplayName("GET /users/me/scraps - 정상 조회 시 200 반환")
void getScraps_success_returns200() throws Exception {
ScrapItemResponse item = new ScrapItemResponse(
UUID.randomUUID(), UUID.randomUUID(),
"Spring Boot 완전 정복", "velog",
"https://thumb.jpg", "핵심 요약", Instant.now()
);
ScrapListResponse response = new ScrapListResponse(List.of(item), 0, 10, 1L, 1);
given(scrapService.getScraps(eq(userId), isNull(), any(), any())).willReturn(response);

mockMvc.perform(get("/users/me/scraps"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.success").value(true))
.andExpect(jsonPath("$.data.content[0].title").value("Spring Boot 완전 정복"))
.andExpect(jsonPath("$.data.totalElements").value(1));
}

@Test
@DisplayName("GET /users/me/scraps - 빈 결과 시 200 반환")
void getScraps_empty_returns200WithEmptyContent() throws Exception {
ScrapListResponse response = new ScrapListResponse(List.of(), 0, 10, 0L, 0);
given(scrapService.getScraps(any(), any(), any(), any())).willReturn(response);

mockMvc.perform(get("/users/me/scraps"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.data.content").isEmpty())
.andExpect(jsonPath("$.data.totalElements").value(0));
}

@Test
@DisplayName("GET /users/me/scraps - q 파라미터 전달 시 서비스에 전달됨")
void getScraps_withQuery_passesQueryToService() throws Exception {
ScrapListResponse response = new ScrapListResponse(List.of(), 0, 10, 0L, 0);
given(scrapService.getScraps(eq(userId), eq("Spring"), any(), any())).willReturn(response);

mockMvc.perform(get("/users/me/scraps").param("q", "Spring"))
.andExpect(status().isOk());
}
}
Loading
Loading