diff --git a/build.gradle b/build.gradle index 8a4623d3..2e173227 100644 --- a/build.gradle +++ b/build.gradle @@ -28,6 +28,10 @@ ext { springAiVersion = "1.0.0" } +test { + useJUnitPlatform() +} + dependencies { // Spring Data JPA implementation 'org.springframework.boot:spring-boot-starter-data-jpa' diff --git a/src/main/java/org/tuna/zoopzoop/backend/BackendApplication.java b/src/main/java/org/tuna/zoopzoop/backend/BackendApplication.java index ba492291..d74d6a50 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/BackendApplication.java +++ b/src/main/java/org/tuna/zoopzoop/backend/BackendApplication.java @@ -5,7 +5,6 @@ @SpringBootApplication public class BackendApplication { - public static void main(String[] args) { SpringApplication.run(BackendApplication.class, args); } diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/auth/handler/OAuth2SuccessHandler.java b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/handler/OAuth2SuccessHandler.java index f54de5b6..abd7e075 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/auth/handler/OAuth2SuccessHandler.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/auth/handler/OAuth2SuccessHandler.java @@ -70,7 +70,7 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo // 확장 프로그램에서 로그인 했을 경우. if(isExtension){ - String redirectUrl = redirect_domain + "/extension/callback " + String redirectUrl = redirect_domain + "/extension/callback" + "?success=true" + "&accessToken=" + URLEncoder.encode(accessToken, "UTF-8") + "&refreshToken=" + URLEncoder.encode(refreshToken, "UTF-8"); @@ -80,7 +80,7 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo if ("http://localhost:3000".equals(redirect_domain)) { // server 환경일 때: URL 파라미터로 토큰 전달 - String redirectUrl = redirect_domain + "/auth/callback" + String redirectUrl = redirect_domain + "/api/auth/callback" + "?success=true" + "&accessToken=" + URLEncoder.encode(accessToken, "UTF-8") + "&refreshToken=" + URLEncoder.encode(refreshToken, "UTF-8"); @@ -115,7 +115,7 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo // 로그인 성공 후 리다이렉트. // 배포 시에 프론트엔드와 조율이 필요한 부분일 듯 함. - response.sendRedirect(redirect_domain + "/auth/callback"); + response.sendRedirect(redirect_domain + "/api/auth/callback"); } // 보안을 좀 더 강화하고자 한다면 CSRF 토큰 같은 걸 생각해볼 수 있겠으나, // 일단은 구현하지 않음.(개발 과정 중에 번거로워질 수 있을 듯 함.) diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/home/controller/HomeController.java b/src/main/java/org/tuna/zoopzoop/backend/domain/home/controller/HomeController.java index 0ab283d2..55933ea0 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/home/controller/HomeController.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/home/controller/HomeController.java @@ -6,7 +6,7 @@ import lombok.SneakyThrows; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; -import org.tuna.zoopzoop.backend.domain.news.service.NewsSearchService; +import org.tuna.zoopzoop.backend.domain.news.service.NewsAPIService; import java.net.InetAddress; @@ -22,7 +22,7 @@ public class HomeController { // // @Value("${kakao.redirect_uri}") // private String kakaoRedirectUri; - private final NewsSearchService newsSearchService; + private final NewsAPIService newsSearchService; @SneakyThrows @GetMapping(produces = TEXT_HTML_VALUE) diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/news/controller/ApiV1NewsController.java b/src/main/java/org/tuna/zoopzoop/backend/domain/news/controller/ApiV1NewsController.java index 4c812eaf..078ad563 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/news/controller/ApiV1NewsController.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/news/controller/ApiV1NewsController.java @@ -5,19 +5,26 @@ import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; +import org.tuna.zoopzoop.backend.domain.member.entity.Member; import org.tuna.zoopzoop.backend.domain.news.dto.req.ReqBodyForKeyword; import org.tuna.zoopzoop.backend.domain.news.dto.res.ResBodyForNaverNews; -import org.tuna.zoopzoop.backend.domain.news.service.NewsSearchService; +import org.tuna.zoopzoop.backend.domain.news.service.NewsAPIService; +import org.tuna.zoopzoop.backend.domain.news.service.NewsService; import org.tuna.zoopzoop.backend.global.rsData.RsData; +import org.tuna.zoopzoop.backend.global.security.jwt.CustomUserDetails; import reactor.core.publisher.Mono; +import java.util.List; + @RestController @RequiredArgsConstructor @RequestMapping("/api/v1/news") @Tag(name = "ApiV1NewsController", description = "뉴스 API 기반 검색 컨트롤러") public class ApiV1NewsController { - private final NewsSearchService newsSearchService; + private final NewsAPIService newsSearchService; + private final NewsService newsService; /** * 최신 뉴스 목록을 조회하는 API @@ -47,7 +54,7 @@ public Mono>> searchRecentNews( * @param dto 키워드를 받아오는 reqDto */ @PostMapping("/keywords") - @Operation(summary = "최신 뉴스 목록 조회") + @Operation(summary = "키워드 기반 뉴스 조회") public Mono>> searchNewsByKeywords( @RequestBody ReqBodyForKeyword dto ) { @@ -64,4 +71,31 @@ public Mono>> searchNewsByKeywords( response ))); } + + /** + * 개인 아카이브의 폴더 내부의 자료를 기반으로 키워드를 추천해서 검색하는 뉴스 API + * HTTP METHOD: GET + * 한번에 100개를 조회 합니다. + * @param userDetails 로그인한 사용자 + * @param folderId 대상 폴더 id + */ + @GetMapping("/recommends/personal/{folderId}") + @Operation(summary = "개인 아카이브 뉴스 추천") + public Mono>> searchNewsByRecommends( + @AuthenticationPrincipal CustomUserDetails userDetails, + @PathVariable Integer folderId + ) { + Member member = userDetails.getMember(); + List frequency = newsService.getTagFrequencyFromFiles(member.getId(), folderId); + String query = String.join(" ", frequency); + + return newsSearchService.searchNews(query, "sim") + .map(response -> ResponseEntity + .status(HttpStatus.OK) + .body(new RsData<>( + "200", + "키워드 기반 뉴스 목록을 조회했습니다.", + response + ))); + } } diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/news/service/NewsSearchService.java b/src/main/java/org/tuna/zoopzoop/backend/domain/news/service/NewsAPIService.java similarity index 98% rename from src/main/java/org/tuna/zoopzoop/backend/domain/news/service/NewsSearchService.java rename to src/main/java/org/tuna/zoopzoop/backend/domain/news/service/NewsAPIService.java index 611b8672..db03e5f9 100644 --- a/src/main/java/org/tuna/zoopzoop/backend/domain/news/service/NewsSearchService.java +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/news/service/NewsAPIService.java @@ -10,7 +10,7 @@ import java.util.List; @Service -public class NewsSearchService { +public class NewsAPIService { private final WebClient webClient; @Value("${naver.client_id}") @@ -19,7 +19,7 @@ public class NewsSearchService { @Value("${naver.client_secret}") private String client_secret; - public NewsSearchService(WebClient.Builder webClientBuilder) { + public NewsAPIService(WebClient.Builder webClientBuilder) { this.webClient = webClientBuilder.baseUrl("https://openapi.naver.com").build(); } diff --git a/src/main/java/org/tuna/zoopzoop/backend/domain/news/service/NewsService.java b/src/main/java/org/tuna/zoopzoop/backend/domain/news/service/NewsService.java new file mode 100644 index 00000000..440a3476 --- /dev/null +++ b/src/main/java/org/tuna/zoopzoop/backend/domain/news/service/NewsService.java @@ -0,0 +1,40 @@ +package org.tuna.zoopzoop.backend.domain.news.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.tuna.zoopzoop.backend.domain.archive.folder.service.FolderService; +import org.tuna.zoopzoop.backend.domain.datasource.dto.FileSummary; +import org.tuna.zoopzoop.backend.domain.datasource.dto.FolderFilesDto; + +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class NewsService { + private final FolderService folderService; + + public List getTagFrequencyFromFiles(Integer memberId, Integer folderId) { + FolderFilesDto folderFilesDto = folderService.getFilesInFolderForPersonal(memberId, folderId); + + List files = folderFilesDto.files(); + + Map tags = files.stream() + .flatMap(file -> file.tags().stream()) + .map(tag -> tag.getTagName()) + .collect(Collectors.groupingBy( + tagName -> tagName, + Collectors.counting() + )); + + List frequency = tags.entrySet().stream() + .sorted(Map.Entry.comparingByValue(Comparator.reverseOrder())) + .limit(3) + .map(Map.Entry::getKey) + .toList(); + + return frequency; + } +} diff --git a/src/test/java/org/tuna/zoopzoop/backend/domain/news/service/NewsAPIServiceTest.java b/src/test/java/org/tuna/zoopzoop/backend/domain/news/service/NewsAPIServiceTest.java new file mode 100644 index 00000000..2bbdd7d9 --- /dev/null +++ b/src/test/java/org/tuna/zoopzoop/backend/domain/news/service/NewsAPIServiceTest.java @@ -0,0 +1,37 @@ +package org.tuna.zoopzoop.backend.domain.news.service; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.context.ActiveProfiles; +import org.tuna.zoopzoop.backend.domain.news.dto.res.ResBodyForNaverNews; +import reactor.core.publisher.Mono; + +import static org.junit.jupiter.api.Assertions.assertNotNull; + +@SpringBootTest +@ActiveProfiles("test") +class NewsAPIServiceTest { + @Autowired + private NewsAPIService newsSearchService; + + @Test + @DisplayName("뉴스 서비스 테스트 - 정상적인 JSON 구조 반환 여부 확인") + void newsJsonStructureTest() { + Mono result = newsSearchService.searchNews("뉴스", "sim"); + + // JSON 구조 확인 + result.doOnNext(res -> { + assertNotNull(res.total()); + assertNotNull(res.items()); + + res.items().forEach(item -> { + assertNotNull(item.title()); + assertNotNull(item.link()); + assertNotNull(item.description()); + assertNotNull(item.pubDate()); + }); + }).block(); // Mono 블로킹. + } +} \ No newline at end of file diff --git a/src/test/java/org/tuna/zoopzoop/backend/domain/news/service/NewsServiceTest.java b/src/test/java/org/tuna/zoopzoop/backend/domain/news/service/NewsServiceTest.java index 1c90cc1f..b75e73d6 100644 --- a/src/test/java/org/tuna/zoopzoop/backend/domain/news/service/NewsServiceTest.java +++ b/src/test/java/org/tuna/zoopzoop/backend/domain/news/service/NewsServiceTest.java @@ -1,38 +1,121 @@ package org.tuna.zoopzoop.backend.domain.news.service; +import jakarta.transaction.Transactional; +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.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.context.ActiveProfiles; -import org.tuna.zoopzoop.backend.domain.news.dto.res.ResBodyForNaverNews; -import reactor.core.publisher.Mono; +import org.tuna.zoopzoop.backend.domain.archive.folder.dto.FolderResponse; +import org.tuna.zoopzoop.backend.domain.archive.folder.entity.Folder; +import org.tuna.zoopzoop.backend.domain.archive.folder.repository.FolderRepository; +import org.tuna.zoopzoop.backend.domain.archive.folder.service.FolderService; +import org.tuna.zoopzoop.backend.domain.datasource.entity.Category; +import org.tuna.zoopzoop.backend.domain.datasource.entity.DataSource; +import org.tuna.zoopzoop.backend.domain.datasource.entity.Tag; +import org.tuna.zoopzoop.backend.domain.datasource.repository.DataSourceRepository; +import org.tuna.zoopzoop.backend.domain.datasource.service.DataSourceService; +import org.tuna.zoopzoop.backend.domain.member.entity.Member; +import org.tuna.zoopzoop.backend.domain.member.enums.Provider; +import org.tuna.zoopzoop.backend.domain.member.repository.MemberRepository; +import org.tuna.zoopzoop.backend.domain.member.service.MemberService; -import static org.junit.jupiter.api.Assertions.assertNotNull; +import java.time.LocalDate; +import java.util.List; +import java.util.Map; +import static org.junit.jupiter.api.Assertions.assertEquals; @SpringBootTest @ActiveProfiles("test") -class NewsServiceTest { +@Transactional +public class NewsServiceTest { @Autowired - private NewsSearchService newsSearchService; + private NewsService newsService; + + @Autowired + private NewsAPIService newsAPIService; + + @Autowired + private MemberService memberService; + + @Autowired + private MemberRepository memberRepository; + + @Autowired + private FolderService folderService; + + @Autowired + private FolderRepository folderRepository; + + @Autowired + private DataSourceService dataSourceService; + + @Autowired + private DataSourceRepository dataSourceRepository; + + private final Map> tags = Map.ofEntries( + Map.entry(1, List.of(new Tag("A"), new Tag("B"), new Tag("E"))), + Map.entry(2, List.of(new Tag("B"), new Tag("E"), new Tag("F"))), + Map.entry(3, List.of(new Tag("E"), new Tag("F"))), + Map.entry(4, List.of(new Tag("A"), new Tag("D"), new Tag("E"))), + Map.entry(5, List.of(new Tag("B"), new Tag("F"))), + Map.entry(6, List.of(new Tag("E"), new Tag("F"), new Tag("B"))), + Map.entry(7, List.of(new Tag("D"), new Tag("E"))), + Map.entry(8, List.of(new Tag("B"), new Tag("E"))), + Map.entry(9, List.of(new Tag("F"), new Tag("E"))), + Map.entry(10, List.of(new Tag("C"), new Tag("E"))) + ); + // A = 2회, B = 5회, C = 1회, D = 2회, E = 9회, F = 5회 + + private DataSource buildDataSource(String title, Folder folder, String sourceUrl, List tags) { + DataSource ds = new DataSource(); + ds.setFolder(folder); + ds.setSourceUrl(sourceUrl); + ds.setTitle(title); + ds.setSource("www.examplesource.com"); + ds.setSummary("설명"); + ds.setImageUrl("www.example.com/img"); + ds.setDataCreatedDate(LocalDate.now()); + ds.setTags(tags); + ds.setCategory(Category.ENVIRONMENT); + ds.setActive(true); + return dataSourceRepository.save(ds); + } + + @AfterEach + void cleanUp() { + memberRepository.deleteAll(); + } + + @BeforeEach + public void setUp() { + Member member = memberService.createMember( + "newsServiceTestMember", + "url", + "newsServiceTestKey", + Provider.KAKAO + ); + + FolderResponse folderResponse = folderService.createFolderForPersonal(member.getId(), "newServiceTestFolder"); + Folder folder = folderRepository.findById(folderResponse.folderId()).orElse(null); + + for(int i = 1; i <= 10; i++) { + buildDataSource(String.valueOf(i), folder, String.valueOf(i), tags.get(i)); + } + } @Test - @DisplayName("뉴스 서비스 테스트 - 정상적인 JSON 구조 반환 여부 확인") - void newsJsonStructureTest() { - Mono result = newsSearchService.searchNews("뉴스", "sim"); - - // JSON 구조 확인 - result.doOnNext(res -> { - assertNotNull(res.total()); - assertNotNull(res.items()); - - res.items().forEach(item -> { - assertNotNull(item.title()); - assertNotNull(item.link()); - assertNotNull(item.description()); - assertNotNull(item.pubDate()); - }); - }).block(); // Mono 블로킹. + @DisplayName("태그 빈도 수 추출 테스트") + void DataSourceExtractTagsTest(){ + Member member = memberService.findByProviderKey("newsServiceTestKey"); + List folderResponses = folderService.getFoldersForPersonal(member.getId()); + List frequency = newsService.getTagFrequencyFromFiles(member.getId(), folderResponses.get(0).folderId()); + + assertEquals("E", frequency.get(0)); + assertEquals("B", frequency.get(1)); + assertEquals("F", frequency.get(2)); } -} \ No newline at end of file +}