Skip to content
Open
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
15 changes: 15 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -19,11 +19,22 @@ repositories {
mavenCentral()
}

extra["springCloudVersion"] = "2023.0.2"

dependencyManagement {
imports {
mavenBom("org.springframework.cloud:spring-cloud-dependencies:${property("springCloudVersion")}")
}
}

dependencies {
implementation(platform("org.springframework.ai:spring-ai-bom:1.1.2"))
implementation("org.springframework.boot:spring-boot-starter-data-jpa")
implementation("org.springframework.boot:spring-boot-starter-thymeleaf")
implementation("org.springframework.boot:spring-boot-starter-validation")
implementation("org.springframework.boot:spring-boot-starter-web")
implementation("org.springframework.cloud:spring-cloud-starter-openfeign")
implementation("org.springframework.ai:spring-ai-starter-model-google-genai")
implementation("com.fasterxml.jackson.module:jackson-module-kotlin")
implementation("org.flywaydb:flyway-core")
implementation("org.flywaydb:flyway-mysql")
Expand All @@ -33,6 +44,10 @@ dependencies {
testImplementation("org.springframework.boot:spring-boot-starter-test")
testImplementation("org.jetbrains.kotlin:kotlin-test-junit5")
testRuntimeOnly("org.junit.platform:junit-platform-launcher")

compileOnly("org.projectlombok:lombok")
annotationProcessor("org.projectlombok:lombok")

}

kotlin {
Expand Down
9 changes: 9 additions & 0 deletions src/main/java/sunshine/Application.java
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,19 @@

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.openfeign.EnableFeignClients;
import org.springframework.context.annotation.Bean;
import org.springframework.web.client.RestTemplate;

@EnableFeignClients
@SpringBootApplication
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}

@Bean
public RestTemplate restTemplate() {
return new RestTemplate();
}
}
40 changes: 40 additions & 0 deletions src/main/java/sunshine/application/WeatherService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package sunshine.application;

import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.stereotype.Service;
import sunshine.domain.ClothingRecommendationService;
import sunshine.domain.GeocodingResponse;
import sunshine.domain.GeocodingService;
import sunshine.domain.WeatherDomainService;
import sunshine.domain.WeatherResponseDto;

@Slf4j
@Service
@RequiredArgsConstructor
public class WeatherService {

private final GeocodingService geocodingService;
private final WeatherDomainService weatherDomainService;
private final ClothingRecommendationService clothingRecommendationService;

public ChatResponse getWeather(String cityName) {
// 1. 권역 조회
GeocodingResponse geocode = geocodingService.geocode(cityName);

// 2. 날씨 조회
WeatherResponseDto weather = weatherDomainService.getWeather(geocode);

// 3. 복장 추천 (llm)
ChatResponse response = clothingRecommendationService.recommendClothing(weather);

// 토큰 사용량 로그 출력
log.info("Gemini API 토큰 사용량 - 입력 토큰: {}, 출력 토큰: {}, 총 토큰: {}",
response.getMetadata().getUsage().getPromptTokens(),
response.getMetadata().getUsage().getCompletionTokens(),
response.getMetadata().getUsage().getTotalTokens());

return response;
}
}
42 changes: 42 additions & 0 deletions src/main/java/sunshine/domain/City.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
package sunshine.domain;

import lombok.Getter;

@Getter
public enum City {

Seoul("서울", "37.5665", "126.9780"),
Tokyo("도쿄", "35.6762", "139.6503"),
NewYork("뉴욕", "40.7128", "-74.0060"),
Paris("파리", "48.8566", "2.3522"),
London("런던", "51.5074", "-0.1278");

private final String cityKorName;
private final String latitude;
private final String longitude;

City(String cityKorName, String latitude, String longitude) {
this.cityKorName = cityKorName;
this.latitude = latitude;
this.longitude = longitude;
}

public static City getCityInfo(String cityName) {
for (City city : City.values()) {
if (city.name().equalsIgnoreCase(cityName)) {
return city;
}
}
throw new IllegalArgumentException("Invalid city name: " + cityName);
}

public static String getCityKorName(String cityName) {
for (City city : City.values()) {
if (city.name().equalsIgnoreCase(cityName)) {
return city.cityKorName;
}
}
throw new IllegalArgumentException("Invalid city name: " + cityName);
}

}
56 changes: 56 additions & 0 deletions src/main/java/sunshine/domain/ClothingRecommendationService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
package sunshine.domain;

