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
6 changes: 6 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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'
}
Expand Down
37 changes: 37 additions & 0 deletions src/main/java/Konkuk/U2E/domain/news/domain/News.java
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,29 @@ public class News extends BaseEntity {
@OneToMany(mappedBy = "news", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Comment> 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<Climate> climateList) {
this.newsUrl = newsUrl;
Expand All @@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -13,10 +14,22 @@ public record GetNewsInfoResponse(
String newsUrl,
String newsImageUrl,
String newsBody,
String newsDate
String newsDate,
String aiSolution,
List<RelatedArticle> aiRelated
) {
public static GetNewsInfoResponse of(NewsMappingResult newsMappingResult) {
public static GetNewsInfoResponse of(NewsMappingResult newsMappingResult, String aiSolution, List<RelatedArticle> 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
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,4 +21,6 @@ public interface ClimateRepository extends JpaRepository<Climate, Long> {
"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);
}
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -9,4 +11,6 @@
@Repository
public interface NewsRepository extends JpaRepository<News, Long> {
List<News> findTop5ByOrderByNewsDateDesc();

Page<News> findByAiSummaryIsNull(Pageable pageable);
}
32 changes: 30 additions & 2 deletions src/main/java/Konkuk/U2E/domain/news/service/NewsInfoService.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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<RelatedArticle> related = fromEntity(news);
return GetNewsInfoResponse.of(mapping, news.getAiSolution(), related);
}

private List<RelatedArticle> fromEntity(News n) {
List<RelatedArticle> 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;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,15 @@
import org.springframework.stereotype.Repository;

import java.util.List;
import java.util.Optional;

@Repository
public interface RegionRepository extends JpaRepository<Region, Long> {

//문자열을 포함하는 모든 지역을 찾는 메서드
@Query("SELECT r FROM Region r WHERE LOWER(r.name) LIKE LOWER(CONCAT('%', :region, '%'))")
List<Region> findRegionsByName(String region);

Optional<Region> findByNameIgnoreCase(String name);

}
9 changes: 9 additions & 0 deletions src/main/java/Konkuk/U2E/global/config/SchedulingConfig.java
Original file line number Diff line number Diff line change
@@ -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 {
}
170 changes: 170 additions & 0 deletions src/main/java/Konkuk/U2E/global/openApi/gemini/GeminiClient.java
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package Konkuk.U2E.global.openApi.gemini.dto.request;

public record AiNewsRequest(
String body, // 분석할 뉴스 본문
String locale // "ko" or "en" (옵션)
) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package Konkuk.U2E.global.openApi.gemini.dto.response;

import java.util.List;

public record AiResponse(
String summary, // 기사 요약
String solution, // 실행 가능한 핵심 솔루션
List<RelatedArticle> related, // 관련 뉴스 (최대 3)
List<RegionCandidate> regions , // 지역 후보 (최대 3, 이름+위도+경도)
List<String> climateProblems // 기후문제 enum 이름 리스트 (예: ["WILDFIRE","HEAVY_RAIN_OR_FLOOD"])
) {
}
Original file line number Diff line number Diff line change
@@ -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
) {}
Loading
Loading