diff --git a/build.gradle b/build.gradle index 83457bb6..d65fd858 100644 --- a/build.gradle +++ b/build.gradle @@ -127,6 +127,9 @@ dependencies { // Xml implementation "com.fasterxml.jackson.dataformat:jackson-dataformat-xml:2.19.0-rc2" + // 좌표 + implementation 'org.locationtech.proj4j:proj4j:1.1.1' + } // JaCoCo 버전 설정 diff --git a/src/main/java/org/programmers/signalbuddyfinal/domain/air_quality/controller/AirQualityController.java b/src/main/java/org/programmers/signalbuddyfinal/domain/air_quality/controller/AirQualityController.java index 0f3b2059..d59ddae8 100644 --- a/src/main/java/org/programmers/signalbuddyfinal/domain/air_quality/controller/AirQualityController.java +++ b/src/main/java/org/programmers/signalbuddyfinal/domain/air_quality/controller/AirQualityController.java @@ -7,6 +7,7 @@ import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @RestController @@ -14,10 +15,13 @@ @RequiredArgsConstructor public class AirQualityController { - private final AirQualityService airQualityService; + private final AirQualityService airQualityService; - @GetMapping - public ResponseEntity> getAirQuality() { - return ResponseEntity.ok(ApiResponse.createSuccess(airQualityService.getAirQuality())); - } + @GetMapping + public ResponseEntity> getAirQuality( + @RequestParam double lat, + @RequestParam double lng + ) { + return ResponseEntity.ok(ApiResponse.createSuccess(airQualityService.getAirQuality(lat, lng))); + } } diff --git a/src/main/java/org/programmers/signalbuddyfinal/domain/air_quality/controller/AirQualityScheduler.java b/src/main/java/org/programmers/signalbuddyfinal/domain/air_quality/controller/AirQualityScheduler.java index 2fe2a723..cd14fa25 100644 --- a/src/main/java/org/programmers/signalbuddyfinal/domain/air_quality/controller/AirQualityScheduler.java +++ b/src/main/java/org/programmers/signalbuddyfinal/domain/air_quality/controller/AirQualityScheduler.java @@ -3,7 +3,6 @@ import lombok.RequiredArgsConstructor; import net.javacrumbs.shedlock.spring.annotation.SchedulerLock; import org.programmers.signalbuddyfinal.domain.air_quality.service.AirQualityService; -import org.springframework.context.annotation.Configuration; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; @@ -21,4 +20,4 @@ public class AirQualityScheduler { public void updateAirQuality(){ airQualityService.updateAriQuality(); } -} +} \ No newline at end of file diff --git a/src/main/java/org/programmers/signalbuddyfinal/domain/air_quality/dto/AirQuality.java b/src/main/java/org/programmers/signalbuddyfinal/domain/air_quality/dto/AirQuality.java deleted file mode 100644 index b62edb97..00000000 --- a/src/main/java/org/programmers/signalbuddyfinal/domain/air_quality/dto/AirQuality.java +++ /dev/null @@ -1,16 +0,0 @@ -package org.programmers.signalbuddyfinal.domain.air_quality.dto; - -import com.fasterxml.jackson.annotation.JsonProperty; -import java.util.List; -import lombok.Getter; - -@Getter -public class AirQuality { - - private int list_total_count; - - @JsonProperty("RESULT") - private Result result; - - private List row; -} diff --git a/src/main/java/org/programmers/signalbuddyfinal/domain/air_quality/dto/AirQualityItems.java b/src/main/java/org/programmers/signalbuddyfinal/domain/air_quality/dto/AirQualityItems.java deleted file mode 100644 index 27fa505b..00000000 --- a/src/main/java/org/programmers/signalbuddyfinal/domain/air_quality/dto/AirQualityItems.java +++ /dev/null @@ -1,36 +0,0 @@ -package org.programmers.signalbuddyfinal.domain.air_quality.dto; - -import com.fasterxml.jackson.annotation.JsonProperty; -import lombok.Getter; - -@Getter -public class AirQualityItems { - - @JsonProperty("GRADE") - private String grade; - - @JsonProperty("IDEX_MVL") - private String mvl; - - @JsonProperty("POLLUTANT") - private String pollutant; - - @JsonProperty("NITROGEN") - private String nitrogen; - - @JsonProperty("OZONE") - private String ozone; - - @JsonProperty("CARBON") - private String carbon; - - @JsonProperty("SULFUROUS") - private String sulfurous; - - @JsonProperty("PM10") - private String pm10; - - @JsonProperty("PM25") - private String pm25; - -} diff --git a/src/main/java/org/programmers/signalbuddyfinal/domain/air_quality/dto/AirQualityResponse.java b/src/main/java/org/programmers/signalbuddyfinal/domain/air_quality/dto/AirQualityResponse.java index fd8b2d60..3985962c 100644 --- a/src/main/java/org/programmers/signalbuddyfinal/domain/air_quality/dto/AirQualityResponse.java +++ b/src/main/java/org/programmers/signalbuddyfinal/domain/air_quality/dto/AirQualityResponse.java @@ -4,13 +4,11 @@ import lombok.AllArgsConstructor; import lombok.Builder; -import lombok.Data; import lombok.Getter; import lombok.NoArgsConstructor; @Getter @Builder -@Data @AllArgsConstructor @NoArgsConstructor public class AirQualityResponse implements Serializable { diff --git a/src/main/java/org/programmers/signalbuddyfinal/domain/air_quality/dto/Observatory.java b/src/main/java/org/programmers/signalbuddyfinal/domain/air_quality/dto/Observatory.java new file mode 100644 index 00000000..0924feaf --- /dev/null +++ b/src/main/java/org/programmers/signalbuddyfinal/domain/air_quality/dto/Observatory.java @@ -0,0 +1,36 @@ +package org.programmers.signalbuddyfinal.domain.air_quality.dto; + +import lombok.Data; +import java.util.List; + + +public class Observatory { + + @Data + public static class Response { + private Body body; + private Header header; + } + + @Data + public static class Body { + private int totalCount; + private List items; + private int pageNo; + private int numOfRows; + } + + @Data + public static class Item { + private String stationCode; + private double tm; + private String addr; + private String stationName; + } + + @Data + public static class Header { + private String resultMsg; + private String resultCode; + } +} diff --git a/src/main/java/org/programmers/signalbuddyfinal/domain/air_quality/dto/ObservatoryResponse.java b/src/main/java/org/programmers/signalbuddyfinal/domain/air_quality/dto/ObservatoryResponse.java new file mode 100644 index 00000000..ec721b8c --- /dev/null +++ b/src/main/java/org/programmers/signalbuddyfinal/domain/air_quality/dto/ObservatoryResponse.java @@ -0,0 +1,15 @@ +package org.programmers.signalbuddyfinal.domain.air_quality.dto; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class ObservatoryResponse { + + private String addr; + + private String stationName; + + private String stationCode; +} diff --git a/src/main/java/org/programmers/signalbuddyfinal/domain/air_quality/dto/RegionAirQuality.java b/src/main/java/org/programmers/signalbuddyfinal/domain/air_quality/dto/RegionAirQuality.java new file mode 100644 index 00000000..8c206d2e --- /dev/null +++ b/src/main/java/org/programmers/signalbuddyfinal/domain/air_quality/dto/RegionAirQuality.java @@ -0,0 +1,60 @@ +package org.programmers.signalbuddyfinal.domain.air_quality.dto; + +import lombok.Builder; +import java.util.List; +import lombok.Getter; + +@Builder +@Getter +public class RegionAirQuality { + private Response response; + + @Builder + @Getter + public static class Response { + private Body body; + private Header header; + } + + @Builder + @Getter + public static class Body { + private int totalCount; + private List items; + private int pageNo; + private int numOfRows; + } + + @Builder + @Getter + public static class Item { + private String so2Grade; + private String coFlag; + private String khaiValue; + private String so2Value; + private String coValue; + private String pm25Flag; + private String pm10Flag; + private String pm10Value; + private String o3Grade; + private String khaiGrade; + private String pm25Value; + private String no2Flag; + private String no2Grade; + private String o3Flag; + private String pm25Grade; + private String so2Flag; + private String dataTime; + private String coGrade; + private String no2Value; + private String pm10Grade; + private String o3Value; + } + + @Builder + @Getter + public static class Header { + private String resultMsg; + private String resultCode; + } +} diff --git a/src/main/java/org/programmers/signalbuddyfinal/domain/air_quality/dto/Result.java b/src/main/java/org/programmers/signalbuddyfinal/domain/air_quality/dto/Result.java deleted file mode 100644 index 99acdc40..00000000 --- a/src/main/java/org/programmers/signalbuddyfinal/domain/air_quality/dto/Result.java +++ /dev/null @@ -1,19 +0,0 @@ -package org.programmers.signalbuddyfinal.domain.air_quality.dto; - -import com.fasterxml.jackson.annotation.JsonProperty; -import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty; -import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlRootElement; -import lombok.Getter; - -@Getter -@JacksonXmlRootElement(localName = "RESULT") -public class Result { - - @JsonProperty("CODE") - @JacksonXmlProperty(localName = "CODE") - private String code; - - @JsonProperty("MESSAGE") - @JacksonXmlProperty(localName = "MESSAGE") - private String message; -} diff --git a/src/main/java/org/programmers/signalbuddyfinal/domain/air_quality/dto/SeoulAirQuality.java b/src/main/java/org/programmers/signalbuddyfinal/domain/air_quality/dto/SeoulAirQuality.java new file mode 100644 index 00000000..646ec35d --- /dev/null +++ b/src/main/java/org/programmers/signalbuddyfinal/domain/air_quality/dto/SeoulAirQuality.java @@ -0,0 +1,68 @@ +package org.programmers.signalbuddyfinal.domain.air_quality.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlProperty; +import com.fasterxml.jackson.dataformat.xml.annotation.JacksonXmlRootElement; +import java.util.List; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class SeoulAirQuality { + + @JsonProperty("list_total_count") + private int totalCount; + + @JsonProperty("RESULT") + private Result result; + + private List row; + + @Getter + @Builder + @JacksonXmlRootElement(localName = "RESULT") + public static class Result { + + @JsonProperty("CODE") + @JacksonXmlProperty(localName = "CODE") + private String code; + + @JsonProperty("MESSAGE") + @JacksonXmlProperty(localName = "MESSAGE") + private String message; + } + + @Getter + @Builder + public static class Item{ + + @JsonProperty("GRADE") + private String grade; + + @JsonProperty("IDEX_MVL") + private String mvl; + + @JsonProperty("POLLUTANT") + private String pollutant; + + @JsonProperty("NITROGEN") + private String nitrogen; + + @JsonProperty("OZONE") + private String ozone; + + @JsonProperty("CARBON") + private String carbon; + + @JsonProperty("SULFUROUS") + private String sulfurous; + + @JsonProperty("PM10") + private String pm10; + + @JsonProperty("PM25") + private String pm25; + + } +} diff --git a/src/main/java/org/programmers/signalbuddyfinal/domain/air_quality/exception/AirQualityErrorCode.java b/src/main/java/org/programmers/signalbuddyfinal/domain/air_quality/exception/AirQualityErrorCode.java index 7c60f649..145f70b1 100644 --- a/src/main/java/org/programmers/signalbuddyfinal/domain/air_quality/exception/AirQualityErrorCode.java +++ b/src/main/java/org/programmers/signalbuddyfinal/domain/air_quality/exception/AirQualityErrorCode.java @@ -12,7 +12,9 @@ public enum AirQualityErrorCode implements ErrorCode { AIR_QUALITY_SERVICE_UNAVAILABLE(HttpStatus.SERVICE_UNAVAILABLE, "20000", "일시적인 문제로 데이터를 불러올 수 없습니다. 잠시 후 다시 시도해 주세요."), - AIR_QUALITY_DATA_NOT_READ(HttpStatus.INTERNAL_SERVER_ERROR, "20001", "미세먼지 API 데이터 파싱 에러"); + AIR_QUALITY_DATA_NOT_READ(HttpStatus.INTERNAL_SERVER_ERROR, "20001", "미세먼지 API 데이터 파싱 에러"), + ALL_AIR_QUALITY_DATA_NOT_READ(HttpStatus.INTERNAL_SERVER_ERROR, "20002", "측정소별 미세먼지 API 데이터 파싱 에러"), + OBSERVATORY_DATA_NOT_READ(HttpStatus.INTERNAL_SERVER_ERROR, "20003", "관측소 API 데이터 파싱 에러"); private HttpStatus httpStatus; private String code; diff --git a/src/main/java/org/programmers/signalbuddyfinal/domain/air_quality/service/AirQualityService.java b/src/main/java/org/programmers/signalbuddyfinal/domain/air_quality/service/AirQualityService.java index 03afa2f3..6f156aaf 100644 --- a/src/main/java/org/programmers/signalbuddyfinal/domain/air_quality/service/AirQualityService.java +++ b/src/main/java/org/programmers/signalbuddyfinal/domain/air_quality/service/AirQualityService.java @@ -5,9 +5,8 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.programmers.signalbuddyfinal.domain.air_quality.dto.AirQuality; -import org.programmers.signalbuddyfinal.domain.air_quality.dto.AirQualityResponse; -import org.programmers.signalbuddyfinal.domain.air_quality.dto.CachedAirQuality; +import org.locationtech.proj4j.ProjCoordinate; +import org.programmers.signalbuddyfinal.domain.air_quality.dto.*; import org.programmers.signalbuddyfinal.domain.air_quality.exception.AirQualityErrorCode; import org.programmers.signalbuddyfinal.global.exception.BusinessException; import org.springframework.data.redis.core.RedisTemplate; @@ -18,60 +17,129 @@ @RequiredArgsConstructor public class AirQualityService { - private final AirQualityProvider airQualityProvider; + private final SeoulAirQualityProvider airQualityProvider; + private final RegionAirQualityProvider regionAirQualityProvider; + private final ObservatoryProvider observatoryProvider; + private final RedisTemplate redisTemplate; + private final CoordinateConverter coordinateConverter; - private static final String key = "air-quality: "; + private static final String key = "air-quality:"; private static final Duration TTL = Duration.ofHours(2); - public AirQualityResponse getAirQuality() { - return getCachedAirQuality().orElseGet(this::updateAriQuality); + public AirQualityResponse getAirQuality(double lat, double lng) { + ObservatoryResponse observatoryResponse = getObservatory(lat, lng).orElseThrow( + () -> new BusinessException(AirQualityErrorCode.AIR_QUALITY_SERVICE_UNAVAILABLE)); + + if (isSeoul(observatoryResponse)) { + return getCachedAirQuality("seoul").orElseGet(this::updateAriQuality); + } + return getCachedAirQuality(observatoryResponse.getStationCode()).orElseGet( + () -> updateRegionAriQuality(observatoryResponse)); } public AirQualityResponse updateAriQuality() { return requestAirQuality() - .map(this::successfulResponse) - .orElseGet(this::failBackOrThrow); + .map(this::successfulResponse) + .orElseGet(() -> failBackOrThrow("seoul")); + } + + public AirQualityResponse updateRegionAriQuality(ObservatoryResponse observatoryResponse) { + return requestAllAirQuality(observatoryResponse.getStationName()) + .map(newAirQuality -> successfulAllResponse(newAirQuality, + observatoryResponse.getStationCode())) + .orElseGet(() -> failBackOrThrow(observatoryResponse.getStationCode())); } - private Optional getCachedAirQuality() { - return Optional.ofNullable(getCache()) - .filter(CachedAirQuality::isFresh) - .map(CachedAirQuality::getData); + + private Optional getCachedAirQuality(String code) { + return Optional.ofNullable(getCache(key + code)) + .filter(CachedAirQuality::isFresh) + .map(CachedAirQuality::getData); } - private AirQualityResponse successfulResponse(AirQuality newAirQuality) { - AirQualityResponse response = createResponse(newAirQuality); - saveToCache(response, true); + private AirQualityResponse successfulResponse(SeoulAirQuality newSeoulAirQuality) { + AirQualityResponse response = createResponse(newSeoulAirQuality); + saveToCache(response, true, "seoul"); return response; } - private AirQualityResponse failBackOrThrow() { - return Optional.ofNullable(getCache()) - .map(cache -> { - saveToCache(cache.getData(), false); - return cache.getData(); - }) - .orElseThrow(() -> new BusinessException(AirQualityErrorCode.AIR_QUALITY_SERVICE_UNAVAILABLE)); + private AirQualityResponse successfulAllResponse(RegionAirQuality newAirQuality, String value) { + AirQualityResponse response = createAllResponse(newAirQuality); + saveToCache(response, true, value); + return response; } - private void saveToCache(AirQualityResponse airQualityResponse, boolean fresh) { - redisTemplate.opsForValue().set(key, new CachedAirQuality(airQualityResponse, fresh), TTL); + private AirQualityResponse failBackOrThrow(String value) { + String newKey = key + value; + return Optional.ofNullable(getCache(newKey)) + .map(cache -> { + saveToCache(cache.getData(), false, value); + return cache.getData(); + }) + .orElseThrow( + () -> new BusinessException(AirQualityErrorCode.AIR_QUALITY_SERVICE_UNAVAILABLE)); } - private CachedAirQuality getCache() { - return (CachedAirQuality) redisTemplate.opsForValue().get(key); + private void saveToCache(AirQualityResponse airQualityResponse, boolean fresh, String value) { + String newKey = key + value; + redisTemplate.opsForValue() + .set(newKey, new CachedAirQuality(airQualityResponse, fresh), TTL); } - private Optional requestAirQuality() { + private CachedAirQuality getCache(String newKey) { + return (CachedAirQuality) redisTemplate.opsForValue().get(newKey); + } + + private Optional requestAirQuality() { return airQualityProvider.getAirQuality(); } - private AirQualityResponse createResponse(AirQuality airQuality) { + private Optional requestAllAirQuality(String stationName) { + return regionAirQualityProvider.geAllAirQuality(stationName); + } + + private AirQualityResponse createResponse(SeoulAirQuality seoulAirQuality) { return AirQualityResponse.builder() - .grade(airQuality.getRow().get(0).getGrade()) - .pm25(airQuality.getRow().get(0).getPm25()) - .pm10(airQuality.getRow().get(0).getPm10()) - .build(); + .grade(seoulAirQuality.getRow().get(0).getGrade()) + .pm25(seoulAirQuality.getRow().get(0).getPm25()) + .pm10(seoulAirQuality.getRow().get(0).getPm10()) + .build(); + } + + private AirQualityResponse createAllResponse(RegionAirQuality regionAirQuality) { + return AirQualityResponse.builder() + .grade(convertGrade(regionAirQuality)) + .pm25(regionAirQuality.getResponse().getBody().getItems().get(0).getPm25Value()) + .pm10(regionAirQuality.getResponse().getBody().getItems().get(0).getPm10Value()) + .build(); + } + + private ProjCoordinate convertCoordinate(double lat, double lng) { + return coordinateConverter.convert(lat, lng); + } + + private Optional getObservatory(double lat, double lng) { + ProjCoordinate coordinate = convertCoordinate(lat, lng); + return observatoryProvider.getObservatory(coordinate.x, coordinate.y); + } + + private boolean isSeoul(ObservatoryResponse observatoryResponse) { + return observatoryResponse.getAddr().startsWith("서울"); + } + + private String convertGrade(RegionAirQuality airQuality) { + switch (airQuality.getResponse().getBody().getItems().get(0).getPm10Grade()) { + case "1": + return "좋음"; + case "2": + return "보통"; + case "3": + return "나쁨"; + case "4": + return "매우 나쁨"; + default: + return "-"; + } } } diff --git a/src/main/java/org/programmers/signalbuddyfinal/domain/air_quality/service/CoordinateConverter.java b/src/main/java/org/programmers/signalbuddyfinal/domain/air_quality/service/CoordinateConverter.java new file mode 100644 index 00000000..ae608b69 --- /dev/null +++ b/src/main/java/org/programmers/signalbuddyfinal/domain/air_quality/service/CoordinateConverter.java @@ -0,0 +1,33 @@ +package org.programmers.signalbuddyfinal.domain.air_quality.service; + +import lombok.RequiredArgsConstructor; +import org.locationtech.proj4j.CRSFactory; +import org.locationtech.proj4j.CoordinateReferenceSystem; +import org.locationtech.proj4j.CoordinateTransform; +import org.locationtech.proj4j.CoordinateTransformFactory; +import org.locationtech.proj4j.ProjCoordinate; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class CoordinateConverter { + + private final CoordinateTransform transform; + + public CoordinateConverter() { + CRSFactory factory = new CRSFactory(); + CoordinateReferenceSystem crsWGS84 = factory.createFromName("EPSG:4326"); + CoordinateReferenceSystem crsTM = factory.createFromName("EPSG:5181"); + + CoordinateTransformFactory ctFactory = new CoordinateTransformFactory(); + this.transform = ctFactory.createTransform(crsWGS84, crsTM); + } + + public ProjCoordinate convert(double longitude, double latitude) { + ProjCoordinate srcCoord = new ProjCoordinate(longitude, latitude); + ProjCoordinate destCoord = new ProjCoordinate(); + + transform.transform(srcCoord, destCoord); + return destCoord; + } +} diff --git a/src/main/java/org/programmers/signalbuddyfinal/domain/air_quality/service/ObservatoryProvider.java b/src/main/java/org/programmers/signalbuddyfinal/domain/air_quality/service/ObservatoryProvider.java new file mode 100644 index 00000000..47901d89 --- /dev/null +++ b/src/main/java/org/programmers/signalbuddyfinal/domain/air_quality/service/ObservatoryProvider.java @@ -0,0 +1,84 @@ +package org.programmers.signalbuddyfinal.domain.air_quality.service; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.xml.XmlMapper; +import java.util.Optional; +import lombok.extern.slf4j.Slf4j; +import lombok.RequiredArgsConstructor; +import org.programmers.signalbuddyfinal.domain.air_quality.dto.ObservatoryResponse; +import org.programmers.signalbuddyfinal.domain.air_quality.exception.AirQualityErrorCode; +import org.programmers.signalbuddyfinal.global.exception.BusinessException; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; + +import java.util.StringTokenizer; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ObservatoryProvider { + + @Qualifier("observatoryApiWebClient") + private final WebClient webClient; + @Value("${observatory.api-key}") + private String apiKey; + private final ObjectMapper objectMapper; + private final XmlMapper xmlMapper = (XmlMapper) new XmlMapper() + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + + + public Optional getObservatory(double lat, double lng) { + + String body = webClient.get() + .uri(uriBuilder -> uriBuilder + .path("/{apiKey}/json/{lat}/{lng}/1.1") + .build(apiKey, lat, lng) + ).accept(MediaType.ALL) + .retrieve() + .bodyToMono(String.class) + .block(); + + ObservatoryResponse response = parserObservatory(body); + if (response != null) { + return Optional.of(response); + } + return Optional.empty(); + } + + private ObservatoryResponse parserObservatory(String body) { + try { + JsonNode jsonRoot = objectMapper.readTree(body); + if (jsonRoot.has("response")) { + JsonNode itemsNode = jsonRoot.get("response").get("body").get("items"); + String fullAddr = itemsNode.get(0).get("addr").asText(); + StringTokenizer st = new StringTokenizer(fullAddr, " "); + String addr = st.nextToken(); + + return ObservatoryResponse.builder() + .addr(addr) + .stationName(itemsNode.get(0).get("stationName").asText()) + .stationCode(itemsNode.get(0).get("stationCode").asText()) + .build(); + } + } catch (Exception e) { + try { + JsonNode rootNode = xmlMapper.readTree(body); + + String code = rootNode.at("/cmmMsgHeader/returnReasonCode").asText(); + String message = rootNode.at("/cmmMsgHeader/errMsg").asText(); + + log.info("측정소 API 응답 실패 - CODE: {}, MESSAGE: {}", code, + message); + return null; + } catch (Exception xmlEx) { + throw new BusinessException(AirQualityErrorCode.OBSERVATORY_DATA_NOT_READ); + } + } + return null; + } +} diff --git a/src/main/java/org/programmers/signalbuddyfinal/domain/air_quality/service/RegionAirQualityProvider.java b/src/main/java/org/programmers/signalbuddyfinal/domain/air_quality/service/RegionAirQualityProvider.java new file mode 100644 index 00000000..d9139469 --- /dev/null +++ b/src/main/java/org/programmers/signalbuddyfinal/domain/air_quality/service/RegionAirQualityProvider.java @@ -0,0 +1,74 @@ +package org.programmers.signalbuddyfinal.domain.air_quality.service; + +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.dataformat.xml.XmlMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.programmers.signalbuddyfinal.domain.air_quality.dto.RegionAirQuality; +import org.programmers.signalbuddyfinal.domain.air_quality.exception.AirQualityErrorCode; +import org.programmers.signalbuddyfinal.global.exception.BusinessException; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.web.reactive.function.client.WebClient; + +import java.util.Optional; + +@Slf4j +@Component +@RequiredArgsConstructor +public class RegionAirQualityProvider { + + @Qualifier("allAirQualityApiWebClient") + private final WebClient webClient; + @Value("${observatory.api-key}") + private String apiKey; + private final ObjectMapper objectMapper; + private final XmlMapper xmlMapper = (XmlMapper) new XmlMapper() + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + + public Optional geAllAirQuality(String stationName) { + + String body = webClient.get() + .uri(uriBuilder -> uriBuilder + .path("/{apiKey}/json/1/1/{stationName}/DAILY/1.0") + .build(apiKey, stationName) + ).accept(MediaType.ALL) + .retrieve() + .bodyToMono(String.class) + .block(); + + RegionAirQuality response = parserAirQuality(body); + if (response != null) { + return Optional.of(response); + } + return Optional.empty(); + } + + private RegionAirQuality parserAirQuality(String body){ + try { + JsonNode jsonRoot = objectMapper.readTree(body); + if (jsonRoot.has("response")) { + return objectMapper.readValue(body, RegionAirQuality.class); + } + }catch (Exception e) { + try { + JsonNode rootNode = xmlMapper.readTree(body); + + String code = rootNode.at("/cmmMsgHeader/returnReasonCode").asText(); + String message = rootNode.at("/cmmMsgHeader/errMsg").asText(); + + log.info("측정소별 미세먼지 API 응답 실패 - CODE: {}, MESSAGE: {}", code, + message); + return null; + } catch (Exception xmlEx) { + throw new BusinessException(AirQualityErrorCode.ALL_AIR_QUALITY_DATA_NOT_READ); + } + } + return null; + } + +} diff --git a/src/main/java/org/programmers/signalbuddyfinal/domain/air_quality/service/AirQualityProvider.java b/src/main/java/org/programmers/signalbuddyfinal/domain/air_quality/service/SeoulAirQualityProvider.java similarity index 73% rename from src/main/java/org/programmers/signalbuddyfinal/domain/air_quality/service/AirQualityProvider.java rename to src/main/java/org/programmers/signalbuddyfinal/domain/air_quality/service/SeoulAirQualityProvider.java index d7ef0b38..0ba56ef3 100644 --- a/src/main/java/org/programmers/signalbuddyfinal/domain/air_quality/service/AirQualityProvider.java +++ b/src/main/java/org/programmers/signalbuddyfinal/domain/air_quality/service/SeoulAirQualityProvider.java @@ -7,10 +7,7 @@ import java.util.Optional; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.apache.poi.sl.draw.geom.GuideIf.Op; -import org.programmers.signalbuddyfinal.domain.air_quality.dto.AirQuality; -import org.programmers.signalbuddyfinal.domain.air_quality.dto.AirQualityItems; -import org.programmers.signalbuddyfinal.domain.air_quality.dto.Result; +import org.programmers.signalbuddyfinal.domain.air_quality.dto.SeoulAirQuality; import org.programmers.signalbuddyfinal.domain.air_quality.exception.AirQualityErrorCode; import org.programmers.signalbuddyfinal.global.exception.BusinessException; import org.springframework.beans.factory.annotation.Qualifier; @@ -22,7 +19,7 @@ @Slf4j @Component @RequiredArgsConstructor -public class AirQualityProvider { +public class SeoulAirQualityProvider { @Qualifier("airQualityApiWebClient") private final WebClient webClient; @@ -33,7 +30,7 @@ public class AirQualityProvider { .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); - public Optional getAirQuality() { + public Optional getAirQuality() { String body = webClient.get() .uri(uriBuilder -> uriBuilder @@ -45,25 +42,27 @@ public Optional getAirQuality() { .bodyToMono(String.class) .block(); - AirQuality airQuality = parseAirQuality(body); - if(airQuality != null) { - return Optional.of(airQuality); + SeoulAirQuality seoulAirQuality = parseAirQuality(body); + if(seoulAirQuality != null) { + return Optional.of(seoulAirQuality); } return Optional.empty(); } - private AirQuality parseAirQuality(String body) { + private SeoulAirQuality parseAirQuality(String body) { try { JsonNode jsonRoot = objectMapper.readTree(body); if (jsonRoot.has("ListAvgOfSeoulAirQualityService")) { JsonNode service = jsonRoot.get("ListAvgOfSeoulAirQualityService"); - return objectMapper.treeToValue(service, AirQuality.class); + return objectMapper.treeToValue(service, SeoulAirQuality.class); } } catch (Exception e) { try { - Result error = xmlMapper.readValue(body, Result.class); - log.info("미세먼지 API 응답 실패 - CODE: {}, MESSAGE: {}", error.getCode(), - error.getMessage()); + JsonNode rootNode = xmlMapper.readTree(body); + String code = rootNode.has("CODE") ?rootNode.get("CODE").asText() : "UNKNOWN"; + String message = rootNode.has("MESSAGE") ? rootNode.get("MESSAGE").asText() : "UNKNOWN"; + log.info("미세먼지 API 응답 실패 - CODE: {}, MESSAGE: {}", code, + message); return null; } catch (Exception xmlEx) { throw new BusinessException(AirQualityErrorCode.AIR_QUALITY_DATA_NOT_READ); diff --git a/src/main/java/org/programmers/signalbuddyfinal/global/config/WebClientConfig.java b/src/main/java/org/programmers/signalbuddyfinal/global/config/WebClientConfig.java index adcaa633..8a5df782 100644 --- a/src/main/java/org/programmers/signalbuddyfinal/global/config/WebClientConfig.java +++ b/src/main/java/org/programmers/signalbuddyfinal/global/config/WebClientConfig.java @@ -26,6 +26,12 @@ public class WebClientConfig { @Value("${air-quality.base-url}") private String airQualityApiBaseUrl; + @Value("${region-air-quality.base-url}") + private String allAirQualityApiBaseUrl; + + @Value("${observatory.base-url}") + private String observatoryApiBaseUrl; + private final int processors = Runtime.getRuntime().availableProcessors(); // PC의 Processor 개수 private final HttpClient httpClient = HttpClient.create( ConnectionProvider.builder("ApiConnections") @@ -65,4 +71,24 @@ public WebClient airQualityApiWebClient() { .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) .build(); } + + // 미세먼지 측정소 API WebClient + @Bean + public WebClient observatoryApiWebClient() { + return WebClient.builder() + .baseUrl(observatoryApiBaseUrl) + .clientConnector(new ReactorClientHttpConnector(httpClient)) + .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .build(); + } + + // 전국 미세먼지 API WebClient + @Bean + public WebClient allAirQualityApiWebClient() { + return WebClient.builder() + .baseUrl(allAirQualityApiBaseUrl) + .clientConnector(new ReactorClientHttpConnector(httpClient)) + .defaultHeader(HttpHeaders.CONTENT_TYPE, MediaType.APPLICATION_JSON_VALUE) + .build(); + } } diff --git a/src/test/java/org/programmers/signalbuddyfinal/domain/air_quality/controller/AirQualityControllerTest.java b/src/test/java/org/programmers/signalbuddyfinal/domain/air_quality/controller/AirQualityControllerTest.java index 4e8dd95e..6ea9502a 100644 --- a/src/test/java/org/programmers/signalbuddyfinal/domain/air_quality/controller/AirQualityControllerTest.java +++ b/src/test/java/org/programmers/signalbuddyfinal/domain/air_quality/controller/AirQualityControllerTest.java @@ -24,7 +24,7 @@ @WebMvcTest(AirQualityController.class) public class AirQualityControllerTest extends ControllerTest { - private final String tag = "AirQuality API"; + private final String tag = "SeoulAirQuality API"; @MockitoBean private AirQualityService airQualityService; @@ -38,9 +38,12 @@ void getAirQuality() throws Exception { .pm10("25") .build(); - given(airQualityService.getAirQuality()).willReturn(response); + given(airQualityService.getAirQuality(127.4170933,127.4170933)).willReturn(response); - mockMvc.perform(get("/api/air-quality")) + mockMvc.perform(get("/api/air-quality") + .param("lat","127.4170933") + .param("lng", "127.4170933") + ) .andExpect(status().isOk()) .andDo(print()) .andDo(document("미세먼지 조회", diff --git a/src/test/java/org/programmers/signalbuddyfinal/domain/air_quality/service/AirQualityServiceTest.java b/src/test/java/org/programmers/signalbuddyfinal/domain/air_quality/service/AirQualityServiceTest.java index a16936dd..9cf587ad 100644 --- a/src/test/java/org/programmers/signalbuddyfinal/domain/air_quality/service/AirQualityServiceTest.java +++ b/src/test/java/org/programmers/signalbuddyfinal/domain/air_quality/service/AirQualityServiceTest.java @@ -2,15 +2,23 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.mockito.ArgumentMatchers.anyDouble; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.when; +import java.util.List; import okhttp3.mockwebserver.MockResponse; import okhttp3.mockwebserver.MockWebServer; -import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.programmers.signalbuddyfinal.domain.air_quality.dto.AirQualityResponse; +import org.programmers.signalbuddyfinal.domain.air_quality.dto.RegionAirQuality; import org.programmers.signalbuddyfinal.domain.air_quality.dto.CachedAirQuality; +import org.programmers.signalbuddyfinal.domain.air_quality.dto.ObservatoryResponse; +import org.programmers.signalbuddyfinal.domain.air_quality.dto.SeoulAirQuality; import org.programmers.signalbuddyfinal.global.config.RedisConfig; import org.programmers.signalbuddyfinal.global.db.RedisTestContainer; import org.programmers.signalbuddyfinal.global.exception.BusinessException; @@ -26,6 +34,8 @@ import org.springframework.test.context.TestPropertySource; import java.io.IOException; +import java.util.Optional; +import org.springframework.test.context.bean.override.mockito.MockitoBean; @SpringBootTest @Import(RedisConfig.class) @@ -43,27 +53,32 @@ public class AirQualityServiceTest extends ServiceTest implements RedisTestConta @Autowired private RedisTemplate redisTemplate; - private static MockWebServer mockWebServer; + @MockitoBean + private static ObservatoryProvider observatoryProvider; + + @MockitoBean + private RegionAirQualityProvider regionAirQualityProvider; + + @MockitoBean + private SeoulAirQualityProvider seoulAirQualityProvider; - private final String key = "air-quality: "; + private static MockWebServer mockWebServer; - private static CachedAirQuality cachedAirQuality; + private final String key = "air-quality:"; @BeforeAll static void startMockServer() throws IOException { mockWebServer = new MockWebServer(); mockWebServer.start(); + } - AirQualityResponse response = AirQualityResponse.builder() - .grade("보통") - .pm10("61") - .pm25("20") - .build(); - cachedAirQuality = new CachedAirQuality(response, true); + @BeforeEach + void setup() { + flushRedis(); } - @AfterAll - static void stopMockServer() throws IOException { + @AfterEach + void shutDown() throws IOException { mockWebServer.shutdown(); } @@ -72,14 +87,18 @@ static void overrideProperties(DynamicPropertyRegistry registry) { registry.add("air-quality.base-url", () -> "http://localhost:" + mockWebServer.getPort()); } - @DisplayName("첫 요청 성공 시 응답 반환 및 캐싱 테스트") + @DisplayName("서울 첫 요청 성공 시 응답 반환 및 캐싱 테스트") @Test void successRequestTest() { - flushRedis(); - createMockWebServer(createResponse()); + createMockWebServer(createSeoulApiResponse()); + + when(observatoryProvider.getObservatory(anyDouble(), anyDouble())) + .thenReturn(createObservatoryResponse("서울역", "서울 XXX OOO")); + when(seoulAirQualityProvider.getAirQuality()) + .thenReturn(Optional.of(createSeoulAirQuality())); - AirQualityResponse response = airQualityService.getAirQuality(); - CachedAirQuality cache = (CachedAirQuality) redisTemplate.opsForValue().get(key); + AirQualityResponse response = airQualityService.getAirQuality(127.4170933, 36.35218384); + CachedAirQuality cache = getCache("seoul"); assertThat(response.getGrade()).isEqualTo("보통"); assertThat(cache).isNotNull(); @@ -87,15 +106,16 @@ void successRequestTest() { assertThat(cache.isFresh()).isTrue(); } - @DisplayName("두 번째 요청 시 캐싱된 데이터 반환") + @DisplayName("서울 두 번째 요청 시 캐싱된 데이터 반환") @Test void successSecondRequestTest() { - flushRedis(); int count = mockWebServer.getRequestCount(); - createMockWebServer(createResponse()); - redisTemplate.opsForValue().set(key, cachedAirQuality); + createMockWebServer(createSeoulApiResponse()); + setCache("seoul", true); + when(observatoryProvider.getObservatory(anyDouble(), anyDouble())) + .thenReturn(createObservatoryResponse("서울역", "서울 XXX OOO")); - AirQualityResponse response = airQualityService.getAirQuality(); + AirQualityResponse response = airQualityService.getAirQuality(127.4170933, 36.35218384); assertThat(response).isNotNull(); assertThat(response.getGrade()).isEqualTo("보통"); @@ -104,28 +124,106 @@ void successSecondRequestTest() { assertThat(mockWebServer.getRequestCount()).isEqualTo(count); } - @DisplayName("failBack 실패 테스트") + @DisplayName("서울 failBack 실패 테스트") @Test void failBackRequestTest() { - flushRedis(); - createMockWebServer(createFailResponse()); + createMockWebServer(createFailSeoulApiResponse()); + when(observatoryProvider.getObservatory(anyDouble(), anyDouble())) + .thenReturn(createObservatoryResponse("서울역", "서울 XXX OOO")); - assertThrows(BusinessException.class, () -> airQualityService.getAirQuality()); + assertThrows(BusinessException.class, + () -> airQualityService.getAirQuality(127.4170933, 36.35218384)); } - @DisplayName("failBack 성공 테스트") + @DisplayName("서울 failBack 성공 테스트") @Test void failBackSuccessTest() { - flushRedis(); - createMockWebServer(createFailResponse()); - redisTemplate.opsForValue().set(key, cachedAirQuality); - CachedAirQuality before = (CachedAirQuality) redisTemplate.opsForValue().get(key); + createMockWebServer(createFailSeoulApiResponse()); + setCache("seoul", true); + CachedAirQuality before = getCache("seoul"); + when(observatoryProvider.getObservatory(anyDouble(), anyDouble())) + .thenReturn(createObservatoryResponse("서울역", "서울 XXX OOO")); airQualityService.updateAriQuality(); - CachedAirQuality after = (CachedAirQuality) redisTemplate.opsForValue().get(key); + CachedAirQuality after = getCache("seoul"); + + assertThat(after.isFresh()).isFalse(); + assertThat(after.getData()).usingRecursiveComparison().isEqualTo(before.getData()); + + } + + @Test + @DisplayName("서울 외 지역 응답 성공 테스트") + void successRegionRequestTest() { + createMockWebServer(createFailRegionApiResponse()); + when(observatoryProvider.getObservatory(anyDouble(), anyDouble())) + .thenReturn(createObservatoryResponse("설성면", "경기 이천시 설성면")); + when(regionAirQualityProvider.geAllAirQuality(anyString())) + .thenReturn(Optional.of(createRegionAirQuality("13", "23"))); + + AirQualityResponse response = airQualityService.getAirQuality(127.0421, 37.5716); + + assertThat(response).isNotNull(); + assertThat(response.getGrade()).isEqualTo("보통"); + assertThat(response.getPm10()).isEqualTo("23"); + assertThat(response.getPm25()).isEqualTo("13"); + } + + @DisplayName("서울외 지역 두 번째 요청 시 캐싱된 데이터 반환") + @Test + void successSecondRegionRequestTest() { + int count = mockWebServer.getRequestCount(); + createMockWebServer(createRegionApiResponse()); + setCache("111123", true); + when(observatoryProvider.getObservatory(anyDouble(), anyDouble())) + .thenReturn(createObservatoryResponse("설성면", "경기 이천시 설성면")); + + AirQualityResponse response = airQualityService.getAirQuality(127.4170933, 36.35218384); + + assertThat(response).isNotNull(); + assertThat(response.getGrade()).isEqualTo("보통"); + assertThat(response.getPm10()).isEqualTo("61"); + assertThat(response.getPm25()).isEqualTo("20"); + assertThat(mockWebServer.getRequestCount()).isEqualTo(count); + } + + @DisplayName("서울외 지역 failBack 실패 테스트") + @Test + void RegionFailBackRequestTest() { + createMockWebServer(createFailRegionApiResponse()); + when(observatoryProvider.getObservatory(anyDouble(), anyDouble())) + .thenReturn(createObservatoryResponse("설성면", "경기 이천시 설성면")); + + assertThrows(BusinessException.class, + () -> airQualityService.getAirQuality(127.4170933, 36.35218384)); + } + + @DisplayName("서울외 지역 failBack 성공 테스트") + @Test + void RegionFailBackSuccessTest() { + createMockWebServer(createRegionApiResponse()); + setCache("111123", true); + CachedAirQuality before = getCache("111123"); + when(observatoryProvider.getObservatory(anyDouble(), anyDouble())) + .thenReturn(createObservatoryResponse("설성면", "경기 이천시 설성면")); + + airQualityService.updateRegionAriQuality( + createObservatoryResponse("설성면", "경기 이천시 설성면").get()); + CachedAirQuality after = getCache("111123"); assertThat(after.isFresh()).isFalse(); - assertThat(after.getData()).isEqualTo(before.getData()); + assertThat(after.getData()).usingRecursiveComparison().isEqualTo(before.getData()); + + } + + @DisplayName("Observatory null 입력시 예외 발생 테스트") + @Test + void observatoryNullTest() { + when(observatoryProvider.getObservatory(anyDouble(), anyDouble())) + .thenReturn(Optional.empty()); + + assertThrows(BusinessException.class, + () -> airQualityService.getAirQuality(127.4170933, 36.35218384)); } private void flushRedis() { @@ -142,43 +240,181 @@ private void createMockWebServer(String response) { .setResponseCode(200)); } - private String createResponse() { + private String createSeoulApiResponse() { return """ - { - "ListAvgOfSeoulAirQualityService": { - "list_total_count": 1, - "RESULT": { - "CODE": "INFO-000", - "MESSAGE": "정상 처리되었습니다" - }, - "row": [ - { - "GRADE": "보통", - "IDEX_MVL": "55", - "POLLUTANT": "O3", - "NITROGEN": 0.017, - "OZONE": 0.036, - "CARBON": 0.4, - "SULFUROUS": 0.003, - "PM10": 23, - "PM25": 12 - } - ] + { + "ListAvgOfSeoulAirQualityService": { + "list_total_count": 1, + "RESULT": { + "CODE": "INFO-000", + "MESSAGE": "정상 처리되었습니다" + }, + "row": [ + { + "GRADE": "보통", + "IDEX_MVL": "55", + "POLLUTANT": "O3", + "NITROGEN": 0.017, + "OZONE": 0.036, + "CARBON": 0.4, + "SULFUROUS": 0.003, + "PM10": 23, + "PM25": 12 } + ] + } + } + """; + } + + + private String createFailSeoulApiResponse() { + return """ + +