diff --git a/build.gradle b/build.gradle index d534496..78f4417 100644 --- a/build.gradle +++ b/build.gradle @@ -42,6 +42,12 @@ dependencies { implementation 'io.jsonwebtoken:jjwt-impl:0.11.5' implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5' + // WebClient + implementation 'org.springframework.boot:spring-boot-starter-webflux' + implementation 'com.fasterxml.jackson.core:jackson-databind' + implementation 'com.fasterxml.jackson.core:jackson-annotations' + implementation 'com.fasterxml.jackson.core:jackson-core' + // 헬스 체크 api 를 사용하기 위한 Actuator 의존성 추가 implementation 'org.springframework.boot:spring-boot-starter-actuator' } diff --git a/src/main/java/Konkuk/U2E/domain/news/domain/News.java b/src/main/java/Konkuk/U2E/domain/news/domain/News.java index c7c58eb..c0777fb 100644 --- a/src/main/java/Konkuk/U2E/domain/news/domain/News.java +++ b/src/main/java/Konkuk/U2E/domain/news/domain/News.java @@ -43,6 +43,29 @@ public class News extends BaseEntity { @OneToMany(mappedBy = "news", cascade = CascadeType.ALL, orphanRemoval = true) private List comments = new ArrayList<>(); + @Lob + @Column(name = "ai_solution", columnDefinition = "LONGTEXT") + private String aiSolution; + + @Column(name = "ai_related1_title", length = 300) + private String aiRelated1Title; + @Column(name = "ai_related1_url", columnDefinition = "TEXT") + private String aiRelated1Url; + + @Column(name = "ai_related2_title", length = 300) + private String aiRelated2Title; + @Column(name = "ai_related2_url", columnDefinition = "TEXT") + private String aiRelated2Url; + + @Column(name = "ai_related3_title", length = 300) + private String aiRelated3Title; + @Column(name = "ai_related3_url", columnDefinition = "TEXT") + private String aiRelated3Url; + + @Lob + @Column(name = "ai_summary", columnDefinition = "LONGTEXT") + private String aiSummary; + @Builder public News(String newsUrl, String imageUrl, String newsTitle, String newsBody, LocalDate newsDate, List climateList) { this.newsUrl = newsUrl; @@ -57,4 +80,18 @@ public void addComment(Comment comment) { this.comments.add(comment); comment.setNews(this); } + + public void applyAiResult(String solution, + String t1, String u1, + String t2, String u2, + String t3, String u3) { + this.aiSolution = solution; + this.aiRelated1Title = t1; this.aiRelated1Url = u1; + this.aiRelated2Title = t2; this.aiRelated2Url = u2; + this.aiRelated3Title = t3; this.aiRelated3Url = u3; + } + + public void applyAiSummary(String summary) { + this.aiSummary = summary; + } } diff --git a/src/main/java/Konkuk/U2E/domain/news/dto/response/GetNewsInfoResponse.java b/src/main/java/Konkuk/U2E/domain/news/dto/response/GetNewsInfoResponse.java index 078779b..0295b7d 100644 --- a/src/main/java/Konkuk/U2E/domain/news/dto/response/GetNewsInfoResponse.java +++ b/src/main/java/Konkuk/U2E/domain/news/dto/response/GetNewsInfoResponse.java @@ -3,6 +3,7 @@ import Konkuk.U2E.domain.news.domain.ClimateProblem; import Konkuk.U2E.domain.news.domain.News; import Konkuk.U2E.domain.news.service.mapper.NewsMappingResult; +import Konkuk.U2E.global.openApi.gemini.dto.response.RelatedArticle; import java.util.List; @@ -13,10 +14,22 @@ public record GetNewsInfoResponse( String newsUrl, String newsImageUrl, String newsBody, - String newsDate + String newsDate, + String aiSolution, + List aiRelated ) { - public static GetNewsInfoResponse of(NewsMappingResult newsMappingResult) { + public static GetNewsInfoResponse of(NewsMappingResult newsMappingResult, String aiSolution, List aiRelated) { News news = newsMappingResult.news(); - return new GetNewsInfoResponse(newsMappingResult.climateProblems(), newsMappingResult.regionNames(), news.getNewsTitle(), news.getNewsUrl(), news.getImageUrl(), news.getNewsBody(), news.getNewsDate().toString()); + return new GetNewsInfoResponse( + newsMappingResult.climateProblems(), + newsMappingResult.regionNames(), + news.getNewsTitle(), + news.getNewsUrl(), + news.getImageUrl(), + news.getAiSummary(), + news.getNewsDate().toString(), + aiSolution, + aiRelated + ); } } diff --git a/src/main/java/Konkuk/U2E/domain/news/repository/ClimateRepository.java b/src/main/java/Konkuk/U2E/domain/news/repository/ClimateRepository.java index 7b00e20..d9f2ca9 100644 --- a/src/main/java/Konkuk/U2E/domain/news/repository/ClimateRepository.java +++ b/src/main/java/Konkuk/U2E/domain/news/repository/ClimateRepository.java @@ -21,4 +21,6 @@ public interface ClimateRepository extends JpaRepository { "FROM Climate c WHERE c.news = :news AND c.climateProblem = :climateProblem") boolean existsByNewsAndClimateProblem(@Param("news") News news, @Param("climateProblem") ClimateProblem climateProblem); + + void deleteAllByNews(News news); } diff --git a/src/main/java/Konkuk/U2E/domain/news/repository/NewsRepository.java b/src/main/java/Konkuk/U2E/domain/news/repository/NewsRepository.java index aa32628..7f5c0a4 100644 --- a/src/main/java/Konkuk/U2E/domain/news/repository/NewsRepository.java +++ b/src/main/java/Konkuk/U2E/domain/news/repository/NewsRepository.java @@ -1,6 +1,8 @@ package Konkuk.U2E.domain.news.repository; import Konkuk.U2E.domain.news.domain.News; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; @@ -9,4 +11,6 @@ @Repository public interface NewsRepository extends JpaRepository { List findTop5ByOrderByNewsDateDesc(); + + Page findByAiSummaryIsNull(Pageable pageable); } diff --git a/src/main/java/Konkuk/U2E/domain/news/service/NewsInfoService.java b/src/main/java/Konkuk/U2E/domain/news/service/NewsInfoService.java index 2188233..2a90aea 100644 --- a/src/main/java/Konkuk/U2E/domain/news/service/NewsInfoService.java +++ b/src/main/java/Konkuk/U2E/domain/news/service/NewsInfoService.java @@ -5,8 +5,18 @@ import Konkuk.U2E.domain.news.exception.NewsNotFoundException; import Konkuk.U2E.domain.news.repository.NewsRepository; import Konkuk.U2E.domain.news.service.mapper.NewsMapperFactory; +import Konkuk.U2E.global.openApi.gemini.dto.request.AiNewsRequest; +import Konkuk.U2E.global.openApi.gemini.dto.response.AiResponse; +import Konkuk.U2E.global.openApi.gemini.dto.response.RelatedArticle; +import Konkuk.U2E.global.openApi.gemini.service.NewsAiService; +import Konkuk.U2E.global.openApi.gemini.service.NewsRegionUpsertService; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.util.ArrayList; +import java.util.List; import static Konkuk.U2E.global.response.status.BaseExceptionResponseStatus.NEWS_NOT_FOUND; @@ -17,11 +27,29 @@ public class NewsInfoService { private final NewsRepository newsRepository; private final NewsMapperFactory newsMapperFactory; - // 뉴스 상세보기 + private final NewsAiService aggregatedNewsAiService; + private final NewsRegionUpsertService newsRegionUpsertService; + + @Transactional public GetNewsInfoResponse getNewsInfo(Long newsId) { News news = newsRepository.findById(newsId) .orElseThrow(() -> new NewsNotFoundException(NEWS_NOT_FOUND)); - return GetNewsInfoResponse.of(newsMapperFactory.newsMappingFunction().apply(news)); + // 여기서는 DB에 있는 값만 사용 (Gemini 호출/저장 X) + var mapping = newsMapperFactory.newsMappingFunction().apply(news); + List related = fromEntity(news); + return GetNewsInfoResponse.of(mapping, news.getAiSolution(), related); } + + private List fromEntity(News n) { + List list = new ArrayList<>(3); + if (StringUtils.hasText(n.getAiRelated1Title()) || StringUtils.hasText(n.getAiRelated1Url())) + list.add(new RelatedArticle(n.getAiRelated1Title(), n.getAiRelated1Url())); + if (StringUtils.hasText(n.getAiRelated2Title()) || StringUtils.hasText(n.getAiRelated2Url())) + list.add(new RelatedArticle(n.getAiRelated2Title(), n.getAiRelated2Url())); + if (StringUtils.hasText(n.getAiRelated3Title()) || StringUtils.hasText(n.getAiRelated3Url())) + list.add(new RelatedArticle(n.getAiRelated3Title(), n.getAiRelated3Url())); + return list; + } + } diff --git a/src/main/java/Konkuk/U2E/domain/pin/repository/RegionRepository.java b/src/main/java/Konkuk/U2E/domain/pin/repository/RegionRepository.java index 67c0c26..b463c53 100644 --- a/src/main/java/Konkuk/U2E/domain/pin/repository/RegionRepository.java +++ b/src/main/java/Konkuk/U2E/domain/pin/repository/RegionRepository.java @@ -6,6 +6,7 @@ import org.springframework.stereotype.Repository; import java.util.List; +import java.util.Optional; @Repository public interface RegionRepository extends JpaRepository { @@ -13,4 +14,7 @@ public interface RegionRepository extends JpaRepository { //문자열을 포함하는 모든 지역을 찾는 메서드 @Query("SELECT r FROM Region r WHERE LOWER(r.name) LIKE LOWER(CONCAT('%', :region, '%'))") List findRegionsByName(String region); + + Optional findByNameIgnoreCase(String name); + } diff --git a/src/main/java/Konkuk/U2E/global/config/SchedulingConfig.java b/src/main/java/Konkuk/U2E/global/config/SchedulingConfig.java new file mode 100644 index 0000000..8a377dd --- /dev/null +++ b/src/main/java/Konkuk/U2E/global/config/SchedulingConfig.java @@ -0,0 +1,9 @@ +package Konkuk.U2E.global.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableScheduling; + +@Configuration +@EnableScheduling +public class SchedulingConfig { +} diff --git a/src/main/java/Konkuk/U2E/global/openApi/gemini/GeminiClient.java b/src/main/java/Konkuk/U2E/global/openApi/gemini/GeminiClient.java new file mode 100644 index 0000000..0678407 --- /dev/null +++ b/src/main/java/Konkuk/U2E/global/openApi/gemini/GeminiClient.java @@ -0,0 +1,170 @@ +package Konkuk.U2E.global.openApi.gemini; + +import Konkuk.U2E.global.openApi.gemini.exception.GeminiCallFailedException; +import Konkuk.U2E.global.openApi.gemini.exception.GeminiInvalidResponseException; +import Konkuk.U2E.global.openApi.gemini.exception.GeminiMissingApiKeyException; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatusCode; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; + +@Slf4j +@Component +@RequiredArgsConstructor +public class GeminiClient { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Value("${gemini.api-key}") + private String apiKey; + + @Value("${gemini.model}") + private String model; + + @Value("${gemini.endpoint}") + private String endpoint; + + private WebClient webClient() { + return WebClient.builder() + .baseUrl(endpoint) + .build(); + } + + public String generateContentJson(String systemPrompt, String userPrompt) { + if (!StringUtils.hasText(apiKey)) throw new GeminiMissingApiKeyException(); + + String payload = """ + { + "system_instruction": { + "role": "system", + "parts": [{ "text": %s }] + }, + "contents": [{ + "parts": [{ "text": %s }] + }], + "generation_config": { + "temperature": 0.3, + "top_k": 32, + "top_p": 0.9, + "max_output_tokens": 2048, + "response_mime_type": "application/json" + } + } + """.formatted(jsonEscape(systemPrompt), jsonEscape(userPrompt)); + + String path = "/models/%s:generateContent".formatted(model); + + String raw = webClient() + .post() + .uri(u -> u.path(path).queryParam("key", apiKey).build()) + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(payload) + .retrieve() + .onStatus(HttpStatusCode::isError, resp -> + resp.bodyToMono(String.class) + .defaultIfEmpty("") + .flatMap(body -> Mono.error(new GeminiCallFailedException( + "status=" + resp.statusCode() + ", body=" + body))) + ) + .bodyToMono(String.class) + .block(); + + try { + JsonNode root = objectMapper.readTree(raw); + JsonNode candidates = root.path("candidates"); + if (!candidates.isArray() || candidates.isEmpty()) { + throw new GeminiInvalidResponseException("candidates 비어있음. raw=" + safePreview(raw)); + } + + StringBuilder sb = new StringBuilder(); + for (JsonNode cand : candidates) { + JsonNode parts = cand.path("content").path("parts"); + if (parts.isArray()) { + for (JsonNode p : parts) { + JsonNode t = p.path("text"); + if (t.isTextual()) sb.append(t.asText()); + } + } + } + String text = sb.toString(); + if (text.isBlank()) throw new GeminiInvalidResponseException("parts.text 없음. raw=" + safePreview(raw)); + + if (canParseJson(text)) return text; + + String s2 = stripPrologueAndExtractObject(text); + if (canParseJson(s2)) return s2; + + String unwrapped = tryUnwrapJsonString(s2); + if (canParseJson(unwrapped)) return unwrapped; + + String unwrappedRaw = tryUnwrapJsonString(text); + if (canParseJson(unwrappedRaw)) return unwrappedRaw; + + throw new GeminiInvalidResponseException("JSON 파싱 실패(복구 실패). preview=" + safePreview(text)); + + } catch (GeminiInvalidResponseException e) { + throw e; + } catch (Exception e) { + throw new GeminiInvalidResponseException("JSON 파싱 실패: " + e.getMessage()); + } + } + + private boolean canParseJson(String s) { + if (s == null || s.isBlank()) return false; + try { + objectMapper.readTree(s); + return true; + } catch (Exception e) { + log.warn("[GeminiClient] parse fail: {}", e.toString()); + return false; + } + } + + private static String stripPrologueAndExtractObject(String s) { + if (s == null) return ""; + String v = s.replace("```json", "") + .replace("```JSON", "") + .replace("```", ""); + v = v.replaceFirst("(?i)^\\s*here is the json requested:\\s*", ""); + int start = v.indexOf('{'); + int end = v.lastIndexOf('}'); + if (start >= 0 && end > start) v = v.substring(start, end + 1); + return v.trim(); + } + + private String tryUnwrapJsonString(String s) { + if (s == null || s.isBlank()) return s; + try { + String inner = objectMapper.readValue(s, String.class); + return inner.replace("\\\"", "\""); + } catch (JsonProcessingException ignore) { + if (s.contains("\\\"")) return s.replace("\\\"", "\""); + String t = s.trim(); + if ((t.startsWith("\"") && t.endsWith("\"")) || (t.startsWith("'") && t.endsWith("'"))) { + return t.substring(1, t.length() - 1); + } + return s; + } + } + + private static String jsonEscape(String s) { + return objectToJsonString(s); + } + private static String objectToJsonString(Object o) { + try { return new ObjectMapper().writeValueAsString(o); } + catch (Exception e) { throw new RuntimeException(e); } + } + private static String safePreview(String s) { + if (s == null) return "null"; + String t = s.replaceAll("\\s+", " "); + return t.length() > 300 ? t.substring(0, 300) + "..." : t; + } +} \ No newline at end of file diff --git a/src/main/java/Konkuk/U2E/global/openApi/gemini/dto/request/AiNewsRequest.java b/src/main/java/Konkuk/U2E/global/openApi/gemini/dto/request/AiNewsRequest.java new file mode 100644 index 0000000..487ab1f --- /dev/null +++ b/src/main/java/Konkuk/U2E/global/openApi/gemini/dto/request/AiNewsRequest.java @@ -0,0 +1,6 @@ +package Konkuk.U2E.global.openApi.gemini.dto.request; + +public record AiNewsRequest( + String body, // 분석할 뉴스 본문 + String locale // "ko" or "en" (옵션) +) {} diff --git a/src/main/java/Konkuk/U2E/global/openApi/gemini/dto/response/AiResponse.java b/src/main/java/Konkuk/U2E/global/openApi/gemini/dto/response/AiResponse.java new file mode 100644 index 0000000..d7bea26 --- /dev/null +++ b/src/main/java/Konkuk/U2E/global/openApi/gemini/dto/response/AiResponse.java @@ -0,0 +1,12 @@ +package Konkuk.U2E.global.openApi.gemini.dto.response; + +import java.util.List; + +public record AiResponse( + String summary, // 기사 요약 + String solution, // 실행 가능한 핵심 솔루션 + List related, // 관련 뉴스 (최대 3) + List regions , // 지역 후보 (최대 3, 이름+위도+경도) + List climateProblems // 기후문제 enum 이름 리스트 (예: ["WILDFIRE","HEAVY_RAIN_OR_FLOOD"]) +) { +} diff --git a/src/main/java/Konkuk/U2E/global/openApi/gemini/dto/response/RegionCandidate.java b/src/main/java/Konkuk/U2E/global/openApi/gemini/dto/response/RegionCandidate.java new file mode 100644 index 0000000..a2f032d --- /dev/null +++ b/src/main/java/Konkuk/U2E/global/openApi/gemini/dto/response/RegionCandidate.java @@ -0,0 +1,10 @@ +package Konkuk.U2E.global.openApi.gemini.dto.response; + + +import java.math.BigDecimal; + +public record RegionCandidate( + String name, + BigDecimal latitude, + BigDecimal longitude +) {} diff --git a/src/main/java/Konkuk/U2E/global/openApi/gemini/dto/response/RelatedArticle.java b/src/main/java/Konkuk/U2E/global/openApi/gemini/dto/response/RelatedArticle.java new file mode 100644 index 0000000..3093d19 --- /dev/null +++ b/src/main/java/Konkuk/U2E/global/openApi/gemini/dto/response/RelatedArticle.java @@ -0,0 +1,6 @@ +package Konkuk.U2E.global.openApi.gemini.dto.response; + +public record RelatedArticle( + String title, + String url +) {} diff --git a/src/main/java/Konkuk/U2E/global/openApi/gemini/exception/GeminiCallFailedException.java b/src/main/java/Konkuk/U2E/global/openApi/gemini/exception/GeminiCallFailedException.java new file mode 100644 index 0000000..c65d23e --- /dev/null +++ b/src/main/java/Konkuk/U2E/global/openApi/gemini/exception/GeminiCallFailedException.java @@ -0,0 +1,9 @@ +package Konkuk.U2E.global.openApi.gemini.exception; + +import static Konkuk.U2E.global.response.status.BaseExceptionResponseStatus.GEMINI_CALL_FAILED; + +public class GeminiCallFailedException extends GeminiException { + public GeminiCallFailedException(String detail) { + super(GEMINI_CALL_FAILED, detail); + } +} diff --git a/src/main/java/Konkuk/U2E/global/openApi/gemini/exception/GeminiException.java b/src/main/java/Konkuk/U2E/global/openApi/gemini/exception/GeminiException.java new file mode 100644 index 0000000..a7a70dd --- /dev/null +++ b/src/main/java/Konkuk/U2E/global/openApi/gemini/exception/GeminiException.java @@ -0,0 +1,15 @@ +package Konkuk.U2E.global.openApi.gemini.exception; + +import Konkuk.U2E.global.response.status.ResponseStatus; +import lombok.Getter; + +@Getter +public class GeminiException extends RuntimeException{ + private final ResponseStatus exceptionStatus; + + protected GeminiException(ResponseStatus status, String detail) { + super(detail == null ? status.getMessage() : detail); + this.exceptionStatus = status; + } + +} diff --git a/src/main/java/Konkuk/U2E/global/openApi/gemini/exception/GeminiInvalidResponseException.java b/src/main/java/Konkuk/U2E/global/openApi/gemini/exception/GeminiInvalidResponseException.java new file mode 100644 index 0000000..96c9506 --- /dev/null +++ b/src/main/java/Konkuk/U2E/global/openApi/gemini/exception/GeminiInvalidResponseException.java @@ -0,0 +1,9 @@ +package Konkuk.U2E.global.openApi.gemini.exception; + +import static Konkuk.U2E.global.response.status.BaseExceptionResponseStatus.GEMINI_INVALID_RESPONSE; + +public class GeminiInvalidResponseException extends GeminiException { + public GeminiInvalidResponseException(String detail) { + super(GEMINI_INVALID_RESPONSE, detail); + } +} diff --git a/src/main/java/Konkuk/U2E/global/openApi/gemini/exception/GeminiMissingApiKeyException.java b/src/main/java/Konkuk/U2E/global/openApi/gemini/exception/GeminiMissingApiKeyException.java new file mode 100644 index 0000000..d43c731 --- /dev/null +++ b/src/main/java/Konkuk/U2E/global/openApi/gemini/exception/GeminiMissingApiKeyException.java @@ -0,0 +1,9 @@ +package Konkuk.U2E.global.openApi.gemini.exception; + +import static Konkuk.U2E.global.response.status.BaseExceptionResponseStatus.GEMINI_MISSING_API_KEY; + +public class GeminiMissingApiKeyException extends GeminiException { + public GeminiMissingApiKeyException() { + super(GEMINI_MISSING_API_KEY, null); + } +} diff --git a/src/main/java/Konkuk/U2E/global/openApi/gemini/exception/handler/GeminiControllerAdvice.java b/src/main/java/Konkuk/U2E/global/openApi/gemini/exception/handler/GeminiControllerAdvice.java new file mode 100644 index 0000000..dc73747 --- /dev/null +++ b/src/main/java/Konkuk/U2E/global/openApi/gemini/exception/handler/GeminiControllerAdvice.java @@ -0,0 +1,45 @@ +package Konkuk.U2E.global.openApi.gemini.exception.handler; + + +import Konkuk.U2E.global.openApi.gemini.exception.GeminiCallFailedException; +import Konkuk.U2E.global.openApi.gemini.exception.GeminiInvalidResponseException; +import Konkuk.U2E.global.openApi.gemini.exception.GeminiMissingApiKeyException; +import Konkuk.U2E.global.response.BaseErrorResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.annotation.Order; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import static org.springframework.core.Ordered.HIGHEST_PRECEDENCE; + +@Slf4j +@Order(HIGHEST_PRECEDENCE) +@RestControllerAdvice +public class GeminiControllerAdvice { + + // 외부 API 호출 실패 + @ResponseStatus(HttpStatus.BAD_GATEWAY) + @ExceptionHandler(GeminiCallFailedException.class) + public BaseErrorResponse handleGeminiCallFailed(GeminiCallFailedException e) { + log.error("[GeminiCallFailed]", e); + return new BaseErrorResponse(e.getExceptionStatus(), e.getMessage()); + } + + // 파싱 실패 + @ResponseStatus(HttpStatus.BAD_GATEWAY) + @ExceptionHandler(GeminiInvalidResponseException.class) + public BaseErrorResponse handleGeminiInvalidResponse(GeminiInvalidResponseException e) { + log.error("[GeminiInvalidResponse]", e); + return new BaseErrorResponse(e.getExceptionStatus(), e.getMessage()); + } + + // 설정/키 누락 + @ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR) + @ExceptionHandler(GeminiMissingApiKeyException.class) + public BaseErrorResponse handleGeminiMissingApiKey(GeminiMissingApiKeyException e) { + log.error("[GeminiMissingApiKey]", e); + return new BaseErrorResponse(e.getExceptionStatus(), e.getMessage()); + } +} diff --git a/src/main/java/Konkuk/U2E/global/openApi/gemini/scheduler/NewsAiProcessor.java b/src/main/java/Konkuk/U2E/global/openApi/gemini/scheduler/NewsAiProcessor.java new file mode 100644 index 0000000..1446a77 --- /dev/null +++ b/src/main/java/Konkuk/U2E/global/openApi/gemini/scheduler/NewsAiProcessor.java @@ -0,0 +1,74 @@ +package Konkuk.U2E.global.openApi.gemini.scheduler; + +import Konkuk.U2E.domain.news.domain.News; +import Konkuk.U2E.domain.news.repository.NewsRepository; +import Konkuk.U2E.global.openApi.gemini.dto.request.AiNewsRequest; +import Konkuk.U2E.global.openApi.gemini.dto.response.AiResponse; +import Konkuk.U2E.global.openApi.gemini.service.NewsAiService; +import Konkuk.U2E.global.openApi.gemini.service.NewsClimateUpsertService; +import Konkuk.U2E.global.openApi.gemini.service.NewsRegionUpsertService; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class NewsAiProcessor { + + private final NewsRepository newsRepository; + private final NewsAiService newsAiService; + private final NewsRegionUpsertService newsRegionUpsertService; + private final NewsClimateUpsertService newsClimateUpsertService; + + @Transactional + public void processOneTransactional(Long newsId) { + News news = newsRepository.findById(newsId).orElse(null); + if (news == null) return; + if (news.getAiSummary() != null && !news.getAiSummary().isBlank()) return; + + int attempts = 0; + int maxAttempts = 3; + long backoffMs = 1500L; + + while (true) { + attempts++; + try { + AiResponse ai = newsAiService.analyzeAll(new AiNewsRequest(news.getNewsBody(), "en")); + + String t1 = relTitle(ai, 0); String u1 = relUrl(ai, 0); + String t2 = relTitle(ai, 1); String u2 = relUrl(ai, 1); + String t3 = relTitle(ai, 2); String u3 = relUrl(ai, 2); + + news.applyAiResult(ai.solution(), t1, u1, t2, u2, t3, u3); + news.applyAiSummary(ai.summary()); + + // 지역/핀 매핑 + newsRegionUpsertService.linkFromCandidates(news, ai.regions()); + + // 기후문제 매핑 (DB 교체 저장) + newsClimateUpsertService.replaceWithEnums(news, ai.climateProblems()); + + return; + + } catch (Exception e) { + if (attempts >= maxAttempts) { + log.error("[NewsAiFill] give up newsId={} after {} attempts: {}", newsId, attempts, e.toString()); + return; + } + log.warn("[NewsAiFill] retry newsId={} attempt={}/{} : {}", newsId, attempts, maxAttempts, e.toString()); + sleep(backoffMs); + backoffMs *= 2; + } + } + } + + private String relTitle(AiResponse a, int i) { + return (a.related() != null && a.related().size() > i) ? a.related().get(i).title() : null; + } + private String relUrl(AiResponse a, int i) { + return (a.related() != null && a.related().size() > i) ? a.related().get(i).url() : null; + } + private void sleep(long ms) { try { Thread.sleep(ms); } catch (InterruptedException ignored) {} } +} \ No newline at end of file diff --git a/src/main/java/Konkuk/U2E/global/openApi/gemini/scheduler/NewsAiScheduler.java b/src/main/java/Konkuk/U2E/global/openApi/gemini/scheduler/NewsAiScheduler.java new file mode 100644 index 0000000..9415425 --- /dev/null +++ b/src/main/java/Konkuk/U2E/global/openApi/gemini/scheduler/NewsAiScheduler.java @@ -0,0 +1,53 @@ +package Konkuk.U2E.global.openApi.gemini.scheduler; + +import Konkuk.U2E.domain.news.domain.News; +import Konkuk.U2E.domain.news.repository.NewsRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class NewsAiScheduler { + + private final NewsRepository newsRepository; + private final NewsAiProcessor newsAiProcessor; + + @Scheduled(cron = "*/30 * * * * *", zone = "Asia/Seoul") // 테스트용으로 30초마다 확인 +// @Scheduled(cron = "0 30 3 * * WED", zone = "Asia/Seoul") // 매주 수요일 03:30 + public void fillMissingAiFieldsDaily() { + final int PAGE_SIZE = 50; + int page = 0; + + log.info("[NewsAiFill] start"); + + while (true) { + Page slice = newsRepository.findByAiSummaryIsNull(PageRequest.of(page, PAGE_SIZE)); + if (slice.isEmpty()) break; + + slice.forEach(n -> processOneSafely(n.getNewsId())); + + if (!slice.hasNext()) break; + page++; + } + + log.info("[NewsAiFill] done"); + } + + private void processOneSafely(Long newsId) { + try { + newsAiProcessor.processOneTransactional(newsId); + sleep(200L); + } catch (Exception e) { + log.error("[NewsAiFill] failed newsId={} : {}", newsId, e.toString()); + } + } + + private void sleep(long ms) { + try { Thread.sleep(ms); } catch (InterruptedException ignored) {} + } +} diff --git a/src/main/java/Konkuk/U2E/global/openApi/gemini/service/NewsAiService.java b/src/main/java/Konkuk/U2E/global/openApi/gemini/service/NewsAiService.java new file mode 100644 index 0000000..565263a --- /dev/null +++ b/src/main/java/Konkuk/U2E/global/openApi/gemini/service/NewsAiService.java @@ -0,0 +1,132 @@ +package Konkuk.U2E.global.openApi.gemini.service; + +import Konkuk.U2E.global.openApi.gemini.GeminiClient; +import Konkuk.U2E.global.openApi.gemini.dto.request.AiNewsRequest; +import Konkuk.U2E.global.openApi.gemini.dto.response.AiResponse; +import Konkuk.U2E.global.openApi.gemini.dto.response.RegionCandidate; +import Konkuk.U2E.global.openApi.gemini.dto.response.RelatedArticle; +import Konkuk.U2E.global.openApi.gemini.exception.GeminiInvalidResponseException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.util.ArrayList; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class NewsAiService { + + private final GeminiClient geminiClient; + private final ObjectMapper mapper = new ObjectMapper(); + + public AiResponse analyzeAll(AiNewsRequest req) { + String locale = (req.locale() == null || req.locale().isBlank()) ? "ko" : req.locale(); + + String systemPrompt = """ + You are a climate-news assistant. Return ONLY valid JSON exactly like: + { + "summary": "string (3-5 sentences, concise, keep who/what/when/where/why, same language as requested)", + "solution": "string (one practical, actionable measure tailored to the article, 2-4 sentences)", + "related": [ + {"title": "string", "url": "string"}, + {"title": "string", "url": "string"}, + {"title": "string", "url": "string"} + ], + "regions": [ + {"name": "string", "latitude": number, "longitude": number} + ], + "climate_problems": [ + "ENUM_NAME", "ENUM_NAME", "ENUM_NAME" + ] + } + Rules: + - Language: %s + - STRICT: Output MUST be a single valid JSON object. No preface, no extra text, no code fences. + - For "related", prefer reputable sources; it's okay to provide fewer than 3. + - For "regions", include up to 3 places from the article (WGS84 decimals). + - For "climate_problems": choose ALL that apply from THIS EXACT ENUM LIST (use EXACT enum names, not translations): + ["TEMPERATURE_RISE","HEAVY_RAIN_OR_FLOOD","FINE_DUST","DROUGHT_OR_DESERTIFICATION", + "SEA_LEVEL_RISE","TYPHOON_OR_TORNADO","WILDFIRE","EARTHQUAKE","DEFORESTATION","BIODIVERSITY_LOSS"] + If none apply, return an empty array []. + """.formatted(locale); + + String userPrompt = """ + Article body: + --- + %s + --- + Produce the JSON now. + """.formatted(req.body()); + + String jsonText = geminiClient.generateContentJson(systemPrompt, userPrompt); + + try { + JsonNode root = mapper.readTree(jsonText); + + String summary = textOrThrow(root, "summary"); + String solution = textOrThrow(root, "solution"); + + // related + List related = new ArrayList<>(); + JsonNode rel = root.path("related"); + if (rel.isArray()) { + for (int i = 0; i < Math.min(3, rel.size()); i++) { + JsonNode it = rel.get(i); + String title = safeText(it, "title"); + String url = safeText(it, "url"); + if (!title.isBlank() && !url.isBlank()) { + related.add(new RelatedArticle(title, url)); + } + } + } + + // regions + List regions = new ArrayList<>(); + JsonNode regs = root.path("regions"); + if (regs.isArray()) { + for (int i = 0; i < Math.min(3, regs.size()); i++) { + JsonNode it = regs.get(i); + String name = safeText(it, "name"); + if (name.isBlank() || !it.hasNonNull("latitude") || !it.hasNonNull("longitude")) continue; + BigDecimal lat = it.path("latitude").decimalValue(); + BigDecimal lon = it.path("longitude").decimalValue(); + regions.add(new RegionCandidate(name, lat, lon)); + } + } + + // climate_problems + List climateProblems = new ArrayList<>(); + JsonNode cps = root.path("climate_problems"); + if (cps.isArray()) { + for (JsonNode it : cps) { + if (it.isTextual() && !it.asText().isBlank()) { + climateProblems.add(it.asText().trim()); + } + } + } + + return new AiResponse(summary, solution, related, regions, climateProblems); + + } catch (GeminiInvalidResponseException e) { + throw e; + } catch (Exception e) { + throw new GeminiInvalidResponseException("단일 JSON 파싱 실패: " + e.getMessage() + " | text=" + jsonText); + } + } + + private String textOrThrow(JsonNode root, String field) { + JsonNode n = root.path(field); + if (n.isMissingNode() || !n.isTextual() || n.asText().isBlank()) { + throw new GeminiInvalidResponseException(field + " 누락/빈값"); + } + return n.asText(); + } + private String safeText(JsonNode root, String field) { + if (root == null) return ""; + JsonNode n = root.path(field); + return (n.isTextual()) ? n.asText() : ""; + } +} \ No newline at end of file diff --git a/src/main/java/Konkuk/U2E/global/openApi/gemini/service/NewsClimateUpsertService.java b/src/main/java/Konkuk/U2E/global/openApi/gemini/service/NewsClimateUpsertService.java new file mode 100644 index 0000000..409c5f2 --- /dev/null +++ b/src/main/java/Konkuk/U2E/global/openApi/gemini/service/NewsClimateUpsertService.java @@ -0,0 +1,46 @@ +package Konkuk.U2E.global.openApi.gemini.service; + +import Konkuk.U2E.domain.news.domain.Climate; +import Konkuk.U2E.domain.news.domain.ClimateProblem; +import Konkuk.U2E.domain.news.domain.News; +import Konkuk.U2E.domain.news.repository.ClimateRepository; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Set; + +@Service +@RequiredArgsConstructor +public class NewsClimateUpsertService { + + private final ClimateRepository climateRepository; + + @Transactional + public void replaceWithEnums(News news, List enumNames) { + if (news == null) return; + + climateRepository.deleteAllByNews(news); + + if (enumNames == null || enumNames.isEmpty()) return; + + Set unique = new LinkedHashSet<>(); + for (String name : enumNames) { + if (name != null && !name.isBlank()) unique.add(name.trim()); + } + + for (String name : unique) { + try { + ClimateProblem problem = ClimateProblem.valueOf(name); // EXACT name + Climate c = Climate.builder() + .climateProblem(problem) + .news(news) + .build(); + climateRepository.save(c); + } catch (IllegalArgumentException ignored) { + } + } + } +} \ No newline at end of file diff --git a/src/main/java/Konkuk/U2E/global/openApi/gemini/service/NewsRegionUpsertService.java b/src/main/java/Konkuk/U2E/global/openApi/gemini/service/NewsRegionUpsertService.java new file mode 100644 index 0000000..6f06308 --- /dev/null +++ b/src/main/java/Konkuk/U2E/global/openApi/gemini/service/NewsRegionUpsertService.java @@ -0,0 +1,60 @@ +package Konkuk.U2E.global.openApi.gemini.service; + +import Konkuk.U2E.domain.news.domain.News; +import Konkuk.U2E.domain.news.domain.NewsPin; +import Konkuk.U2E.domain.news.repository.NewsPinRepository; +import Konkuk.U2E.domain.pin.domain.Pin; +import Konkuk.U2E.domain.pin.domain.Region; +import Konkuk.U2E.domain.pin.repository.PinRepository; +import Konkuk.U2E.domain.pin.repository.RegionRepository; +import Konkuk.U2E.global.openApi.gemini.dto.response.RegionCandidate; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class NewsRegionUpsertService { + + private final RegionRepository regionRepository; + private final PinRepository pinRepository; + private final NewsPinRepository newsPinRepository; + + @Transactional + public void linkFromCandidates(News news, List candidates) { + if (candidates == null || candidates.isEmpty()) return; + + for (RegionCandidate rc : candidates) { + + Region region = regionRepository + .findByNameIgnoreCase(rc.name()) + .orElseGet(() -> regionRepository.save( + Region.builder() + .name(rc.name()) + .latitude(rc.latitude()) + .longitude(rc.longitude()) + .build() + )); + + Pin pin = pinRepository.findPinByRegion(region); + if (pin == null) { + pin = pinRepository.save(Pin.builder().region(region).build()); + } + + // 이미 연결되어 있으면 skip + final Long targetPinId = pin.getPinId(); + boolean alreadyLinked = newsPinRepository.findPinsByNews(news).stream() + .anyMatch(p -> p.getPinId().equals(targetPinId)); + + if (!alreadyLinked) { + newsPinRepository.save(NewsPin.builder() + .news(news) + .pin(pin) + .build()); + } + } + } + +} diff --git a/src/main/java/Konkuk/U2E/global/response/status/BaseExceptionResponseStatus.java b/src/main/java/Konkuk/U2E/global/response/status/BaseExceptionResponseStatus.java index 78375f1..7611567 100644 --- a/src/main/java/Konkuk/U2E/global/response/status/BaseExceptionResponseStatus.java +++ b/src/main/java/Konkuk/U2E/global/response/status/BaseExceptionResponseStatus.java @@ -32,7 +32,14 @@ public enum BaseExceptionResponseStatus implements ResponseStatus { * 90000 : User */ DUPLICATE_USER(90000, "중복된 아이디 입니다."), - INVALID_ACCESS_TOKEN(90001, "유효하지 않은 액세스 토큰입니다.") + INVALID_ACCESS_TOKEN(90001, "유효하지 않은 액세스 토큰입니다."), + + /** + * 100000 : External API (Gemini) + */ + GEMINI_CALL_FAILED(100000, "Gemini API 호출에 실패했습니다."), + GEMINI_INVALID_RESPONSE(100001, "Gemini 응답 파싱에 실패했습니다."), + GEMINI_MISSING_API_KEY(100002, "Gemini API Key가 설정되지 않았습니다.") ; private final boolean success = false; diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 99d1f72..93d3f5f 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -33,7 +33,7 @@ spring: hibernate: format_sql: true dialect: org.hibernate.dialect.MySQLDialect - + docker: compose: enabled: false @@ -112,3 +112,8 @@ springdoc: swagger-ui: enabled: true path: /swagger-ui/index.html + +gemini: + api-key: ${GEMINI_API_KEY} + model: gemini-2.5-flash + endpoint: https://generativelanguage.googleapis.com/v1beta \ No newline at end of file