diff --git a/src/main/java/in/koreatech/koin/domain/weather/client/WeatherClient.java b/src/main/java/in/koreatech/koin/domain/weather/client/WeatherClient.java new file mode 100644 index 000000000..35e8b0847 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/weather/client/WeatherClient.java @@ -0,0 +1,219 @@ +package in.koreatech.koin.domain.weather.client; + +import static java.net.URLEncoder.encode; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.net.URL; +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Component; +import org.springframework.web.client.HttpStatusCodeException; +import org.springframework.web.client.RestTemplate; + +import in.koreatech.koin.domain.weather.dto.WeatherApiResponse; +import in.koreatech.koin.domain.weather.dto.WeatherApiResponse.WeatherForecastItem; +import in.koreatech.koin.domain.weather.exception.WeatherOpenApiException; +import in.koreatech.koin.domain.weather.model.WeatherForecast; +import in.koreatech.koin.domain.weather.model.WeatherForecastRequestTime; +import in.koreatech.koin.global.exception.custom.KoinIllegalStateException; + +@Component +public class WeatherClient { + + private static final String OPEN_API_URL = + "https://apis.data.go.kr/1360000/VilageFcstInfoService_2.0/getVilageFcst"; + private static final String ENCODE_TYPE = "UTF-8"; + private static final int BYEONGCHEON_NX = 66; + private static final int BYEONGCHEON_NY = 109; + private static final int ROW_COUNT = 1000; + + private final String openApiKey; + private final RestTemplate restTemplate; + private final ObjectMapper objectMapper; + + public WeatherClient( + @Value("${OPEN_API_KEY_PUBLIC}") String openApiKey, + RestTemplate restTemplate, + ObjectMapper objectMapper + ) { + this.openApiKey = openApiKey; + this.restTemplate = restTemplate; + this.objectMapper = objectMapper; + } + + public WeatherForecast getWeatherForecast(WeatherForecastRequestTime requestTime) { + try { + return getWeatherForecastWithoutFallback(requestTime); + } catch (WeatherOpenApiException e) { + if (!canRetryWithPreviousBaseTime(e)) { + throw e; + } + return getWeatherForecastWithoutFallback(requestTime.previousBaseTime()); + } + } + + private WeatherForecast getWeatherForecastWithoutFallback(WeatherForecastRequestTime requestTime) { + WeatherApiResponse response = getOpenApiResponse(requestTime); + List items = extractForecastItems(response); + Map forecasts = items.stream() + .filter(item -> requestTime.forecastDate().equals(item.fcstDate())) + .filter(item -> requestTime.forecastTime().equals(item.fcstTime())) + .collect(Collectors.toMap( + WeatherForecastItem::category, + WeatherForecastItem::fcstValue, + (previous, current) -> current + )); + + if (forecasts.isEmpty()) { + throw WeatherOpenApiException.withDetail("forecastDateTime: " + + requestTime.forecastDate() + requestTime.forecastTime()); + } + + String temperature = requireForecastValue(forecasts, "TMP"); + String sky = requireForecastValue(forecasts, "SKY"); + String precipitationType = requireForecastValue(forecasts, "PTY"); + + try { + return new WeatherForecast( + Integer.parseInt(temperature), + sky, + precipitationType + ); + } catch (NumberFormatException e) { + throw WeatherOpenApiException.withDetail( + "invalid category: TMP, value: " + temperature + ); + } + } + + private String requireForecastValue(Map forecasts, String category) { + String forecastValue = forecasts.get(category); + if (forecastValue == null || forecastValue.isBlank()) { + throw WeatherOpenApiException.withDetail( + "missing category: " + category + ); + } + return forecastValue; + } + + private boolean canRetryWithPreviousBaseTime(WeatherOpenApiException e) { + String errorMessage = e.getFullMessage(); + return errorMessage.contains("NO_DATA") + || errorMessage.contains("response body is empty") + || errorMessage.contains("forecastDateTime"); + } + + private WeatherApiResponse getOpenApiResponse(WeatherForecastRequestTime requestTime) { + try { + HttpHeaders headers = new HttpHeaders(); + headers.set("Accept", "*/*"); + + HttpEntity entity = new HttpEntity<>(headers); + URL url = new URL(getRequestURL(requestTime)); + ResponseEntity response = restTemplate.exchange( + url.toURI(), + HttpMethod.GET, + entity, + String.class + ); + return parseResponse(response.getBody(), requestTime); + } catch (WeatherOpenApiException e) { + throw e; + } catch (HttpStatusCodeException e) { + String responseBody = e.getResponseBodyAsString(); + if (responseBody != null && !responseBody.isBlank()) { + return parseResponse(responseBody, requestTime); + } + throw WeatherOpenApiException.withDetail("baseDateTime: " + + requestTime.baseDate() + requestTime.baseTime() + ", httpStatus: " + e.getStatusCode()); + } catch (Exception e) { + throw WeatherOpenApiException.withDetail("baseDateTime: " + + requestTime.baseDate() + requestTime.baseTime() + ", cause: " + e.getClass().getSimpleName()); + } + } + + private WeatherApiResponse parseResponse(String responseBody, WeatherForecastRequestTime requestTime) { + if (responseBody == null || responseBody.isBlank()) { + throw WeatherOpenApiException.withDetail("baseDateTime: " + + requestTime.baseDate() + requestTime.baseTime() + ", response body is empty"); + } + + if (!responseBody.trim().startsWith("{")) { + throw WeatherOpenApiException.withDetail("baseDateTime: " + + requestTime.baseDate() + requestTime.baseTime() + ", " + extractXmlErrorMessage(responseBody)); + } + + try { + return objectMapper.readValue(responseBody, WeatherApiResponse.class); + } catch (Exception e) { + throw WeatherOpenApiException.withDetail("baseDateTime: " + + requestTime.baseDate() + requestTime.baseTime() + ", invalid JSON response"); + } + } + + private String getRequestURL(WeatherForecastRequestTime requestTime) { + StringBuilder urlBuilder = new StringBuilder(OPEN_API_URL); + try { + urlBuilder.append("?" + encode("serviceKey", ENCODE_TYPE) + "=" + encode(openApiKey, ENCODE_TYPE)); + urlBuilder.append("&" + encode("numOfRows", ENCODE_TYPE) + "=" + + encode(String.valueOf(ROW_COUNT), ENCODE_TYPE)); + urlBuilder.append("&" + encode("pageNo", ENCODE_TYPE) + "=" + encode("1", ENCODE_TYPE)); + urlBuilder.append("&" + encode("dataType", ENCODE_TYPE) + "=" + encode("JSON", ENCODE_TYPE)); + urlBuilder.append("&" + encode("base_date", ENCODE_TYPE) + "=" + + encode(requestTime.baseDate(), ENCODE_TYPE)); + urlBuilder.append("&" + encode("base_time", ENCODE_TYPE) + "=" + + encode(requestTime.baseTime(), ENCODE_TYPE)); + urlBuilder.append("&" + encode("nx", ENCODE_TYPE) + "=" + encode(String.valueOf(BYEONGCHEON_NX), ENCODE_TYPE)); + urlBuilder.append("&" + encode("ny", ENCODE_TYPE) + "=" + encode(String.valueOf(BYEONGCHEON_NY), ENCODE_TYPE)); + return urlBuilder.toString(); + } catch (Exception e) { + throw new KoinIllegalStateException("기상청 단기예보 API URL 생성중 문제가 발생했습니다.", "uri build failed"); + } + } + + private String extractXmlErrorMessage(String responseBody) { + String returnAuthMsg = extractTagValue(responseBody, "returnAuthMsg"); + String returnReasonCode = extractTagValue(responseBody, "returnReasonCode"); + if (returnAuthMsg != null || returnReasonCode != null) { + return "returnAuthMsg: " + returnAuthMsg + ", returnReasonCode: " + returnReasonCode; + } + return "non JSON response"; + } + + private String extractTagValue(String responseBody, String tagName) { + String startTag = "<" + tagName + ">"; + String endTag = ""; + int startIndex = responseBody.indexOf(startTag); + int endIndex = responseBody.indexOf(endTag); + if (startIndex == -1 || endIndex == -1 || startIndex > endIndex) { + return null; + } + return responseBody.substring(startIndex + startTag.length(), endIndex); + } + + private List extractForecastItems(WeatherApiResponse response) { + if (response == null + || response.response() == null + || response.response().header() == null + || response.response().body() == null + || response.response().body().items() == null + || response.response().body().items().item() == null) { + throw WeatherOpenApiException.withDetail("response body is empty"); + } + + String resultCode = response.response().header().resultCode(); + if (!"00".equals(resultCode) && !"0".equals(resultCode)) { + throw WeatherOpenApiException.withDetail( + "resultCode: " + resultCode + ", resultMsg: " + response.response().header().resultMsg() + ); + } + return response.response().body().items().item(); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/weather/controller/WeatherApi.java b/src/main/java/in/koreatech/koin/domain/weather/controller/WeatherApi.java new file mode 100644 index 000000000..daa788e52 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/weather/controller/WeatherApi.java @@ -0,0 +1,35 @@ +package in.koreatech.koin.domain.weather.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; + +import in.koreatech.koin.domain.weather.dto.WeatherResponse; +import io.swagger.v3.oas.annotations.Operation; +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; + +@Tag(name = "(Normal) Weather: 날씨", description = "병천 날씨 정보를 조회한다") +public interface WeatherApi { + + @ApiResponses( + value = { + @ApiResponse(responseCode = "200", content = @Content(mediaType = "application/json", examples = + @ExampleObject( + value = """ + { + "temperature": 21, + "weather": "맑음" + } + """ + ))), + @ApiResponse(responseCode = "500", content = @Content(schema = @Schema(hidden = true))), + } + ) + @Operation(summary = "병천 날씨 조회") + @GetMapping("/weather") + ResponseEntity getWeather(); +} diff --git a/src/main/java/in/koreatech/koin/domain/weather/controller/WeatherController.java b/src/main/java/in/koreatech/koin/domain/weather/controller/WeatherController.java new file mode 100644 index 000000000..8e7e35216 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/weather/controller/WeatherController.java @@ -0,0 +1,22 @@ +package in.koreatech.koin.domain.weather.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +import in.koreatech.koin.domain.weather.dto.WeatherResponse; +import in.koreatech.koin.domain.weather.service.WeatherService; +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +public class WeatherController implements WeatherApi { + + private final WeatherService weatherService; + + @GetMapping("/weather") + public ResponseEntity getWeather() { + WeatherResponse response = weatherService.getWeather(); + return ResponseEntity.ok(response); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/weather/dto/WeatherApiResponse.java b/src/main/java/in/koreatech/koin/domain/weather/dto/WeatherApiResponse.java new file mode 100644 index 000000000..8dee837a5 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/weather/dto/WeatherApiResponse.java @@ -0,0 +1,51 @@ +package in.koreatech.koin.domain.weather.dto; + +import java.util.List; + +public record WeatherApiResponse( + InnerResponse response +) { + + public record InnerResponse( + InnerHeader header, + InnerBody body + ) { + + } + + public record InnerHeader( + String resultCode, + String resultMsg + ) { + + } + + public record InnerBody( + String dataType, + InnerItems items, + Integer numOfRows, + Integer pageNo, + Integer totalCount + ) { + + } + + public record InnerItems( + List item + ) { + + } + + public record WeatherForecastItem( + String baseDate, + String baseTime, + String category, + String fcstDate, + String fcstTime, + String fcstValue, + Integer nx, + Integer ny + ) { + + } +} diff --git a/src/main/java/in/koreatech/koin/domain/weather/dto/WeatherResponse.java b/src/main/java/in/koreatech/koin/domain/weather/dto/WeatherResponse.java new file mode 100644 index 000000000..71eff90f6 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/weather/dto/WeatherResponse.java @@ -0,0 +1,23 @@ +package in.koreatech.koin.domain.weather.dto; + +import static com.fasterxml.jackson.databind.PropertyNamingStrategies.SnakeCaseStrategy; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import com.fasterxml.jackson.databind.annotation.JsonNaming; + +import in.koreatech.koin.domain.weather.model.WeatherCondition; +import io.swagger.v3.oas.annotations.media.Schema; + +@JsonNaming(SnakeCaseStrategy.class) +public record WeatherResponse( + @Schema(description = "기온(섭씨)", example = "21", requiredMode = REQUIRED) + Integer temperature, + + @Schema(description = "날씨 상태", example = "맑음", requiredMode = REQUIRED) + String weather +) { + + public static WeatherResponse of(Integer temperature, WeatherCondition weatherCondition) { + return new WeatherResponse(temperature, weatherCondition.getValue()); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/weather/exception/WeatherOpenApiException.java b/src/main/java/in/koreatech/koin/domain/weather/exception/WeatherOpenApiException.java new file mode 100644 index 000000000..9ce59aef3 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/weather/exception/WeatherOpenApiException.java @@ -0,0 +1,20 @@ +package in.koreatech.koin.domain.weather.exception; + +import in.koreatech.koin.global.exception.custom.ExternalServiceException; + +public class WeatherOpenApiException extends ExternalServiceException { + + private static final String DEFAULT_MESSAGE = "기상청 단기예보 API 응답이 정상적이지 않습니다."; + + public WeatherOpenApiException(String message) { + super(message); + } + + public WeatherOpenApiException(String message, String detail) { + super(message, detail); + } + + public static WeatherOpenApiException withDetail(String detail) { + return new WeatherOpenApiException(DEFAULT_MESSAGE, detail); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/weather/model/WeatherCache.java b/src/main/java/in/koreatech/koin/domain/weather/model/WeatherCache.java new file mode 100644 index 000000000..847d3e985 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/weather/model/WeatherCache.java @@ -0,0 +1,49 @@ +package in.koreatech.koin.domain.weather.model; + +import java.util.concurrent.TimeUnit; + +import org.springframework.data.annotation.Id; +import org.springframework.data.redis.core.RedisHash; +import org.springframework.data.redis.core.TimeToLive; + +import in.koreatech.koin.domain.weather.dto.WeatherResponse; +import lombok.Builder; +import lombok.Getter; + +@Getter +@RedisHash("Weather") +public class WeatherCache { + + public static final String BYEONGCHEON_ID = "byeongcheon"; + private static final long CACHE_EXPIRE_HOUR = 2L; + + @Id + private String id; + + private WeatherForecastRequestTime requestTime; + private WeatherResponse weather; + + @TimeToLive(unit = TimeUnit.HOURS) + private final Long expiration; + + @Builder + private WeatherCache( + String id, + WeatherForecastRequestTime requestTime, + WeatherResponse weather, + Long expiration + ) { + this.id = id; + this.requestTime = requestTime; + this.weather = weather; + this.expiration = expiration == null ? CACHE_EXPIRE_HOUR : expiration; + } + + public static WeatherCache of(WeatherForecastRequestTime requestTime, WeatherResponse weather) { + return WeatherCache.builder() + .id(BYEONGCHEON_ID) + .requestTime(requestTime) + .weather(weather) + .build(); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/weather/model/WeatherCondition.java b/src/main/java/in/koreatech/koin/domain/weather/model/WeatherCondition.java new file mode 100644 index 000000000..89a35cb81 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/weather/model/WeatherCondition.java @@ -0,0 +1,42 @@ +package in.koreatech.koin.domain.weather.model; + +import java.util.Map; + +import lombok.Getter; + +@Getter +public enum WeatherCondition { + SUNNY("맑음"), + CLOUDY("구름많음"), + OVERCAST("흐림"), + RAIN("비"), + RAIN_AND_SNOW("비/눈"), + SNOW("눈"), + SHOWER("소나기"); + + private static final Map SKY_CONDITIONS = Map.of( + "1", SUNNY, + "3", CLOUDY, + "4", OVERCAST + ); + + private static final Map PRECIPITATION_CONDITIONS = Map.of( + "1", RAIN, + "2", RAIN_AND_SNOW, + "3", SNOW, + "4", SHOWER + ); + + private final String value; + + WeatherCondition(String value) { + this.value = value; + } + + public static WeatherCondition from(String sky, String precipitationType) { + if (precipitationType != null && !precipitationType.equals("0")) { + return PRECIPITATION_CONDITIONS.getOrDefault(precipitationType, RAIN); + } + return SKY_CONDITIONS.getOrDefault(sky, OVERCAST); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/weather/model/WeatherForecast.java b/src/main/java/in/koreatech/koin/domain/weather/model/WeatherForecast.java new file mode 100644 index 000000000..f1271ea50 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/weather/model/WeatherForecast.java @@ -0,0 +1,17 @@ +package in.koreatech.koin.domain.weather.model; + +import in.koreatech.koin.domain.weather.dto.WeatherResponse; + +public record WeatherForecast( + Integer temperature, + String sky, + String precipitationType +) { + + public WeatherResponse toResponse() { + return WeatherResponse.of( + temperature, + WeatherCondition.from(sky, precipitationType) + ); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/weather/model/WeatherForecastRequestTime.java b/src/main/java/in/koreatech/koin/domain/weather/model/WeatherForecastRequestTime.java new file mode 100644 index 000000000..dd1e0c57c --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/weather/model/WeatherForecastRequestTime.java @@ -0,0 +1,72 @@ +package in.koreatech.koin.domain.weather.model; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.format.DateTimeFormatter; +import java.util.List; + +public record WeatherForecastRequestTime( + String baseDate, + String baseTime, + String forecastDate, + String forecastTime +) { + + private static final DateTimeFormatter DATE_FORMATTER = DateTimeFormatter.ofPattern("yyyyMMdd"); + private static final DateTimeFormatter TIME_FORMATTER = DateTimeFormatter.ofPattern("HHmm"); + private static final List BASE_TIMES = List.of( + LocalTime.of(2, 0), + LocalTime.of(5, 0), + LocalTime.of(8, 0), + LocalTime.of(11, 0), + LocalTime.of(14, 0), + LocalTime.of(17, 0), + LocalTime.of(20, 0), + LocalTime.of(23, 0) + ); + + public static WeatherForecastRequestTime from(LocalDateTime now) { + LocalDateTime availableDateTime = now.minusMinutes(10); + LocalDate baseDate = availableDateTime.toLocalDate(); + LocalTime availableTime = availableDateTime.toLocalTime(); + LocalTime baseTime = null; + for (LocalTime time : BASE_TIMES) { + if (!time.isAfter(availableTime)) { + baseTime = time; + } + } + if (baseTime == null) { + baseDate = baseDate.minusDays(1); + baseTime = BASE_TIMES.get(BASE_TIMES.size() - 1); + } + + LocalDateTime forecastDateTime = now.withMinute(0).withSecond(0).withNano(0); + + return new WeatherForecastRequestTime( + baseDate.format(DATE_FORMATTER), + baseTime.format(TIME_FORMATTER), + forecastDateTime.toLocalDate().format(DATE_FORMATTER), + forecastDateTime.toLocalTime().format(TIME_FORMATTER) + ); + } + + public WeatherForecastRequestTime previousBaseTime() { + LocalDate baseLocalDate = LocalDate.parse(baseDate, DATE_FORMATTER); + LocalTime baseLocalTime = LocalTime.parse(baseTime, TIME_FORMATTER); + int baseTimeIndex = BASE_TIMES.indexOf(baseLocalTime); + if (baseTimeIndex <= 0) { + baseLocalDate = baseLocalDate.minusDays(1); + baseLocalTime = BASE_TIMES.get(BASE_TIMES.size() - 1); + } else { + baseLocalTime = BASE_TIMES.get(baseTimeIndex - 1); + } + + return new WeatherForecastRequestTime( + baseLocalDate.format(DATE_FORMATTER), + baseLocalTime.format(TIME_FORMATTER), + forecastDate, + forecastTime + ); + } +} diff --git a/src/main/java/in/koreatech/koin/domain/weather/repository/WeatherCacheRepository.java b/src/main/java/in/koreatech/koin/domain/weather/repository/WeatherCacheRepository.java new file mode 100644 index 000000000..7405f43bb --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/weather/repository/WeatherCacheRepository.java @@ -0,0 +1,14 @@ +package in.koreatech.koin.domain.weather.repository; + +import java.util.Optional; + +import org.springframework.data.repository.Repository; + +import in.koreatech.koin.domain.weather.model.WeatherCache; + +public interface WeatherCacheRepository extends Repository { + + WeatherCache save(WeatherCache weatherCache); + + Optional findById(String id); +} diff --git a/src/main/java/in/koreatech/koin/domain/weather/scheduler/WeatherScheduler.java b/src/main/java/in/koreatech/koin/domain/weather/scheduler/WeatherScheduler.java new file mode 100644 index 000000000..18d2fb520 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/weather/scheduler/WeatherScheduler.java @@ -0,0 +1,32 @@ +package in.koreatech.koin.domain.weather.scheduler; + +import org.springframework.boot.context.event.ApplicationReadyEvent; +import org.springframework.context.event.EventListener; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import in.koreatech.koin.domain.weather.service.WeatherService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Component +@RequiredArgsConstructor +public class WeatherScheduler { + + private final WeatherService weatherService; + + @EventListener(ApplicationReadyEvent.class) + public void refreshWeatherOnStartup() { + refreshWeather(); + } + + @Scheduled(cron = "0 0 * * * *") + public void refreshWeather() { + try { + weatherService.refreshWeather(); + } catch (Exception e) { + log.warn("날씨 스케줄링 과정에서 오류가 발생했습니다.", e); + } + } +} diff --git a/src/main/java/in/koreatech/koin/domain/weather/service/WeatherService.java b/src/main/java/in/koreatech/koin/domain/weather/service/WeatherService.java new file mode 100644 index 000000000..f690f79b6 --- /dev/null +++ b/src/main/java/in/koreatech/koin/domain/weather/service/WeatherService.java @@ -0,0 +1,36 @@ +package in.koreatech.koin.domain.weather.service; + +import java.time.Clock; +import java.time.LocalDateTime; + +import org.springframework.stereotype.Service; + +import in.koreatech.koin.domain.weather.client.WeatherClient; +import in.koreatech.koin.domain.weather.dto.WeatherResponse; +import in.koreatech.koin.domain.weather.exception.WeatherOpenApiException; +import in.koreatech.koin.domain.weather.model.WeatherCache; +import in.koreatech.koin.domain.weather.model.WeatherForecastRequestTime; +import in.koreatech.koin.domain.weather.repository.WeatherCacheRepository; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class WeatherService { + + private final Clock clock; + private final WeatherClient weatherClient; + private final WeatherCacheRepository weatherCacheRepository; + + public WeatherResponse getWeather() { + return weatherCacheRepository.findById(WeatherCache.BYEONGCHEON_ID) + .map(WeatherCache::getWeather) + .orElseThrow(() -> WeatherOpenApiException.withDetail("weather cache is empty")); + } + + public synchronized void refreshWeather() { + LocalDateTime now = LocalDateTime.now(clock); + WeatherForecastRequestTime requestTime = WeatherForecastRequestTime.from(now); + WeatherResponse response = weatherClient.getWeatherForecast(requestTime).toResponse(); + weatherCacheRepository.save(WeatherCache.of(requestTime, response)); + } +} diff --git a/src/main/java/in/koreatech/koin/global/config/SwaggerGroupConfig.java b/src/main/java/in/koreatech/koin/global/config/SwaggerGroupConfig.java index cb6956325..01916dbea 100644 --- a/src/main/java/in/koreatech/koin/global/config/SwaggerGroupConfig.java +++ b/src/main/java/in/koreatech/koin/global/config/SwaggerGroupConfig.java @@ -57,7 +57,8 @@ public GroupedOpenApi campusApi() { "in.koreatech.koin.domain.dining", "in.koreatech.koin.domain.banner", "in.koreatech.koin.domain.club", - "in.koreatech.koin.domain.callvan" + "in.koreatech.koin.domain.callvan", + "in.koreatech.koin.domain.weather" }); } diff --git a/src/test/java/in/koreatech/koin/acceptance/domain/WeatherApiTest.java b/src/test/java/in/koreatech/koin/acceptance/domain/WeatherApiTest.java new file mode 100644 index 000000000..dc912824c --- /dev/null +++ b/src/test/java/in/koreatech/koin/acceptance/domain/WeatherApiTest.java @@ -0,0 +1,42 @@ +package in.koreatech.koin.acceptance.domain; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.MediaType; + +import in.koreatech.koin.acceptance.AcceptanceTest; +import in.koreatech.koin.domain.weather.dto.WeatherResponse; +import in.koreatech.koin.domain.weather.model.WeatherCache; +import in.koreatech.koin.domain.weather.model.WeatherForecastRequestTime; +import in.koreatech.koin.domain.weather.repository.WeatherCacheRepository; + +class WeatherApiTest extends AcceptanceTest { + + @Autowired + private WeatherCacheRepository weatherCacheRepository; + + @Test + void 병천_날씨를_조회한다() throws Exception { + clear(); + weatherCacheRepository.save(WeatherCache.of( + new WeatherForecastRequestTime("20240115", "1100", "20240115", "1200"), + new WeatherResponse(21, "맑음") + )); + + mockMvc.perform( + get("/weather") + .contentType(MediaType.APPLICATION_JSON) + ) + .andExpect(status().isOk()) + .andExpect(content().json(""" + { + "temperature": 21, + "weather": "맑음" + } + """)); + } +} diff --git a/src/test/java/in/koreatech/koin/unit/domain/weather/WeatherClientTest.java b/src/test/java/in/koreatech/koin/unit/domain/weather/WeatherClientTest.java new file mode 100644 index 000000000..39dc7c674 --- /dev/null +++ b/src/test/java/in/koreatech/koin/unit/domain/weather/WeatherClientTest.java @@ -0,0 +1,115 @@ +package in.koreatech.koin.unit.domain.weather; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.springframework.test.web.client.ExpectedCount.once; +import static org.springframework.test.web.client.match.MockRestRequestMatchers.anything; +import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.test.web.client.MockRestServiceServer; +import org.springframework.web.client.RestTemplate; + +import com.fasterxml.jackson.databind.ObjectMapper; + +import in.koreatech.koin.domain.weather.client.WeatherClient; +import in.koreatech.koin.domain.weather.exception.WeatherOpenApiException; +import in.koreatech.koin.domain.weather.model.WeatherForecast; +import in.koreatech.koin.domain.weather.model.WeatherForecastRequestTime; + +class WeatherClientTest { + + private static final WeatherForecastRequestTime REQUEST_TIME = new WeatherForecastRequestTime( + "20240115", + "1100", + "20240115", + "1200" + ); + + private MockRestServiceServer server; + private WeatherClient weatherClient; + + @BeforeEach + void setUp() { + RestTemplate restTemplate = new RestTemplate(); + server = MockRestServiceServer.bindTo(restTemplate).build(); + weatherClient = new WeatherClient("test-api-key", restTemplate, new ObjectMapper()); + } + + @Test + void 기상청_예보_응답에서_기온_하늘상태_강수형태를_추출한다() { + server.expect(once(), anything()) + .andRespond(withSuccess(weatherApiResponse(""" + {"category":"TMP","fcstDate":"20240115","fcstTime":"1200","fcstValue":"21"}, + {"category":"SKY","fcstDate":"20240115","fcstTime":"1200","fcstValue":"1"}, + {"category":"PTY","fcstDate":"20240115","fcstTime":"1200","fcstValue":"0"} + """), MediaType.APPLICATION_JSON)); + + WeatherForecast forecast = weatherClient.getWeatherForecast(REQUEST_TIME); + + assertThat(forecast.temperature()).isEqualTo(21); + assertThat(forecast.sky()).isEqualTo("1"); + assertThat(forecast.precipitationType()).isEqualTo("0"); + server.verify(); + } + + @Test + void 요청한_예보시각의_데이터가_없으면_직전_발표시각으로_다시_조회한다() { + server.expect(once(), anything()) + .andRespond(withSuccess(weatherApiResponse(""" + {"category":"TMP","fcstDate":"20240115","fcstTime":"1300","fcstValue":"21"}, + {"category":"SKY","fcstDate":"20240115","fcstTime":"1300","fcstValue":"1"}, + {"category":"PTY","fcstDate":"20240115","fcstTime":"1300","fcstValue":"0"} + """), MediaType.APPLICATION_JSON)); + server.expect(once(), anything()) + .andRespond(withSuccess(weatherApiResponse(""" + {"category":"TMP","fcstDate":"20240115","fcstTime":"1200","fcstValue":"20"}, + {"category":"SKY","fcstDate":"20240115","fcstTime":"1200","fcstValue":"3"}, + {"category":"PTY","fcstDate":"20240115","fcstTime":"1200","fcstValue":"0"} + """), MediaType.APPLICATION_JSON)); + + WeatherForecast forecast = weatherClient.getWeatherForecast(REQUEST_TIME); + + assertThat(forecast.temperature()).isEqualTo(20); + assertThat(forecast.sky()).isEqualTo("3"); + assertThat(forecast.precipitationType()).isEqualTo("0"); + server.verify(); + } + + @Test + void 필수_예보_항목이_누락되면_비정상_응답으로_처리한다() { + server.expect(once(), anything()) + .andRespond(withSuccess(weatherApiResponse(""" + {"category":"TMP","fcstDate":"20240115","fcstTime":"1200","fcstValue":"21"}, + {"category":"PTY","fcstDate":"20240115","fcstTime":"1200","fcstValue":"0"} + """), MediaType.APPLICATION_JSON)); + + assertThatThrownBy(() -> weatherClient.getWeatherForecast(REQUEST_TIME)) + .isInstanceOf(WeatherOpenApiException.class) + .hasMessage("기상청 단기예보 API 응답이 정상적이지 않습니다."); + + server.verify(); + } + + private String weatherApiResponse(String items) { + return """ + { + "response": { + "header": { + "resultCode": "00", + "resultMsg": "NORMAL_SERVICE" + }, + "body": { + "items": { + "item": [ + %s + ] + } + } + } + } + """.formatted(items); + } +} diff --git a/src/test/java/in/koreatech/koin/unit/domain/weather/WeatherConditionTest.java b/src/test/java/in/koreatech/koin/unit/domain/weather/WeatherConditionTest.java new file mode 100644 index 000000000..e774f7735 --- /dev/null +++ b/src/test/java/in/koreatech/koin/unit/domain/weather/WeatherConditionTest.java @@ -0,0 +1,32 @@ +package in.koreatech.koin.unit.domain.weather; + +import static in.koreatech.koin.domain.weather.model.WeatherCondition.CLOUDY; +import static in.koreatech.koin.domain.weather.model.WeatherCondition.OVERCAST; +import static in.koreatech.koin.domain.weather.model.WeatherCondition.RAIN; +import static in.koreatech.koin.domain.weather.model.WeatherCondition.RAIN_AND_SNOW; +import static in.koreatech.koin.domain.weather.model.WeatherCondition.SHOWER; +import static in.koreatech.koin.domain.weather.model.WeatherCondition.SNOW; +import static in.koreatech.koin.domain.weather.model.WeatherCondition.SUNNY; +import static org.assertj.core.api.Assertions.assertThat; + +import org.junit.jupiter.api.Test; + +import in.koreatech.koin.domain.weather.model.WeatherCondition; + +class WeatherConditionTest { + + @Test + void 강수형태가_없으면_하늘상태로_날씨를_정한다() { + assertThat(WeatherCondition.from("1", "0")).isEqualTo(SUNNY); + assertThat(WeatherCondition.from("3", "0")).isEqualTo(CLOUDY); + assertThat(WeatherCondition.from("4", "0")).isEqualTo(OVERCAST); + } + + @Test + void 강수형태가_있으면_하늘상태보다_강수형태를_우선한다() { + assertThat(WeatherCondition.from("1", "1")).isEqualTo(RAIN); + assertThat(WeatherCondition.from("1", "2")).isEqualTo(RAIN_AND_SNOW); + assertThat(WeatherCondition.from("1", "3")).isEqualTo(SNOW); + assertThat(WeatherCondition.from("1", "4")).isEqualTo(SHOWER); + } +} diff --git a/src/test/java/in/koreatech/koin/unit/domain/weather/WeatherForecastRequestTimeTest.java b/src/test/java/in/koreatech/koin/unit/domain/weather/WeatherForecastRequestTimeTest.java new file mode 100644 index 000000000..e59247c8d --- /dev/null +++ b/src/test/java/in/koreatech/koin/unit/domain/weather/WeatherForecastRequestTimeTest.java @@ -0,0 +1,82 @@ +package in.koreatech.koin.unit.domain.weather; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.LocalDateTime; + +import org.junit.jupiter.api.Test; + +import in.koreatech.koin.domain.weather.model.WeatherForecastRequestTime; + +class WeatherForecastRequestTimeTest { + + @Test + void 기상청_단기예보_제공시각이_지난_가장_최근_발표시각을_사용한다() { + WeatherForecastRequestTime requestTime = WeatherForecastRequestTime.from( + LocalDateTime.of(2024, 1, 15, 12, 35) + ); + + assertThat(requestTime.baseDate()).isEqualTo("20240115"); + assertThat(requestTime.baseTime()).isEqualTo("1100"); + assertThat(requestTime.forecastDate()).isEqualTo("20240115"); + assertThat(requestTime.forecastTime()).isEqualTo("1200"); + } + + @Test + void 첫_발표시각_전에는_전날_마지막_발표시각을_사용한다() { + WeatherForecastRequestTime requestTime = WeatherForecastRequestTime.from( + LocalDateTime.of(2024, 1, 15, 2, 5) + ); + + assertThat(requestTime.baseDate()).isEqualTo("20240114"); + assertThat(requestTime.baseTime()).isEqualTo("2300"); + assertThat(requestTime.forecastDate()).isEqualTo("20240115"); + assertThat(requestTime.forecastTime()).isEqualTo("0200"); + } + + @Test + void 자정_직후에는_전날_마지막_발표시각을_사용한다() { + WeatherForecastRequestTime requestTime = WeatherForecastRequestTime.from( + LocalDateTime.of(2024, 1, 15, 0, 5) + ); + + assertThat(requestTime.baseDate()).isEqualTo("20240114"); + assertThat(requestTime.baseTime()).isEqualTo("2300"); + assertThat(requestTime.forecastDate()).isEqualTo("20240115"); + assertThat(requestTime.forecastTime()).isEqualTo("0000"); + } + + @Test + void 직전_발표시각을_계산한다() { + WeatherForecastRequestTime requestTime = new WeatherForecastRequestTime( + "20240115", + "1100", + "20240115", + "1200" + ); + + WeatherForecastRequestTime previousBaseTime = requestTime.previousBaseTime(); + + assertThat(previousBaseTime.baseDate()).isEqualTo("20240115"); + assertThat(previousBaseTime.baseTime()).isEqualTo("0800"); + assertThat(previousBaseTime.forecastDate()).isEqualTo("20240115"); + assertThat(previousBaseTime.forecastTime()).isEqualTo("1200"); + } + + @Test + void 첫_발표시각의_직전_발표시각은_전날_마지막_발표시각이다() { + WeatherForecastRequestTime requestTime = new WeatherForecastRequestTime( + "20240115", + "0200", + "20240115", + "0300" + ); + + WeatherForecastRequestTime previousBaseTime = requestTime.previousBaseTime(); + + assertThat(previousBaseTime.baseDate()).isEqualTo("20240114"); + assertThat(previousBaseTime.baseTime()).isEqualTo("2300"); + assertThat(previousBaseTime.forecastDate()).isEqualTo("20240115"); + assertThat(previousBaseTime.forecastTime()).isEqualTo("0300"); + } +} diff --git a/src/test/java/in/koreatech/koin/unit/domain/weather/WeatherServiceTest.java b/src/test/java/in/koreatech/koin/unit/domain/weather/WeatherServiceTest.java new file mode 100644 index 000000000..a88fcf0f1 --- /dev/null +++ b/src/test/java/in/koreatech/koin/unit/domain/weather/WeatherServiceTest.java @@ -0,0 +1,88 @@ +package in.koreatech.koin.unit.domain.weather; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.Clock; +import java.time.Instant; +import java.time.ZoneId; +import java.util.Optional; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import in.koreatech.koin.domain.weather.client.WeatherClient; +import in.koreatech.koin.domain.weather.dto.WeatherResponse; +import in.koreatech.koin.domain.weather.exception.WeatherOpenApiException; +import in.koreatech.koin.domain.weather.model.WeatherCache; +import in.koreatech.koin.domain.weather.model.WeatherForecast; +import in.koreatech.koin.domain.weather.model.WeatherForecastRequestTime; +import in.koreatech.koin.domain.weather.repository.WeatherCacheRepository; +import in.koreatech.koin.domain.weather.service.WeatherService; + +@ExtendWith(MockitoExtension.class) +class WeatherServiceTest { + + @Mock + private WeatherClient weatherClient; + + @Mock + private WeatherCacheRepository weatherCacheRepository; + + @Test + void 레디스_캐시가_있으면_기상청_API를_호출하지_않고_캐시를_반환한다() { + Clock clock = Clock.fixed(Instant.parse("2024-01-15T03:35:00Z"), ZoneId.of("Asia/Seoul")); + WeatherService weatherService = new WeatherService(clock, weatherClient, weatherCacheRepository); + WeatherForecastRequestTime requestTime = new WeatherForecastRequestTime( + "20240115", + "1100", + "20240115", + "1200" + ); + WeatherResponse cachedWeather = new WeatherResponse(21, "맑음"); + when(weatherCacheRepository.findById(WeatherCache.BYEONGCHEON_ID)) + .thenReturn(Optional.of(WeatherCache.of(requestTime, cachedWeather))); + + WeatherResponse response = weatherService.getWeather(); + + assertThat(response).isEqualTo(cachedWeather); + verify(weatherClient, never()).getWeatherForecast(requestTime); + } + + @Test + void 레디스_캐시가_없어도_조회_API에서는_기상청_API를_호출하지_않는다() { + Clock clock = Clock.fixed(Instant.parse("2024-01-15T03:35:00Z"), ZoneId.of("Asia/Seoul")); + WeatherService weatherService = new WeatherService(clock, weatherClient, weatherCacheRepository); + when(weatherCacheRepository.findById(WeatherCache.BYEONGCHEON_ID)) + .thenReturn(Optional.empty()); + + assertThrows(WeatherOpenApiException.class, weatherService::getWeather); + + verify(weatherClient, never()).getWeatherForecast(any()); + } + + @Test + void 스케줄러_갱신에서는_기상청_API를_호출하고_캐시를_저장한다() { + Clock clock = Clock.fixed(Instant.parse("2024-01-15T03:35:00Z"), ZoneId.of("Asia/Seoul")); + WeatherService weatherService = new WeatherService(clock, weatherClient, weatherCacheRepository); + WeatherForecastRequestTime requestTime = new WeatherForecastRequestTime( + "20240115", + "1100", + "20240115", + "1200" + ); + when(weatherClient.getWeatherForecast(requestTime)) + .thenReturn(new WeatherForecast(21, "1", "0")); + + weatherService.refreshWeather(); + + verify(weatherClient).getWeatherForecast(requestTime); + verify(weatherCacheRepository).save(any(WeatherCache.class)); + } +}