diff --git a/backend/src/main/java/gc/mda/kcg/collector/aircraft/OpenSkyCollector.java b/backend/src/main/java/gc/mda/kcg/collector/aircraft/OpenSkyCollector.java index aa9a773..126b596 100644 --- a/backend/src/main/java/gc/mda/kcg/collector/aircraft/OpenSkyCollector.java +++ b/backend/src/main/java/gc/mda/kcg/collector/aircraft/OpenSkyCollector.java @@ -3,6 +3,7 @@ package gc.mda.kcg.collector.aircraft; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import gc.mda.kcg.collector.CollectorStatusTracker; +import gc.mda.kcg.config.AppProperties; import gc.mda.kcg.domain.aircraft.AircraftDto; import gc.mda.kcg.domain.aircraft.AircraftPosition; import gc.mda.kcg.domain.aircraft.AircraftPositionRepository; @@ -11,9 +12,15 @@ import lombok.extern.slf4j.Slf4j; import org.locationtech.jts.geom.Coordinate; import org.locationtech.jts.geom.GeometryFactory; import org.locationtech.jts.geom.PrecisionModel; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.HttpMethod; +import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Component; +import org.springframework.util.LinkedMultiValueMap; +import org.springframework.util.MultiValueMap; import org.springframework.web.client.RestTemplate; import java.time.Instant; @@ -24,22 +31,23 @@ import java.util.List; @RequiredArgsConstructor public class OpenSkyCollector { - private static final String BASE_URL = "https://opensky-network.org/api"; - - // 이란/중동 bbox private static final String IRAN_PARAMS = "lamin=24&lomin=30&lamax=42&lomax=62"; - // 한국/동아시아 bbox private static final String KOREA_PARAMS = "lamin=20&lomin=115&lamax=45&lomax=145"; + private static final long TOKEN_REFRESH_MARGIN_SEC = 120; private final RestTemplate restTemplate; private final ObjectMapper objectMapper; private final AircraftCacheStore cacheStore; private final AircraftPositionRepository positionRepository; private final CollectorStatusTracker tracker; + private final AppProperties appProperties; private final GeometryFactory geometryFactory = new GeometryFactory(new PrecisionModel(), 4326); - @Scheduled(initialDelay = 30_000, fixedDelay = 60_000) + private String accessToken; + private Instant tokenExpiresAt = Instant.EPOCH; + + @Scheduled(initialDelay = 30_000, fixedDelay = 300_000) public void collectIran() { List aircraft = fetchStates(IRAN_PARAMS); if (!aircraft.isEmpty()) { @@ -48,12 +56,12 @@ public class OpenSkyCollector { persistAll(aircraft, "opensky", "iran"); tracker.recordSuccess("opensky-iran", "iran", aircraft.size()); } else { - tracker.recordFailure("opensky-iran", "iran", "빈 응답 또는 429"); + tracker.recordFailure("opensky-iran", "iran", "빈 응답 또는 인증 실패"); } log.debug("OpenSky 이란 수집 완료: {} 항공기", aircraft.size()); } - @Scheduled(initialDelay = 45_000, fixedDelay = 60_000) + @Scheduled(initialDelay = 180_000, fixedDelay = 300_000) public void collectKorea() { List aircraft = fetchStates(KOREA_PARAMS); if (!aircraft.isEmpty()) { @@ -62,15 +70,23 @@ public class OpenSkyCollector { persistAll(aircraft, "opensky", "korea"); tracker.recordSuccess("opensky-korea", "korea", aircraft.size()); } else { - tracker.recordFailure("opensky-korea", "korea", "빈 응답 또는 429"); + tracker.recordFailure("opensky-korea", "korea", "빈 응답 또는 인증 실패"); } log.debug("OpenSky 한국 수집 완료: {} 항공기", aircraft.size()); } private List fetchStates(String bboxParams) { try { - String url = BASE_URL + "/states/all?" + bboxParams; - ResponseEntity response = restTemplate.getForEntity(url, String.class); + String token = getAccessToken(); + String url = appProperties.getCollector().getOpenSkyBaseUrl() + "/states/all?" + bboxParams; + + HttpHeaders headers = new HttpHeaders(); + if (token != null) { + headers.setBearerAuth(token); + } + HttpEntity entity = new HttpEntity<>(headers); + + ResponseEntity response = restTemplate.exchange(url, HttpMethod.GET, entity, String.class); if (response.getStatusCode().value() == 429) { log.warn("OpenSky 429 rate limited, 스킵"); return List.of(); @@ -83,6 +99,47 @@ public class OpenSkyCollector { } } + private synchronized String getAccessToken() { + if (accessToken != null && Instant.now().isBefore(tokenExpiresAt.minusSeconds(TOKEN_REFRESH_MARGIN_SEC))) { + return accessToken; + } + + String clientId = appProperties.getCollector().getOpenSkyClientId(); + String clientSecret = appProperties.getCollector().getOpenSkyClientSecret(); + if (clientId == null || clientSecret == null) { + log.debug("OpenSky OAuth2 미설정, 익명 모드로 동작"); + return null; + } + + try { + String authUrl = appProperties.getCollector().getOpenSkyAuthUrl(); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_FORM_URLENCODED); + + MultiValueMap body = new LinkedMultiValueMap<>(); + body.add("grant_type", "client_credentials"); + body.add("client_id", clientId); + body.add("client_secret", clientSecret); + + HttpEntity> request = new HttpEntity<>(body, headers); + ResponseEntity response = restTemplate.postForEntity(authUrl, request, String.class); + + JsonNode json = objectMapper.readTree(response.getBody()); + accessToken = json.get("access_token").asText(); + int expiresIn = json.get("expires_in").asInt(); + tokenExpiresAt = Instant.now().plusSeconds(expiresIn); + + log.info("OpenSky OAuth2 토큰 발급 완료 (만료: {}초)", expiresIn); + return accessToken; + } catch (Exception e) { + log.warn("OpenSky OAuth2 토큰 발급 실패: {}, 익명 모드로 폴백", e.getMessage()); + accessToken = null; + tokenExpiresAt = Instant.EPOCH; + return null; + } + } + private void persistAll(List aircraft, String source, String region) { try { Instant now = Instant.now(); diff --git a/backend/src/main/java/gc/mda/kcg/config/AppProperties.java b/backend/src/main/java/gc/mda/kcg/config/AppProperties.java index 5fb83d8..2f144dc 100644 --- a/backend/src/main/java/gc/mda/kcg/config/AppProperties.java +++ b/backend/src/main/java/gc/mda/kcg/config/AppProperties.java @@ -40,6 +40,9 @@ public class AppProperties { public static class Collector { private String airplanesLiveBaseUrl = "https://api.airplanes.live/v2"; private String openSkyBaseUrl = "https://opensky-network.org/api"; + private String openSkyAuthUrl = "https://auth.opensky-network.org/auth/realms/opensky-network/protocol/openid-connect/token"; + private String openSkyClientId; + private String openSkyClientSecret; private String gdeltBaseUrl = "https://api.gdeltproject.org/api/v2/doc/doc"; private String googleNewsBaseUrl = "https://news.google.com/rss/search"; private String celestrakBaseUrl = "https://celestrak.org/NORAD/elements/gp.php"; diff --git a/backend/src/main/resources/application-local.yml.example b/backend/src/main/resources/application-local.yml.example index 0fa5862..70490de 100644 --- a/backend/src/main/resources/application-local.yml.example +++ b/backend/src/main/resources/application-local.yml.example @@ -11,3 +11,6 @@ app: client-id: YOUR_GOOGLE_CLIENT_ID auth: allowed-domain: gcsc.co.kr + collector: + open-sky-client-id: YOUR_OPENSKY_CLIENT_ID + open-sky-client-secret: YOUR_OPENSKY_CLIENT_SECRET diff --git a/backend/src/main/resources/application-prod.yml b/backend/src/main/resources/application-prod.yml index b62d921..71ae3c5 100644 --- a/backend/src/main/resources/application-prod.yml +++ b/backend/src/main/resources/application-prod.yml @@ -11,5 +11,8 @@ app: client-id: ${GOOGLE_CLIENT_ID} auth: allowed-domain: ${AUTH_ALLOWED_DOMAIN:gcsc.co.kr} + collector: + open-sky-client-id: ${OPENSKY_CLIENT_ID:} + open-sky-client-secret: ${OPENSKY_CLIENT_SECRET:} cors: allowed-origins: http://localhost:5173,https://kcg.gc-si.dev