import java.util.Map;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.chat.prompt.SystemPromptTemplate;
import org.springframework.stereotype.Service;

@Service
public class ClothingRecommendationService {

private final ChatClient client;

public ClothingRecommendationService(ChatClient.Builder builder) {
this.client = builder.build();
}

public ChatResponse recommendClothing(WeatherResponseDto weather) {
var systemPrompt = new SystemPromptTemplate("""
You are a helpful fashion advisor AI assistant.
You provide clothing recommendations based on weather conditions.
Your name is {name}.
You should reply in Korean and be friendly and practical.
""");
var system = systemPrompt.createMessage(Map.of("name", "StyleHelper"));

var userMessage = new UserMessage(String.format("""
현재 날씨 정보를 바탕으로 어떤 옷을 입으면 좋을지 추천해주세요.

- 기온: %s°C
- 체감온도: %s°C
- 습도: %s%%
- 날씨 상태: %s
- 강수량: %smm
- 비: %smm
- 눈: %smm

위 날씨 정보를 고려하여 상의, 하의, 신발, 필요한 액세서리를 추천해주세요.
""",
weather.getCurrent().getTemperature2m(),
weather.getCurrent().getApparentTemperature(),
weather.getCurrent().getRelativeHumidity2m(),
WeatherCode.getDescription(weather.getCurrent().getWeatherCode()),
weather.getCurrent().getPrecipitation(),
weather.getCurrent().getRain(),
weather.getCurrent().getSnowfall()
));
var prompt = new Prompt(userMessage, system);

return client.prompt(prompt)
.call()
.chatResponse();
}
}
12 changes: 12 additions & 0 deletions src/main/java/sunshine/domain/GeocodingResponse.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package sunshine.domain;

import lombok.Builder;
import lombok.Getter;

@Getter
@Builder
public class GeocodingResponse {
private double lat;
private double lon;
private String displayName;
}
78 changes: 78 additions & 0 deletions src/main/java/sunshine/domain/GeocodingService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package sunshine.domain;

import com.fasterxml.jackson.databind.JsonNode;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpEntity;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpMethod;
import org.springframework.stereotype.Service;
import org.springframework.web.client.HttpClientErrorException;
import org.springframework.web.client.HttpServerErrorException;
import org.springframework.web.client.ResourceAccessException;
import org.springframework.web.client.RestClientException;
import org.springframework.web.client.RestTemplate;
import sunshine.domain.exception.ExternalApiException;
import sunshine.domain.exception.InvalidLocationException;

@Service
@RequiredArgsConstructor
public class GeocodingService {
private final RestTemplate restTemplate;

public GeocodingResponse geocode(String location) {
if (location == null || location.trim().isEmpty()) {
throw new InvalidLocationException("지역명이 비어있습니다.");
}

String url = "https://nominatim.openstreetmap.org/search?q=" + location + "&format=json&limit=1";

try {
HttpHeaders headers = new HttpHeaders();
headers.set("User-Agent", "Spring-Sunshine-Weather-App");
HttpEntity<String> entity = new HttpEntity<>(headers);

JsonNode[] response = restTemplate.exchange(
url,
HttpMethod.GET,
entity,
JsonNode[].class
).getBody();

// 응답이 null이거나 비어있는 경우
if (response == null || response.length == 0) {
throw new InvalidLocationException("지역을 찾을 수 없습니다: " + location);
}

JsonNode firstResult = response[0];

// 필수 필드 검증
if (!firstResult.has("lat") || !firstResult.has("lon") || !firstResult.has("display_name")) {
throw new ExternalApiException("Geocoding API 응답에 필수 필드가 없습니다.");
}

double lat = firstResult.get("lat").asDouble();
double lon = firstResult.get("lon").asDouble();
String displayName = firstResult.get("display_name").asText();

return new GeocodingResponse(lat, lon, displayName);

} catch (HttpClientErrorException e) {
// 4xx 에러: 클라이언트 오류 (재시도 불필요)
throw new ExternalApiException("Geocoding API 클라이언트 오류: " + e.getStatusCode(), e);
} catch (HttpServerErrorException e) {
// 5xx 에러: 서버 오류 (재시도 가능)
throw new ExternalApiException("Geocoding API 서버 오류: " + e.getStatusCode(), e);
} catch (ResourceAccessException e) {
// 네트워크 오류, 타임아웃
throw new ExternalApiException("Geocoding API 네트워크 오류 또는 타임아웃", e);
} catch (RestClientException e) {
// 기타 RestTemplate 예외
throw new ExternalApiException("Geocoding API 호출 실패", e);
} catch (Exception e) {
// JSON 파싱 오류 등 기타 예외
throw new ExternalApiException("Geocoding API 응답 처리 실패", e);
}
}
}


