diff --git a/src/main/java/com/example/globalTimes_be/domain/news/controller/AiControllerDocs.java b/src/main/java/com/example/globalTimes_be/domain/news/controller/AiControllerDocs.java index b58e46c..673a93f 100644 --- a/src/main/java/com/example/globalTimes_be/domain/news/controller/AiControllerDocs.java +++ b/src/main/java/com/example/globalTimes_be/domain/news/controller/AiControllerDocs.java @@ -35,13 +35,13 @@ public interface AiControllerDocs { """) ) ), - @ApiResponse(responseCode = "400", description = "해당 언론사는 요약 정보 제공이 불가능합니다.", + @ApiResponse(responseCode = "400", description = "크롤링 불가 (본문 서두 제공)", content = @Content(mediaType = "application/json", examples = @ExampleObject(value = """ { "timestamp": "2025-04-01T13:37:56.6982049", "isSuccess": false, - "message": "해당 언론사는 요약 정보 제공이 불가능합니다.", + "message": "해당 언론사는 요약 정보 제공이 불가능합니다. (크롤링 불가)", "data": "NEWARK With a mastery of collaborative, often pretty basketball that belied both its youth and the volatile state of the college sport, Duke soared to the programs 18th Final Four on Saturday night, … [+5884 chars]" } """) @@ -49,16 +49,26 @@ public interface AiControllerDocs { ), @ApiResponse(responseCode = "500", description = "서버 에러가 발생하였습니다.", content = @Content(mediaType = "application/json", - examples = @ExampleObject(value = """ - { - "timestamp": "2024-10-30T15:38:12.43483271", - "isSuccess": false, - "message": "서버 에러가 발생하였습니다.", - "data": null - } - """) + examples = { + @ExampleObject(name="서버 에러", value = """ + { + "timestamp": "2024-10-30T15:38:12.43483271", + "isSuccess": false, + "message": "서버 에러가 발생하였습니다.", + "data": null + } + """), + @ExampleObject(name = "gpt 에러", value = """ + { + "timestamp": "2024-10-30T15:40:00.12345678", + "isSuccess": false, + "message": "GPT 요약 중 에러가 발생하였습니다.", + "data": null + } + """) + } ) - ), + ) }) public ResponseEntity summarizeArticle( @Parameter(description = "뉴스기사 ID", example = "1") diff --git a/src/main/java/com/example/globalTimes_be/domain/news/exception/NewsErrorStatus.java b/src/main/java/com/example/globalTimes_be/domain/news/exception/NewsErrorStatus.java index 305ae41..ef226aa 100644 --- a/src/main/java/com/example/globalTimes_be/domain/news/exception/NewsErrorStatus.java +++ b/src/main/java/com/example/globalTimes_be/domain/news/exception/NewsErrorStatus.java @@ -12,7 +12,9 @@ public enum NewsErrorStatus implements BaseResponse { _EMPTY_NEWS_DATA(HttpStatus.BAD_REQUEST, "해당 기사의 정보가 없습니다,"), - _CRAWLER_ERROR(HttpStatus.BAD_REQUEST, "해당 언론사는 요약 정보 제공이 불가능합니다."), + _CRAWLER_ERROR(HttpStatus.BAD_REQUEST, "해당 언론사는 요약 정보 제공이 불가능합니다. (크롤링 불가)"), + + _GPT_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "GPT 요약 중 에러가 발생하였습니다."), ; private final HttpStatus httpStatus; diff --git a/src/main/java/com/example/globalTimes_be/domain/news/service/AiService.java b/src/main/java/com/example/globalTimes_be/domain/news/service/AiService.java index b3904a0..e930792 100644 --- a/src/main/java/com/example/globalTimes_be/domain/news/service/AiService.java +++ b/src/main/java/com/example/globalTimes_be/domain/news/service/AiService.java @@ -1,5 +1,7 @@ package com.example.globalTimes_be.domain.news.service; +import com.example.globalTimes_be.domain.news.exception.NewsErrorStatus; +import com.example.globalTimes_be.global.exception.BaseException; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.web.reactive.function.client.WebClient; @@ -43,6 +45,6 @@ private String extractContent(Map response) { Map message = (Map) choices.get(0).get("message"); return (String) message.get("content"); } - return "응답을 처리할 수 없습니다."; + throw new BaseException(NewsErrorStatus._GPT_ERROR.getResponse()); } } diff --git a/src/main/java/com/example/globalTimes_be/domain/news/service/NewsService.java b/src/main/java/com/example/globalTimes_be/domain/news/service/NewsService.java index b19708c..5d20183 100644 --- a/src/main/java/com/example/globalTimes_be/domain/news/service/NewsService.java +++ b/src/main/java/com/example/globalTimes_be/domain/news/service/NewsService.java @@ -90,8 +90,6 @@ public String getArticleCrawledContent(Long id) { Article article = articleRepository.findById(id) .orElseThrow(() -> new BaseException(NewsErrorStatus._EMPTY_NEWS_DATA.getResponse())); - System.out.println("newsEntity: " + article.getUrl()); - String crawledContent = article.getCrawledContent(); //content가 null이면 크롤링 해오고 db에 저장 diff --git a/src/main/java/com/example/globalTimes_be/domain/trend/controller/TrendAiController.java b/src/main/java/com/example/globalTimes_be/domain/trend/controller/TrendAiController.java new file mode 100644 index 0000000..e8a2591 --- /dev/null +++ b/src/main/java/com/example/globalTimes_be/domain/trend/controller/TrendAiController.java @@ -0,0 +1,33 @@ +package com.example.globalTimes_be.domain.trend.controller; + +import com.example.globalTimes_be.domain.trend.exception.TrendSuccessStatus; +import com.example.globalTimes_be.domain.trend.service.TrendAiService; +import com.example.globalTimes_be.domain.trend.service.TrendCrawledService; +import com.example.globalTimes_be.global.apiPayload.code.ApiResponse; +import com.example.globalTimes_be.global.apiPayload.code.status.GlobalSuccessStatus; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +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; + +@RequiredArgsConstructor +@RestController +@RequestMapping("/api/trend") +public class TrendAiController implements TrendAiControllerDocs{ + private final TrendCrawledService trendCrawledService; + private final TrendAiService trendAiService; + + //기사의 url을 받으면 요약해주는 api 제공 + @Override + @GetMapping("/summary") + public ResponseEntity getSummarizeTrendArticle (@RequestParam String url, + @RequestParam(defaultValue = "영어") String language) { + //본문 크롤링 + String content = trendCrawledService.getArticleCrawledContent(url); + //크롤링한 본문 요약 + String summary = trendAiService.summarizeTrendArticle(content, language); + return ApiResponse.success(GlobalSuccessStatus._OK.getResponse(), summary); + } +} diff --git a/src/main/java/com/example/globalTimes_be/domain/trend/controller/TrendAiControllerDocs.java b/src/main/java/com/example/globalTimes_be/domain/trend/controller/TrendAiControllerDocs.java new file mode 100644 index 0000000..29cd6de --- /dev/null +++ b/src/main/java/com/example/globalTimes_be/domain/trend/controller/TrendAiControllerDocs.java @@ -0,0 +1,80 @@ +package com.example.globalTimes_be.domain.trend.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.constraints.NotNull; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestParam; + +@Tag(name = "실시간 검색어 페이지", description = "실시간 검색어 관련 API입니다.") +public interface TrendAiControllerDocs { + + @Operation(summary = "기사 요약", + description = "해당 기사에 대한 요약을 한번에 반환합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "응답 성공", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = com.example.globalTimes_be.global.apiPayload.code.ApiResponse.class), + examples = @ExampleObject(value = """ + { + "timestamp": "2025-04-01T15:41:45.2180857", + "isSuccess": true, + "message": "응답에 성공했습니다.", + "data": "국가정보원이 최근 5년간 공공기관의 인공지능(AI) 정보화사업 실태를 조사하기 시작했으며, 이는 AI 기술의 안전한 운용과 보안 강화 방안을 모색하기 위한 차원이다. 이번 조사는 공공기관의 AI 활용 확대에 따른 보안 체계 정비를 목적으로 하고 있다." + } + """) + ) + ), + @ApiResponse(responseCode = "400", description = "크롤링 불가", + content = @Content(mediaType = "application/json", + examples = @ExampleObject(value = """ + { + "timestamp": "2025-04-01T13:37:56.6982049", + "isSuccess": false, + "message": "해당 언론사는 요약 정보 제공이 불가능합니다. (크롤링 불가)" + } + """) + ) + ), + @ApiResponse(responseCode = "500", description = "서버 에러가 발생하였습니다.", + content = @Content(mediaType = "application/json", + examples = { + @ExampleObject(name="서버 에러", value = """ + { + "timestamp": "2024-10-30T15:38:12.43483271", + "isSuccess": false, + "message": "서버 에러가 발생하였습니다.", + "data": null + } + """), + @ExampleObject(name = "gpt 에러", value = """ + { + "timestamp": "2024-10-30T15:40:00.12345678", + "isSuccess": false, + "message": "GPT 요약 중 에러가 발생하였습니다.", + "data": null + } + """) + } + ) + ) + }) + public ResponseEntity getSummarizeTrendArticle( + @Parameter(description = "뉴스기사 url", example = "https://www.etnews.com/20...") + @NotNull(message = "뉴스기사 url은 비어있을 수 없습니다.") + @PathVariable String url, + + @Parameter(description = "언어 설정 (기본값: 영어)", examples = { + @ExampleObject(name = "영어", value = "영어"), + @ExampleObject(name = "한국어", value = "한국어"), + @ExampleObject(name = "중국어", value = "중국어")}) + @RequestParam String language); + +} diff --git a/src/main/java/com/example/globalTimes_be/domain/trend/controller/TrendControllerDocs.java b/src/main/java/com/example/globalTimes_be/domain/trend/controller/TrendControllerDocs.java index 142e10a..8043cf7 100644 --- a/src/main/java/com/example/globalTimes_be/domain/trend/controller/TrendControllerDocs.java +++ b/src/main/java/com/example/globalTimes_be/domain/trend/controller/TrendControllerDocs.java @@ -12,7 +12,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.RequestParam; -@Tag(name = "실시간 검색어", description = "실시간 검색어 관련 API입니다.") +@Tag(name = "실시간 검색어 페이지", description = "실시간 검색어 관련 API입니다.") public interface TrendControllerDocs { @Operation(summary = "나라별 실시간 검색어 데이터 응답", @@ -58,28 +58,26 @@ public interface TrendControllerDocs { """) ) ), - @ApiResponse(responseCode = "400", description = "역직렬화에 실패하였습니다.", - content = @Content(mediaType = "application/json", - examples = @ExampleObject(value = """ - { - "timestamp": "2024-10-30T15:38:12.43483271", - "isSuccess": false, - "message": "역직렬화에 실패하였습니다.", - "data": null - } - """) - ) - ), + @ApiResponse(responseCode = "500", description = "서버 에러가 발생하였습니다.", content = @Content(mediaType = "application/json", - examples = @ExampleObject(value = """ - { - "timestamp": "2024-10-30T15:38:12.43483271", - "isSuccess": false, - "message": "서버 에러가 발생하였습니다.", - "data": null - } - """) + examples = {@ExampleObject(name="서버에러", value = """ + { + "timestamp": "2024-10-30T15:38:12.43483271", + "isSuccess": false, + "message": "서버 에러가 발생하였습니다.", + "data": null + } + """), + @ExampleObject(name="역직렬화 실패", value = """ + { + "timestamp": "2024-10-30T15:38:12.43483271", + "isSuccess": false, + "message": "역직렬화에 실패하였습니다.", + "data": null + } + """) + } ) ), }) diff --git a/src/main/java/com/example/globalTimes_be/domain/trend/exception/TrendErrorStatus.java b/src/main/java/com/example/globalTimes_be/domain/trend/exception/TrendErrorStatus.java index 9236b0b..a53b42d 100644 --- a/src/main/java/com/example/globalTimes_be/domain/trend/exception/TrendErrorStatus.java +++ b/src/main/java/com/example/globalTimes_be/domain/trend/exception/TrendErrorStatus.java @@ -11,8 +11,12 @@ public enum TrendErrorStatus implements BaseResponse { _CUSTOM_ERROR(HttpStatus.BAD_REQUEST, "에러테스트 요청입니다."), _INVALID_COUNTRY_CODE(HttpStatus.BAD_REQUEST, "잘못된 국가 코드입니다."), - _FAIL_SERIALIZATION(HttpStatus.BAD_REQUEST, "직렬화에 실패하였습니다."), - _FAIL_DESERIALIZATION(HttpStatus.BAD_REQUEST, "역직렬화에 실패하였습니다."), + _FAIL_SERIALIZATION(HttpStatus.INTERNAL_SERVER_ERROR, "직렬화에 실패하였습니다."), + _FAIL_DESERIALIZATION(HttpStatus.INTERNAL_SERVER_ERROR, "역직렬화에 실패하였습니다."), + + _CRAWLER_ERROR(HttpStatus.BAD_REQUEST, "해당 언론사는 요약 정보 제공이 불가능합니다. (크롤링 불가)"), + + _GPT_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "GPT 요약 중 에러가 발생하였습니다."), ; private final HttpStatus httpStatus; diff --git a/src/main/java/com/example/globalTimes_be/domain/trend/service/TrendAiService.java b/src/main/java/com/example/globalTimes_be/domain/trend/service/TrendAiService.java new file mode 100644 index 0000000..e04b59c --- /dev/null +++ b/src/main/java/com/example/globalTimes_be/domain/trend/service/TrendAiService.java @@ -0,0 +1,49 @@ +package com.example.globalTimes_be.domain.trend.service; + +import com.example.globalTimes_be.domain.trend.exception.TrendErrorStatus; +import com.example.globalTimes_be.global.exception.BaseException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.web.reactive.function.client.WebClient; + +import java.util.List; +import java.util.Map; + +@RequiredArgsConstructor +@Service +public class TrendAiService { + private final WebClient openAiWebClient; + + public String summarizeTrendArticle (String content, String language) { + String summary = openAiWebClient.post() + .uri("/chat/completions") + .bodyValue(createRequestBody(content, language)) // 요청 본문 + .retrieve() + .bodyToMono(Map.class) // 전체 응답을 한 번에 받음 + .map(response -> extractContent(response)) // 응답 본문에서 요약 내용 추출 + .block(); + return summary; + } + + // OpenAI 요청 본문 생성 (기사 요약) + private Map createRequestBody(String content, String language) { + return Map.of( + "model", "gpt-4o-mini", + "messages", List.of( + Map.of("role", "system", "content", "이 기사를 " + language + "로 2줄 요약해줘."), + Map.of("role", "user", "content", content) + ), + "stream", false // 🔹 스트리밍 비활성화 + ); + } + + // OpenAI 응답에서 'content' 추출 + private String extractContent(Map response) { + List> choices = (List>) response.get("choices"); + if (choices != null && !choices.isEmpty()) { + Map message = (Map) choices.get(0).get("message"); + return (String) message.get("content"); + } + throw new BaseException(TrendErrorStatus._GPT_ERROR.getResponse()); + } +} diff --git a/src/main/java/com/example/globalTimes_be/domain/trend/service/TrendCrawledService.java b/src/main/java/com/example/globalTimes_be/domain/trend/service/TrendCrawledService.java new file mode 100644 index 0000000..ca7ca3b --- /dev/null +++ b/src/main/java/com/example/globalTimes_be/domain/trend/service/TrendCrawledService.java @@ -0,0 +1,40 @@ +package com.example.globalTimes_be.domain.trend.service; + +import com.example.globalTimes_be.domain.trend.exception.TrendErrorStatus; +import com.example.globalTimes_be.global.exception.BaseException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.jsoup.Jsoup; +import org.jsoup.nodes.Document; +import org.jsoup.nodes.Element; +import org.jsoup.select.Elements; +import org.springframework.stereotype.Service; + +import java.io.IOException; + +@Slf4j +@RequiredArgsConstructor +@Service +public class TrendCrawledService { + + //url 본문 기사 크롤링 + public String getArticleCrawledContent(String url){ + try { + // URL에서 HTML 문서 가져오기 + Document doc = Jsoup.connect(url).get(); + + // 모든

태그 가져오기 + Elements paragraphs = doc.select("p"); + + // 텍스트만 추출해서 하나의 문자열로 반환 (줄바꿈 포함) + return paragraphs.stream() + .map(Element::text) + .reduce((p1, p2) -> p1 + "\n" + p2) // 문장마다 줄바꿈 추가 + .orElse(null); + + } catch (IOException e) { + log.error("크롤링에 실패했습니다. \n{}", e.getMessage()); + throw new BaseException(TrendErrorStatus._CRAWLER_ERROR.getResponse()); + } + } +}