|
|
|
|
@ -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<AircraftDto> 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<AircraftDto> 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<AircraftDto> fetchStates(String bboxParams) {
|
|
|
|
|
try {
|
|
|
|
|
String url = BASE_URL + "/states/all?" + bboxParams;
|
|
|
|
|
ResponseEntity<String> 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<Void> entity = new HttpEntity<>(headers);
|
|
|
|
|
|
|
|
|
|
ResponseEntity<String> 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<String, String> body = new LinkedMultiValueMap<>();
|
|
|
|
|
body.add("grant_type", "client_credentials");
|
|
|
|
|
body.add("client_id", clientId);
|
|
|
|
|
body.add("client_secret", clientSecret);
|
|
|
|
|
|
|
|
|
|
HttpEntity<MultiValueMap<String, String>> request = new HttpEntity<>(body, headers);
|
|
|
|
|
ResponseEntity<String> 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<AircraftDto> aircraft, String source, String region) {
|
|
|
|
|
try {
|
|
|
|
|
Instant now = Instant.now();
|
|
|
|
|
|