54 changes: 54 additions & 0 deletions src/main/java/sunshine/domain/WeatherCode.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
package sunshine.domain;

public enum WeatherCode {
CLEAR_SKY(0, "맑음"),
MAINLY_CLEAR(1, "대체로 맑음"),
PARTLY_CLOUDY(2, "부분적으로 흐림"),
OVERCAST(3, "흐림"),
FOG(45, "안개"),
DEPOSITING_RIME_FOG(48, "서리 안개"),
DRIZZLE_LIGHT(51, "가벼운 이슬비"),
DRIZZLE_MODERATE(53, "이슬비"),
DRIZZLE_DENSE(55, "강한 이슬비"),
FREEZING_DRIZZLE_LIGHT(56, "가벼운 어는 이슬비"),
FREEZING_DRIZZLE_DENSE(57, "강한 어는 이슬비"),
RAIN_SLIGHT(61, "약한 비"),
RAIN_MODERATE(63, "비"),
RAIN_HEAVY(65, "강한 비"),
FREEZING_RAIN_LIGHT(66, "약한 어는 비"),
FREEZING_RAIN_HEAVY(67, "강한 어는 비"),
SNOW_SLIGHT(71, "약한 눈"),
SNOW_MODERATE(73, "눈"),
SNOW_HEAVY(75, "강한 눈"),
SNOW_GRAINS(77, "눈알갱이"),
RAIN_SHOWERS_SLIGHT(80, "약한 소나기"),
RAIN_SHOWERS_MODERATE(81, "소나기"),
RAIN_SHOWERS_VIOLENT(82, "강한 소나기"),
SNOW_SHOWERS_SLIGHT(85, "약한 눈 소나기"),
SNOW_SHOWERS_HEAVY(86, "강한 눈 소나기"),
THUNDERSTORM(95, "천둥번개"),
THUNDERSTORM_SLIGHT_HAIL(96, "약한 우박을 동반한 천둥번개"),
THUNDERSTORM_HEAVY_HAIL(99, "강한 우박을 동반한 천둥번개");

private final int code;
private final String description;

WeatherCode(int code, String description) {
this.code = code;
this.description = description;
}

public static String getDescription(String codeStr) {
try {
int code = Integer.parseInt(codeStr);
for (WeatherCode weatherCode : WeatherCode.values()) {
if (weatherCode.code == code) {
return weatherCode.description;
}
}
return "알 수 없는 날씨";
} catch (NumberFormatException e) {
return "알 수 없는 날씨";
}
}
}
7 changes: 7 additions & 0 deletions src/main/java/sunshine/domain/WeatherDomainClient.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package sunshine.domain;

public interface WeatherDomainClient {

WeatherResponseDto getWeather(double latitude, double longitude, String current);

}
16 changes: 16 additions & 0 deletions src/main/java/sunshine/domain/WeatherDomainService.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package sunshine.domain;

import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;

@Component
@RequiredArgsConstructor
public class WeatherDomainService {

private final WeatherDomainClient weatherDomainClient;

public WeatherResponseDto getWeather(GeocodingResponse geo) {
String current = "temperature_2m,relative_humidity_2m,apparent_temperature,precipitation,rain,showers,snowfall,weather_code";
return weatherDomainClient.getWeather(geo.getLat(), geo.getLon(), current);
}
}
Loading