diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index fed34a5..3569db2 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -53,6 +53,8 @@ jobs: GOOGLE_CLIENT_ID: ${{ secrets.GOOGLE_CLIENT_ID }} JWT_SECRET: ${{ secrets.JWT_SECRET }} DB_PASSWORD: ${{ secrets.DB_PASSWORD }} + OPENSKY_CLIENT_ID: ${{ secrets.OPENSKY_CLIENT_ID }} + OPENSKY_CLIENT_SECRET: ${{ secrets.OPENSKY_CLIENT_SECRET }} run: | DEPLOY_DIR=/deploy/kcg-backend mkdir -p $DEPLOY_DIR/backup @@ -68,6 +70,8 @@ jobs: [ -n "$GOOGLE_CLIENT_ID" ] && echo "GOOGLE_CLIENT_ID=${GOOGLE_CLIENT_ID}" >> $DEPLOY_DIR/.env [ -n "$JWT_SECRET" ] && echo "JWT_SECRET=${JWT_SECRET}" >> $DEPLOY_DIR/.env [ -n "$DB_PASSWORD" ] && echo "DB_PASSWORD=${DB_PASSWORD}" >> $DEPLOY_DIR/.env + [ -n "$OPENSKY_CLIENT_ID" ] && echo "OPENSKY_CLIENT_ID=${OPENSKY_CLIENT_ID}" >> $DEPLOY_DIR/.env + [ -n "$OPENSKY_CLIENT_SECRET" ] && echo "OPENSKY_CLIENT_SECRET=${OPENSKY_CLIENT_SECRET}" >> $DEPLOY_DIR/.env # JAR 내부에 application-prod.yml이 있으면 외부 파일 제거 if unzip -l backend/target/kcg.jar | grep -q 'application-prod.yml$'; then 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/collector/osint/OsintCollector.java b/backend/src/main/java/gc/mda/kcg/collector/osint/OsintCollector.java index e58b2b9..deb437f 100644 --- a/backend/src/main/java/gc/mda/kcg/collector/osint/OsintCollector.java +++ b/backend/src/main/java/gc/mda/kcg/collector/osint/OsintCollector.java @@ -118,8 +118,7 @@ public class OsintCollector { if (articleUrl == null || title == null || title.isBlank()) continue; if (osintFeedRepository.existsBySourceAndSourceUrl("gdelt", articleUrl)) continue; - if (osintFeedRepository.existsByRegionAndTitleAndCollectedAtAfter( - region, title, Instant.now().minus(24, ChronoUnit.HOURS))) continue; + if (osintFeedRepository.existsByTitle(title)) continue; String seendate = article.path("seendate").asText(null); Instant publishedAt = parseGdeltDate(seendate); @@ -140,8 +139,12 @@ public class OsintCollector { .publishedAt(publishedAt) .build(); - osintFeedRepository.save(feed); - saved++; + try { + osintFeedRepository.save(feed); + saved++; + } catch (Exception ex) { + log.debug("GDELT 중복 스킵: {}", title); + } } log.debug("GDELT {} 저장: {}건", region, saved); return saved; @@ -184,8 +187,7 @@ public class OsintCollector { if (link == null || title == null || title.isBlank()) continue; if (osintFeedRepository.existsBySourceAndSourceUrl(sourceName, link)) continue; - if (osintFeedRepository.existsByRegionAndTitleAndCollectedAtAfter( - region, title, Instant.now().minus(24, ChronoUnit.HOURS))) continue; + if (osintFeedRepository.existsByTitle(title)) continue; Instant publishedAt = parseRssDate(pubDate); @@ -201,8 +203,12 @@ public class OsintCollector { .publishedAt(publishedAt) .build(); - osintFeedRepository.save(feed); - saved++; + try { + osintFeedRepository.save(feed); + saved++; + } catch (Exception ex) { + log.debug("Google News 중복 스킵: {}", title); + } } log.debug("Google News {} ({}) 저장: {}건", region, lang, saved); return saved; @@ -265,6 +271,6 @@ public class OsintCollector { } private String encodeQuery(String query) { - return query.replace(" ", "+"); + return java.net.URLEncoder.encode(query, StandardCharsets.UTF_8); } } 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/java/gc/mda/kcg/domain/osint/OsintFeedRepository.java b/backend/src/main/java/gc/mda/kcg/domain/osint/OsintFeedRepository.java index b87a231..d3355aa 100644 --- a/backend/src/main/java/gc/mda/kcg/domain/osint/OsintFeedRepository.java +++ b/backend/src/main/java/gc/mda/kcg/domain/osint/OsintFeedRepository.java @@ -9,7 +9,7 @@ public interface OsintFeedRepository extends JpaRepository { boolean existsBySourceAndSourceUrl(String source, String sourceUrl); - boolean existsByRegionAndTitleAndCollectedAtAfter(String region, String title, Instant since); + boolean existsByTitle(String title); List findByRegionAndCollectedAtAfterOrderByPublishedAtDesc(String region, Instant since); } 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 diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index adbe1a5..3f935f5 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -21,6 +21,14 @@ - 센서차트 기본 숨김 - CCTV 레이어 리팩토링 +## [2026-03-19.2] + +### 추가 +- OpenSky OAuth2 Client Credentials 인증 (토큰 자동 갱신) + +### 변경 +- OpenSky 수집 주기 60초 → 300초 (일일 크레딧 소비 11,520 → 2,304) + ## [2026-03-19] ### 변경