From 1e4c51e76b7f3e5443eb81bfb31ae12abbf45e49 Mon Sep 17 00:00:00 2001 From: htlee Date: Tue, 17 Mar 2026 16:49:23 +0900 Subject: [PATCH 1/3] =?UTF-8?q?feat(aircraft):=20=ED=95=AD=EA=B3=B5?= =?UTF-8?q?=EA=B8=B0=20=EB=B0=B1=EC=97=94=EB=93=9C=20=EC=88=98=EC=A7=91?= =?UTF-8?q?=EA=B8=B0=20=EA=B5=AC=ED=98=84=20+=20=ED=94=84=EB=A1=A0?= =?UTF-8?q?=ED=8A=B8=EC=97=94=EB=93=9C=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Airplanes.live / OpenSky @Scheduled 수집기 (60초 주기) - 인메모리 캐시 + PostGIS DB 적재 (향후 리플레이용) - GET /api/aircraft?region=iran|korea REST API - 프론트엔드 LIVE→백엔드 API, REPLAY→샘플 전용 - JDK 17→21 업그레이드 (pom, sdkmanrc, CI/CD, systemd) --- .gitea/workflows/deploy.yml | 4 +- backend/.sdkmanrc | 2 +- backend/pom.xml | 8 +- .../aircraft/AircraftCacheStore.java | 106 ++++++ .../aircraft/AircraftClassifier.java | 118 +++++++ .../aircraft/AirplanesLiveCollector.java | 268 +++++++++++++++ .../aircraft/AirplanesLiveParser.java | 110 ++++++ .../collector/aircraft/OpenSkyCollector.java | 109 ++++++ .../kcg/collector/aircraft/OpenSkyParser.java | 55 +++ .../java/gc/mda/kcg/config/AppProperties.java | 10 + .../gc/mda/kcg/config/RestTemplateConfig.java | 20 ++ .../kcg/domain/aircraft/AircraftCategory.java | 27 ++ .../domain/aircraft/AircraftController.java | 43 +++ .../mda/kcg/domain/aircraft/AircraftDto.java | 28 ++ .../kcg/domain/aircraft/AircraftPosition.java | 78 +++++ .../aircraft/AircraftPositionRepository.java | 6 + database/migration/002_aircraft_positions.sql | 40 +++ deploy/kcg-backend.service | 2 +- frontend/src/App.tsx | 66 +--- .../opensky.ts => data/sampleAircraft.ts} | 122 +------ frontend/src/services/aircraftApi.ts | 21 ++ frontend/src/services/airplaneslive.ts | 318 ------------------ frontend/vite.config.ts | 12 - 23 files changed, 1060 insertions(+), 513 deletions(-) create mode 100644 backend/src/main/java/gc/mda/kcg/collector/aircraft/AircraftCacheStore.java create mode 100644 backend/src/main/java/gc/mda/kcg/collector/aircraft/AircraftClassifier.java create mode 100644 backend/src/main/java/gc/mda/kcg/collector/aircraft/AirplanesLiveCollector.java create mode 100644 backend/src/main/java/gc/mda/kcg/collector/aircraft/AirplanesLiveParser.java create mode 100644 backend/src/main/java/gc/mda/kcg/collector/aircraft/OpenSkyCollector.java create mode 100644 backend/src/main/java/gc/mda/kcg/collector/aircraft/OpenSkyParser.java create mode 100644 backend/src/main/java/gc/mda/kcg/config/RestTemplateConfig.java create mode 100644 backend/src/main/java/gc/mda/kcg/domain/aircraft/AircraftCategory.java create mode 100644 backend/src/main/java/gc/mda/kcg/domain/aircraft/AircraftController.java create mode 100644 backend/src/main/java/gc/mda/kcg/domain/aircraft/AircraftDto.java create mode 100644 backend/src/main/java/gc/mda/kcg/domain/aircraft/AircraftPosition.java create mode 100644 backend/src/main/java/gc/mda/kcg/domain/aircraft/AircraftPositionRepository.java create mode 100644 database/migration/002_aircraft_positions.sql rename frontend/src/{services/opensky.ts => data/sampleAircraft.ts} (90%) create mode 100644 frontend/src/services/aircraftApi.ts delete mode 100644 frontend/src/services/airplaneslive.ts diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml index 2787de1..2d3e787 100644 --- a/.gitea/workflows/deploy.yml +++ b/.gitea/workflows/deploy.yml @@ -31,10 +31,10 @@ jobs: echo "Frontend deployed at $(date '+%Y-%m-%d %H:%M:%S')" # ═══ Backend ═══ - - name: Install JDK 17 + Maven + - name: Install JDK 21 + Maven run: | apt-get update -qq - apt-get install -y -qq openjdk-17-jdk-headless maven > /dev/null 2>&1 + apt-get install -y -qq openjdk-21-jdk-headless maven > /dev/null 2>&1 java -version mvn --version diff --git a/backend/.sdkmanrc b/backend/.sdkmanrc index a4417cd..8bea3c1 100644 --- a/backend/.sdkmanrc +++ b/backend/.sdkmanrc @@ -1 +1 @@ -java=17.0.18-amzn +java=21.0.9-amzn diff --git a/backend/pom.xml b/backend/pom.xml index c99e425..48d4a08 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -19,7 +19,7 @@ KCG Monitoring Dashboard Backend - 17 + 21 0.12.6 @@ -45,6 +45,12 @@ runtime + + + org.hibernate.orm + hibernate-spatial + + org.projectlombok diff --git a/backend/src/main/java/gc/mda/kcg/collector/aircraft/AircraftCacheStore.java b/backend/src/main/java/gc/mda/kcg/collector/aircraft/AircraftCacheStore.java new file mode 100644 index 0000000..965ea60 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/collector/aircraft/AircraftCacheStore.java @@ -0,0 +1,106 @@ +package gc.mda.kcg.collector.aircraft; + +import gc.mda.kcg.domain.aircraft.AircraftDto; +import org.springframework.stereotype.Component; + +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; + +/** + * 항공기 데이터 인메모리 캐시. + * 소스별 원본을 유지하고, 병합된 최종 결과를 Controller에 제공. + */ +@Component +public class AircraftCacheStore { + + // 최종 병합 결과 (Controller가 읽는 데이터) + private final Map> regionCache = new ConcurrentHashMap<>(); + + // 소스별 원본 버퍼 (region → source → aircraft list) + private final Map>> sourceBuffers = new ConcurrentHashMap<>(); + + // 마지막 갱신 시각 + private final Map lastUpdated = new ConcurrentHashMap<>(); + + public static final String SOURCE_LIVE = "live"; + public static final String SOURCE_MIL = "mil"; + public static final String SOURCE_OPENSKY = "opensky"; + + /** + * 특정 소스의 데이터 갱신. + */ + public void updateSource(String region, String source, List aircraft) { + sourceBuffers + .computeIfAbsent(region, k -> new ConcurrentHashMap<>()) + .put(source, Collections.unmodifiableList(new ArrayList<>(aircraft))); + } + + /** + * 소스별 데이터를 병합하여 regionCache를 갱신. + * 병합 우선순위: live > mil > opensky (icao24 기준 중복제거) + */ + public void mergeAndUpdate(String region) { + Map> sources = sourceBuffers.getOrDefault(region, Map.of()); + + List fromLive = sources.getOrDefault(SOURCE_LIVE, List.of()); + List fromMil = sources.getOrDefault(SOURCE_MIL, List.of()); + List fromOpenSky = sources.getOrDefault(SOURCE_OPENSKY, List.of()); + + Map merged = new LinkedHashMap<>(); + + // 1순위: live (point/radius — 가장 상세) + for (AircraftDto a : fromLive) { + merged.put(a.getIcao24(), a); + } + + // 2순위: mil — 기존 항목은 category/typecode 보강, 없는 항목은 추가 + for (AircraftDto m : fromMil) { + AircraftDto existing = merged.get(m.getIcao24()); + if (existing != null) { + // mil 데이터로 category/typecode 보강 + merged.put(m.getIcao24(), AircraftDto.builder() + .icao24(existing.getIcao24()) + .callsign(existing.getCallsign()) + .lat(existing.getLat()) + .lng(existing.getLng()) + .altitude(existing.getAltitude()) + .velocity(existing.getVelocity()) + .heading(existing.getHeading()) + .verticalRate(existing.getVerticalRate()) + .onGround(existing.isOnGround()) + .category(m.getCategory()) + .typecode(m.getTypecode() != null ? m.getTypecode() : existing.getTypecode()) + .typeDesc(existing.getTypeDesc()) + .registration(existing.getRegistration()) + .operator(existing.getOperator()) + .squawk(existing.getSquawk()) + .lastSeen(existing.getLastSeen()) + .build()); + } else { + merged.put(m.getIcao24(), m); + } + } + + // 3순위: opensky — 없는 항목만 추가 + for (AircraftDto a : fromOpenSky) { + merged.putIfAbsent(a.getIcao24(), a); + } + + regionCache.put(region, Collections.unmodifiableList(new ArrayList<>(merged.values()))); + lastUpdated.put(region, System.currentTimeMillis()); + } + + /** + * 병합된 최종 결과 조회. + */ + public List get(String region) { + return regionCache.getOrDefault(region, List.of()); + } + + /** + * 마지막 갱신 시각 조회. + */ + public long getLastUpdated(String region) { + return lastUpdated.getOrDefault(region, 0L); + } +} diff --git a/backend/src/main/java/gc/mda/kcg/collector/aircraft/AircraftClassifier.java b/backend/src/main/java/gc/mda/kcg/collector/aircraft/AircraftClassifier.java new file mode 100644 index 0000000..916c768 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/collector/aircraft/AircraftClassifier.java @@ -0,0 +1,118 @@ +package gc.mda.kcg.collector.aircraft; + +import gc.mda.kcg.domain.aircraft.AircraftCategory; + +import java.util.Map; + +/** + * 항공기 카테고리 분류 유틸리티. + * 프론트엔드 airplaneslive.ts / opensky.ts의 분류 로직을 이식. + */ +public final class AircraftClassifier { + + private AircraftClassifier() {} + + // Airplanes.live 타입코드 기반 분류 + private static final Map TYPE_CATEGORY_MAP = Map.ofEntries( + // Fighter + Map.entry("F16", AircraftCategory.FIGHTER), + Map.entry("F15", AircraftCategory.FIGHTER), + Map.entry("F15E", AircraftCategory.FIGHTER), + Map.entry("FA18", AircraftCategory.FIGHTER), + Map.entry("F22", AircraftCategory.FIGHTER), + Map.entry("F35", AircraftCategory.FIGHTER), + Map.entry("F14", AircraftCategory.FIGHTER), + Map.entry("EF2K", AircraftCategory.FIGHTER), + Map.entry("RFAL", AircraftCategory.FIGHTER), + Map.entry("SU27", AircraftCategory.FIGHTER), + Map.entry("SU30", AircraftCategory.FIGHTER), + Map.entry("SU35", AircraftCategory.FIGHTER), + // Tanker + Map.entry("KC10", AircraftCategory.TANKER), + Map.entry("KC30", AircraftCategory.TANKER), + Map.entry("KC46", AircraftCategory.TANKER), + Map.entry("K35R", AircraftCategory.TANKER), + Map.entry("KC35", AircraftCategory.TANKER), + Map.entry("A332", AircraftCategory.TANKER), + // Surveillance + Map.entry("RC135", AircraftCategory.SURVEILLANCE), + Map.entry("E3", AircraftCategory.SURVEILLANCE), + Map.entry("E8", AircraftCategory.SURVEILLANCE), + Map.entry("RQ4", AircraftCategory.SURVEILLANCE), + Map.entry("MQ9", AircraftCategory.SURVEILLANCE), + Map.entry("P8", AircraftCategory.SURVEILLANCE), + Map.entry("EP3", AircraftCategory.SURVEILLANCE), + Map.entry("E6", AircraftCategory.SURVEILLANCE), + Map.entry("U2", AircraftCategory.SURVEILLANCE), + // Cargo + Map.entry("C17", AircraftCategory.CARGO), + Map.entry("C5", AircraftCategory.CARGO), + Map.entry("C130", AircraftCategory.CARGO), + Map.entry("C2", AircraftCategory.CARGO) + ); + + // OpenSky 콜사인 prefix 기반 분류 + private static final Map CALLSIGN_PREFIX_MAP = Map.ofEntries( + Map.entry("RCH", AircraftCategory.CARGO), + Map.entry("REACH", AircraftCategory.CARGO), + Map.entry("KING", AircraftCategory.TANKER), + Map.entry("ETHYL", AircraftCategory.TANKER), + Map.entry("STEEL", AircraftCategory.TANKER), + Map.entry("PACK", AircraftCategory.TANKER), + Map.entry("NCHO", AircraftCategory.TANKER), + Map.entry("JULEP", AircraftCategory.TANKER), + Map.entry("IRON", AircraftCategory.FIGHTER), + Map.entry("VIPER", AircraftCategory.FIGHTER), + Map.entry("RAGE", AircraftCategory.FIGHTER), + Map.entry("DEATH", AircraftCategory.FIGHTER), + Map.entry("TOXIN", AircraftCategory.SURVEILLANCE), + Map.entry("OLIVE", AircraftCategory.SURVEILLANCE), + Map.entry("COBRA", AircraftCategory.SURVEILLANCE), + Map.entry("FORTE", AircraftCategory.SURVEILLANCE), + Map.entry("HAWK", AircraftCategory.SURVEILLANCE), + Map.entry("GLOBAL", AircraftCategory.SURVEILLANCE), + Map.entry("SNTRY", AircraftCategory.SURVEILLANCE), + Map.entry("WIZARD", AircraftCategory.SURVEILLANCE), + Map.entry("REAPER", AircraftCategory.SURVEILLANCE), + Map.entry("DRAGON", AircraftCategory.SURVEILLANCE), + Map.entry("DOOM", AircraftCategory.MILITARY), + Map.entry("EVAC", AircraftCategory.MILITARY), + Map.entry("SAM", AircraftCategory.MILITARY), + Map.entry("EXEC", AircraftCategory.MILITARY), + Map.entry("NAVY", AircraftCategory.MILITARY), + Map.entry("TOPCT", AircraftCategory.MILITARY) + ); + + /** + * 타입코드 기반 분류 (Airplanes.live). + * 타입코드에 군용 코드가 포함되어 있으면 해당 카테고리 반환. + */ + public static AircraftCategory classifyByTypecode(String typecode) { + if (typecode == null || typecode.isBlank()) return AircraftCategory.CIVILIAN; + String upper = typecode.toUpperCase(); + for (var entry : TYPE_CATEGORY_MAP.entrySet()) { + if (upper.contains(entry.getKey())) return entry.getValue(); + } + return AircraftCategory.CIVILIAN; + } + + /** + * dbFlags 기반 군용기 판별 (Airplanes.live). + * dbFlags & 1 == 1이면 DB에서 군용기로 분류된 항공기. + */ + public static boolean isMilitaryByDbFlags(int dbFlags) { + return (dbFlags & 1) == 1; + } + + /** + * 콜사인 prefix 기반 분류 (OpenSky). + */ + public static AircraftCategory classifyByCallsign(String callsign) { + if (callsign == null || callsign.isBlank()) return AircraftCategory.CIVILIAN; + String upper = callsign.toUpperCase().trim(); + for (var entry : CALLSIGN_PREFIX_MAP.entrySet()) { + if (upper.startsWith(entry.getKey())) return entry.getValue(); + } + return AircraftCategory.CIVILIAN; + } +} diff --git a/backend/src/main/java/gc/mda/kcg/collector/aircraft/AirplanesLiveCollector.java b/backend/src/main/java/gc/mda/kcg/collector/aircraft/AirplanesLiveCollector.java new file mode 100644 index 0000000..2064798 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/collector/aircraft/AirplanesLiveCollector.java @@ -0,0 +1,268 @@ +package gc.mda.kcg.collector.aircraft; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import gc.mda.kcg.domain.aircraft.AircraftDto; +import gc.mda.kcg.domain.aircraft.AircraftPosition; +import gc.mda.kcg.domain.aircraft.AircraftPositionRepository; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +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.ResponseEntity; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +import java.time.Instant; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.atomic.AtomicInteger; + +@Slf4j +@Component +@RequiredArgsConstructor +public class AirplanesLiveCollector { + + private static final String BASE_URL = "https://api.airplanes.live/v2"; + private static final int DELAY_MS = 1500; + private static final int BACKOFF_MS = 5000; + private static final int POINTS_PER_CYCLE = 2; + + private final RestTemplate restTemplate; + private final ObjectMapper objectMapper; + private final AircraftCacheStore cacheStore; + private final AircraftPositionRepository positionRepository; + + private final GeometryFactory geometryFactory = new GeometryFactory(new PrecisionModel(), 4326); + + // 이란/중동 쿼리 포인트 (프론트엔드 LIVE_QUERIES) + private static final List IRAN_QUERIES = List.of( + new RegionQuery(35.5, 51.5, 250), + new RegionQuery(30.0, 52.0, 250), + new RegionQuery(33.0, 57.0, 250), + new RegionQuery(33.5, 44.0, 250), + new RegionQuery(33.0, 36.0, 250), + new RegionQuery(38.0, 40.0, 250), + new RegionQuery(25.0, 55.0, 250), + new RegionQuery(26.0, 44.0, 250), + new RegionQuery(16.0, 44.0, 250), + new RegionQuery(22.0, 62.0, 250) + ); + + // 한국 쿼리 포인트 (프론트엔드 KR_QUERIES) + private static final List KOREA_QUERIES = List.of( + new RegionQuery(37.5, 127.0, 250), + new RegionQuery(35.0, 129.0, 250), + new RegionQuery(33.5, 126.5, 200), + new RegionQuery(36.0, 127.0, 250), + new RegionQuery(38.5, 128.0, 200), + new RegionQuery(35.5, 131.0, 250), + new RegionQuery(34.0, 124.0, 200), + new RegionQuery(40.0, 130.0, 250) + ); + + // 이란 mil 필터 bbox + private static final double IRAN_LAT_MIN = 12, IRAN_LAT_MAX = 42; + private static final double IRAN_LNG_MIN = 25, IRAN_LNG_MAX = 68; + + // 한국 mil 필터 bbox + private static final double KOREA_LAT_MIN = 15, KOREA_LAT_MAX = 50; + private static final double KOREA_LNG_MIN = 110, KOREA_LNG_MAX = 150; + + private final AtomicInteger iranQueryIdx = new AtomicInteger(0); + private final AtomicInteger koreaQueryIdx = new AtomicInteger(0); + + // 지역별 서브캐시 (point/radius 결과를 지점 인덱스별로 보관) + private final Map> iranRegionBuffers = new ConcurrentHashMap<>(); + private final Map> koreaRegionBuffers = new ConcurrentHashMap<>(); + + private volatile boolean iranInitDone = false; + private volatile boolean koreaInitDone = false; + + @PostConstruct + public void init() { + Thread.ofVirtual().name("aircraft-init").start(() -> { + doInitialLoad("iran", IRAN_QUERIES, iranRegionBuffers); + iranInitDone = true; + mergePointResults("iran", iranRegionBuffers); + log.info("Airplanes.live 이란 초기 로드 완료"); + + doInitialLoad("korea", KOREA_QUERIES, koreaRegionBuffers); + koreaInitDone = true; + mergePointResults("korea", koreaRegionBuffers); + log.info("Airplanes.live 한국 초기 로드 완료"); + }); + } + + private void doInitialLoad(String region, List queries, Map> buffers) { + log.info("Airplanes.live {} 초기 로드 시작 ({} 지점)", region, queries.size()); + for (int i = 0; i < queries.size(); i++) { + if (i > 0) sleep(DELAY_MS); + List ac = fetchPoint(queries.get(i)); + buffers.put(String.valueOf(i), ac); + log.debug(" {} 지점 {}: {} 항공기", region, i, ac.size()); + } + } + + /** + * 이란 지역 수집 — 60초마다 2개 지점 순환 갱신 + mil + */ + @Scheduled(initialDelay = 60_000, fixedDelay = 60_000) + public void collectIran() { + if (!iranInitDone) return; + + // 2개 지점 순환 갱신 + int startIdx = iranQueryIdx.getAndUpdate(i -> (i + POINTS_PER_CYCLE) % IRAN_QUERIES.size()); + for (int i = 0; i < POINTS_PER_CYCLE; i++) { + int idx = (startIdx + i) % IRAN_QUERIES.size(); + if (i > 0) sleep(DELAY_MS); + List ac = fetchPoint(IRAN_QUERIES.get(idx)); + iranRegionBuffers.put(String.valueOf(idx), ac); + } + mergePointResults("iran", iranRegionBuffers); + + // mil 엔드포인트 (이란 bbox 필터) + sleep(DELAY_MS); + List mil = fetchMilitary(IRAN_LAT_MIN, IRAN_LAT_MAX, IRAN_LNG_MIN, IRAN_LNG_MAX); + cacheStore.updateSource("iran", AircraftCacheStore.SOURCE_MIL, mil); + cacheStore.mergeAndUpdate("iran"); + + // DB 적재 + persistAll(mergeForPersistence(iranRegionBuffers, mil), "airplaneslive", "iran"); + log.debug("Airplanes.live 이란 수집 완료 — 캐시: {}", cacheStore.get("iran").size()); + } + + /** + * 한국 지역 수집 — 60초마다 2개 지점 순환 갱신 + mil + */ + @Scheduled(initialDelay = 65_000, fixedDelay = 60_000) + public void collectKorea() { + if (!koreaInitDone) return; + + int startIdx = koreaQueryIdx.getAndUpdate(i -> (i + POINTS_PER_CYCLE) % KOREA_QUERIES.size()); + for (int i = 0; i < POINTS_PER_CYCLE; i++) { + int idx = (startIdx + i) % KOREA_QUERIES.size(); + if (i > 0) sleep(DELAY_MS); + List ac = fetchPoint(KOREA_QUERIES.get(idx)); + koreaRegionBuffers.put(String.valueOf(idx), ac); + } + mergePointResults("korea", koreaRegionBuffers); + + sleep(DELAY_MS); + List mil = fetchMilitary(KOREA_LAT_MIN, KOREA_LAT_MAX, KOREA_LNG_MIN, KOREA_LNG_MAX); + cacheStore.updateSource("korea", AircraftCacheStore.SOURCE_MIL, mil); + cacheStore.mergeAndUpdate("korea"); + + persistAll(mergeForPersistence(koreaRegionBuffers, mil), "airplaneslive", "korea"); + log.debug("Airplanes.live 한국 수집 완료 — 캐시: {}", cacheStore.get("korea").size()); + } + + /** + * point/radius 결과를 dedup 후 캐시의 live 소스로 업데이트. + */ + private void mergePointResults(String region, Map> buffers) { + Set seen = new HashSet<>(); + List merged = new ArrayList<>(); + for (List ac : buffers.values()) { + for (AircraftDto a : ac) { + if (seen.add(a.getIcao24())) merged.add(a); + } + } + cacheStore.updateSource(region, AircraftCacheStore.SOURCE_LIVE, merged); + cacheStore.mergeAndUpdate(region); + } + + /** + * DB 적재용 병합 (point + mil dedup) + */ + private List mergeForPersistence(Map> pointBuffers, List mil) { + Map map = new LinkedHashMap<>(); + for (List ac : pointBuffers.values()) { + for (AircraftDto a : ac) map.putIfAbsent(a.getIcao24(), a); + } + for (AircraftDto m : mil) map.putIfAbsent(m.getIcao24(), m); + return new ArrayList<>(map.values()); + } + + private List fetchPoint(RegionQuery q) { + try { + String url = String.format("%s/point/%s/%s/%d", BASE_URL, q.lat(), q.lon(), q.radius()); + ResponseEntity response = restTemplate.getForEntity(url, String.class); + if (response.getStatusCode().value() == 429) { + log.warn("Airplanes.live 429 rate limited, 백오프 {}ms", BACKOFF_MS); + sleep(BACKOFF_MS); + return List.of(); + } + JsonNode root = objectMapper.readTree(response.getBody()); + return AirplanesLiveParser.parse(root); + } catch (Exception e) { + log.warn("Airplanes.live point 요청 실패 ({},{}): {}", q.lat(), q.lon(), e.getMessage()); + return List.of(); + } + } + + private List fetchMilitary(double latMin, double latMax, double lngMin, double lngMax) { + try { + String url = BASE_URL + "/mil"; + ResponseEntity response = restTemplate.getForEntity(url, String.class); + if (response.getStatusCode().value() == 429) { + log.warn("Airplanes.live mil 429, 스킵"); + return List.of(); + } + JsonNode root = objectMapper.readTree(response.getBody()); + return AirplanesLiveParser.parse(root).stream() + .filter(a -> a.getLat() >= latMin && a.getLat() <= latMax + && a.getLng() >= lngMin && a.getLng() <= lngMax) + .toList(); + } catch (Exception e) { + log.warn("Airplanes.live mil 요청 실패: {}", e.getMessage()); + return List.of(); + } + } + + private void persistAll(List aircraft, String source, String region) { + if (aircraft.isEmpty()) return; + try { + Instant now = Instant.now(); + List entities = aircraft.stream() + .map(dto -> AircraftPosition.builder() + .icao24(dto.getIcao24()) + .callsign(dto.getCallsign()) + .position(geometryFactory.createPoint(new Coordinate(dto.getLng(), dto.getLat()))) + .altitude(dto.getAltitude()) + .velocity(dto.getVelocity()) + .heading(dto.getHeading()) + .verticalRate(dto.getVerticalRate()) + .onGround(dto.isOnGround()) + .category(dto.getCategory()) + .typecode(dto.getTypecode()) + .typeDesc(dto.getTypeDesc()) + .registration(dto.getRegistration()) + .operator(dto.getOperator()) + .squawk(dto.getSquawk()) + .source(source) + .region(region) + .collectedAt(now) + .lastSeen(Instant.ofEpochMilli(dto.getLastSeen())) + .build()) + .toList(); + positionRepository.saveAll(entities); + log.debug("DB 적재 완료: {} {} — {} 건", source, region, entities.size()); + } catch (Exception e) { + log.error("DB 적재 실패 ({} {}): {}", source, region, e.getMessage()); + } + } + + private static void sleep(int ms) { + try { + Thread.sleep(ms); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } + + record RegionQuery(double lat, double lon, int radius) {} +} diff --git a/backend/src/main/java/gc/mda/kcg/collector/aircraft/AirplanesLiveParser.java b/backend/src/main/java/gc/mda/kcg/collector/aircraft/AirplanesLiveParser.java new file mode 100644 index 0000000..c35f5eb --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/collector/aircraft/AirplanesLiveParser.java @@ -0,0 +1,110 @@ +package gc.mda.kcg.collector.aircraft; + +import com.fasterxml.jackson.databind.JsonNode; +import gc.mda.kcg.domain.aircraft.AircraftCategory; +import gc.mda.kcg.domain.aircraft.AircraftDto; + +import java.util.ArrayList; +import java.util.List; + +/** + * Airplanes.live API 응답 JSON → AircraftDto 변환. + * 프론트엔드 airplaneslive.ts:parseAirplanesLive 이식. + */ +public final class AirplanesLiveParser { + + private AirplanesLiveParser() {} + + private static final double FT_TO_M = 0.3048; + private static final double KNOTS_TO_MS = 0.5144; + private static final double FPM_TO_MS = 0.00508; + + /** + * Airplanes.live JSON 응답을 파싱하여 AircraftDto 리스트 반환. + * 응답 구조: { "ac": [ { hex, flight, lat, lon, alt_baro, gs, ... } ] } + */ + public static List parse(JsonNode root) { + JsonNode acArray = root.path("ac"); + if (acArray.isMissingNode() || !acArray.isArray()) { + return List.of(); + } + + List result = new ArrayList<>(); + long now = System.currentTimeMillis(); + + for (JsonNode a : acArray) { + if (a.path("lat").isMissingNode() || a.path("lon").isMissingNode()) continue; + Double lat = getDouble(a, "lat"); + Double lon = getDouble(a, "lon"); + if (lat == null || lon == null) continue; + + String typecode = getString(a, "t", ""); + int dbFlags = getInt(a, "dbFlags", 0); + + AircraftCategory category = AircraftClassifier.classifyByTypecode(typecode); + if (category == AircraftCategory.CIVILIAN && AircraftClassifier.isMilitaryByDbFlags(dbFlags)) { + category = AircraftCategory.MILITARY; + } + + // alt_baro: 숫자(feet) 또는 문자열 "ground" + double altitude = 0; + boolean onGround = false; + JsonNode altNode = a.path("alt_baro"); + if (altNode.isTextual() && "ground".equals(altNode.asText())) { + onGround = true; + } else if (altNode.isNumber()) { + altitude = altNode.asDouble() * FT_TO_M; + } + + double heading = a.has("track") ? a.path("track").asDouble(0) + : a.path("nav_heading").asDouble(0); + int seen = getInt(a, "seen", 0); + + result.add(AircraftDto.builder() + .icao24(getString(a, "hex", "")) + .callsign(getString(a, "flight", "").trim()) + .lat(lat) + .lng(lon) + .altitude(altitude) + .velocity(getDouble(a, "gs", 0) * KNOTS_TO_MS) + .heading(heading) + .verticalRate(getDouble(a, "baro_rate", 0) * FPM_TO_MS) + .onGround(onGround) + .category(category) + .typecode(typecode.isEmpty() ? null : typecode) + .typeDesc(getStringOrNull(a, "desc")) + .registration(getStringOrNull(a, "r")) + .operator(getStringOrNull(a, "ownOp")) + .squawk(getStringOrNull(a, "squawk")) + .lastSeen(now - (long) seen * 1000) + .build()); + } + return result; + } + + private static String getString(JsonNode node, String field, String defaultVal) { + JsonNode v = node.path(field); + return v.isMissingNode() || v.isNull() ? defaultVal : v.asText(defaultVal); + } + + private static String getStringOrNull(JsonNode node, String field) { + JsonNode v = node.path(field); + if (v.isMissingNode() || v.isNull() || v.asText().isBlank()) return null; + return v.asText(); + } + + private static Double getDouble(JsonNode node, String field) { + JsonNode v = node.path(field); + return v.isNumber() ? v.asDouble() : null; + } + + private static double getDouble(JsonNode node, String field, double defaultVal) { + JsonNode v = node.path(field); + return v.isNumber() ? v.asDouble() : defaultVal; + } + + private static int getInt(JsonNode node, String field, int defaultVal) { + JsonNode v = node.path(field); + return v.isNumber() ? v.asInt() : defaultVal; + } +} 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 new file mode 100644 index 0000000..5ddab10 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/collector/aircraft/OpenSkyCollector.java @@ -0,0 +1,109 @@ +package gc.mda.kcg.collector.aircraft; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import gc.mda.kcg.domain.aircraft.AircraftDto; +import gc.mda.kcg.domain.aircraft.AircraftPosition; +import gc.mda.kcg.domain.aircraft.AircraftPositionRepository; +import lombok.RequiredArgsConstructor; +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.ResponseEntity; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +import java.time.Instant; +import java.util.List; + +@Slf4j +@Component +@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 final RestTemplate restTemplate; + private final ObjectMapper objectMapper; + private final AircraftCacheStore cacheStore; + private final AircraftPositionRepository positionRepository; + + private final GeometryFactory geometryFactory = new GeometryFactory(new PrecisionModel(), 4326); + + @Scheduled(initialDelay = 30_000, fixedDelay = 60_000) + public void collectIran() { + List aircraft = fetchStates(IRAN_PARAMS); + if (!aircraft.isEmpty()) { + cacheStore.updateSource("iran", AircraftCacheStore.SOURCE_OPENSKY, aircraft); + cacheStore.mergeAndUpdate("iran"); + persistAll(aircraft, "opensky", "iran"); + } + log.debug("OpenSky 이란 수집 완료: {} 항공기", aircraft.size()); + } + + @Scheduled(initialDelay = 45_000, fixedDelay = 60_000) + public void collectKorea() { + List aircraft = fetchStates(KOREA_PARAMS); + if (!aircraft.isEmpty()) { + cacheStore.updateSource("korea", AircraftCacheStore.SOURCE_OPENSKY, aircraft); + cacheStore.mergeAndUpdate("korea"); + persistAll(aircraft, "opensky", "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); + if (response.getStatusCode().value() == 429) { + log.warn("OpenSky 429 rate limited, 스킵"); + return List.of(); + } + JsonNode root = objectMapper.readTree(response.getBody()); + return OpenSkyParser.parse(root); + } catch (Exception e) { + log.warn("OpenSky 요청 실패: {}", e.getMessage()); + return List.of(); + } + } + + private void persistAll(List aircraft, String source, String region) { + try { + Instant now = Instant.now(); + List entities = aircraft.stream() + .map(dto -> AircraftPosition.builder() + .icao24(dto.getIcao24()) + .callsign(dto.getCallsign()) + .position(geometryFactory.createPoint(new Coordinate(dto.getLng(), dto.getLat()))) + .altitude(dto.getAltitude()) + .velocity(dto.getVelocity()) + .heading(dto.getHeading()) + .verticalRate(dto.getVerticalRate()) + .onGround(dto.isOnGround()) + .category(dto.getCategory()) + .typecode(dto.getTypecode()) + .typeDesc(dto.getTypeDesc()) + .registration(dto.getRegistration()) + .operator(dto.getOperator()) + .squawk(dto.getSquawk()) + .source(source) + .region(region) + .collectedAt(now) + .lastSeen(Instant.ofEpochMilli(dto.getLastSeen())) + .build()) + .toList(); + positionRepository.saveAll(entities); + log.debug("DB 적재 완료: {} {} — {} 건", source, region, entities.size()); + } catch (Exception e) { + log.error("DB 적재 실패 ({} {}): {}", source, region, e.getMessage()); + } + } +} diff --git a/backend/src/main/java/gc/mda/kcg/collector/aircraft/OpenSkyParser.java b/backend/src/main/java/gc/mda/kcg/collector/aircraft/OpenSkyParser.java new file mode 100644 index 0000000..0263ce4 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/collector/aircraft/OpenSkyParser.java @@ -0,0 +1,55 @@ +package gc.mda.kcg.collector.aircraft; + +import com.fasterxml.jackson.databind.JsonNode; +import gc.mda.kcg.domain.aircraft.AircraftCategory; +import gc.mda.kcg.domain.aircraft.AircraftDto; + +import java.util.ArrayList; +import java.util.List; + +/** + * OpenSky Network API 응답 JSON → AircraftDto 변환. + * 프론트엔드 opensky.ts:parseOpenSkyResponse 이식. + * + * 응답 구조: { "time": 1234, "states": [ [icao24, callsign, ..., lat, lng, alt, ...], ... ] } + * 인덱스: 0=icao24, 1=callsign, 4=lastSeen, 5=lng, 6=lat, 7=altitude(m), + * 8=onGround, 9=velocity(m/s), 10=heading, 11=verticalRate + */ +public final class OpenSkyParser { + + private OpenSkyParser() {} + + public static List parse(JsonNode root) { + JsonNode states = root.path("states"); + if (states.isMissingNode() || !states.isArray()) { + return List.of(); + } + + List result = new ArrayList<>(); + + for (JsonNode s : states) { + if (!s.isArray() || s.size() < 12) continue; + + // lat(6), lng(5) 필수 + if (s.get(6).isNull() || s.get(5).isNull()) continue; + + String callsign = s.get(1).isNull() ? "" : s.get(1).asText("").trim(); + AircraftCategory category = AircraftClassifier.classifyByCallsign(callsign); + + result.add(AircraftDto.builder() + .icao24(s.get(0).asText("")) + .callsign(callsign) + .lat(s.get(6).asDouble()) + .lng(s.get(5).asDouble()) + .altitude(s.get(7).isNull() ? 0 : s.get(7).asDouble()) + .velocity(s.get(9).isNull() ? 0 : s.get(9).asDouble()) + .heading(s.get(10).isNull() ? 0 : s.get(10).asDouble()) + .verticalRate(s.get(11).isNull() ? 0 : s.get(11).asDouble()) + .onGround(s.get(8).isNull() ? false : s.get(8).asBoolean()) + .category(category) + .lastSeen(s.get(4).isNull() ? System.currentTimeMillis() : s.get(4).asLong() * 1000) + .build()); + } + return result; + } +} 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 686b67d..18acc07 100644 --- a/backend/src/main/java/gc/mda/kcg/config/AppProperties.java +++ b/backend/src/main/java/gc/mda/kcg/config/AppProperties.java @@ -14,6 +14,7 @@ public class AppProperties { private Jwt jwt = new Jwt(); private Google google = new Google(); private Auth auth = new Auth(); + private Collector collector = new Collector(); @Getter @Setter @@ -33,4 +34,13 @@ public class AppProperties { public static class Auth { private String allowedDomain; } + + @Getter + @Setter + public static class Collector { + private String airplanesLiveBaseUrl = "https://api.airplanes.live/v2"; + private String openSkyBaseUrl = "https://opensky-network.org/api"; + private int requestDelayMs = 1500; + private int backoffMs = 5000; + } } diff --git a/backend/src/main/java/gc/mda/kcg/config/RestTemplateConfig.java b/backend/src/main/java/gc/mda/kcg/config/RestTemplateConfig.java new file mode 100644 index 0000000..ee3adee --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/config/RestTemplateConfig.java @@ -0,0 +1,20 @@ +package gc.mda.kcg.config; + +import org.springframework.boot.web.client.RestTemplateBuilder; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestTemplate; + +import java.time.Duration; + +@Configuration +public class RestTemplateConfig { + + @Bean + public RestTemplate restTemplate(RestTemplateBuilder builder) { + return builder + .setConnectTimeout(Duration.ofSeconds(5)) + .setReadTimeout(Duration.ofSeconds(10)) + .build(); + } +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/aircraft/AircraftCategory.java b/backend/src/main/java/gc/mda/kcg/domain/aircraft/AircraftCategory.java new file mode 100644 index 0000000..f6bfc04 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/aircraft/AircraftCategory.java @@ -0,0 +1,27 @@ +package gc.mda.kcg.domain.aircraft; + +import com.fasterxml.jackson.annotation.JsonValue; + +public enum AircraftCategory { + MILITARY, + TANKER, + SURVEILLANCE, + FIGHTER, + CARGO, + CIVILIAN, + UNKNOWN; + + @JsonValue + public String toValue() { + return name().toLowerCase(); + } + + public static AircraftCategory fromString(String value) { + if (value == null) return UNKNOWN; + try { + return valueOf(value.toUpperCase()); + } catch (IllegalArgumentException e) { + return UNKNOWN; + } + } +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/aircraft/AircraftController.java b/backend/src/main/java/gc/mda/kcg/domain/aircraft/AircraftController.java new file mode 100644 index 0000000..1846d1e --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/aircraft/AircraftController.java @@ -0,0 +1,43 @@ +package gc.mda.kcg.domain.aircraft; + +import gc.mda.kcg.collector.aircraft.AircraftCacheStore; +import lombok.RequiredArgsConstructor; +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; + +import java.util.List; +import java.util.Map; +import java.util.Set; + +@RestController +@RequestMapping("/api/aircraft") +@RequiredArgsConstructor +public class AircraftController { + + private static final Set VALID_REGIONS = Set.of("iran", "korea"); + + private final AircraftCacheStore cacheStore; + + @GetMapping + public ResponseEntity> getAircraft( + @RequestParam(defaultValue = "iran") String region) { + + if (!VALID_REGIONS.contains(region)) { + return ResponseEntity.badRequest() + .body(Map.of("error", "유효하지 않은 region: " + region)); + } + + List aircraft = cacheStore.get(region); + long lastUpdated = cacheStore.getLastUpdated(region); + + return ResponseEntity.ok(Map.of( + "region", region, + "count", aircraft.size(), + "lastUpdated", lastUpdated, + "aircraft", aircraft + )); + } +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/aircraft/AircraftDto.java b/backend/src/main/java/gc/mda/kcg/domain/aircraft/AircraftDto.java new file mode 100644 index 0000000..a7984b9 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/aircraft/AircraftDto.java @@ -0,0 +1,28 @@ +package gc.mda.kcg.domain.aircraft; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +@JsonInclude(JsonInclude.Include.NON_NULL) +public class AircraftDto { + + private String icao24; + private String callsign; + private double lat; + private double lng; + private double altitude; + private double velocity; + private double heading; + private double verticalRate; + private boolean onGround; + private AircraftCategory category; + private String typecode; + private String typeDesc; + private String registration; + private String operator; + private String squawk; + private long lastSeen; +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/aircraft/AircraftPosition.java b/backend/src/main/java/gc/mda/kcg/domain/aircraft/AircraftPosition.java new file mode 100644 index 0000000..42f5639 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/aircraft/AircraftPosition.java @@ -0,0 +1,78 @@ +package gc.mda.kcg.domain.aircraft; + +import jakarta.persistence.*; +import lombok.*; +import org.locationtech.jts.geom.Point; + +import java.time.Instant; + +@Entity +@Table(name = "aircraft_positions", schema = "kcg") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class AircraftPosition { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, length = 6) + private String icao24; + + @Column(length = 16) + private String callsign; + + @Column(columnDefinition = "geometry(Point, 4326)", nullable = false) + private Point position; + + private Double altitude; + private Double velocity; + private Double heading; + + @Column(name = "vertical_rate") + private Double verticalRate; + + @Column(name = "on_ground") + private Boolean onGround; + + @Enumerated(EnumType.STRING) + @Column(length = 16) + private AircraftCategory category; + + @Column(length = 16) + private String typecode; + + @Column(name = "type_desc", length = 128) + private String typeDesc; + + @Column(length = 16) + private String registration; + + @Column(length = 128) + private String operator; + + @Column(length = 4) + private String squawk; + + @Column(nullable = false, length = 16) + private String source; + + @Column(nullable = false, length = 16) + private String region; + + @Column(name = "collected_at", nullable = false) + private Instant collectedAt; + + @Column(name = "last_seen") + private Instant lastSeen; + + @PrePersist + protected void onCreate() { + if (collectedAt == null) { + collectedAt = Instant.now(); + } + } +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/aircraft/AircraftPositionRepository.java b/backend/src/main/java/gc/mda/kcg/domain/aircraft/AircraftPositionRepository.java new file mode 100644 index 0000000..2442340 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/aircraft/AircraftPositionRepository.java @@ -0,0 +1,6 @@ +package gc.mda.kcg.domain.aircraft; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface AircraftPositionRepository extends JpaRepository { +} diff --git a/database/migration/002_aircraft_positions.sql b/database/migration/002_aircraft_positions.sql new file mode 100644 index 0000000..95f131a --- /dev/null +++ b/database/migration/002_aircraft_positions.sql @@ -0,0 +1,40 @@ +-- 002: 항공기 위치 이력 테이블 (PostGIS) +-- 리플레이 기능을 위한 시계열 위치 데이터 저장 + +SET search_path TO kcg; + +-- PostGIS 확장 활성화 +CREATE EXTENSION IF NOT EXISTS postgis; + +-- 항공기 위치 이력 테이블 +CREATE TABLE IF NOT EXISTS aircraft_positions ( + id BIGSERIAL PRIMARY KEY, + icao24 VARCHAR(6) NOT NULL, + callsign VARCHAR(16), + position geometry(Point, 4326) NOT NULL, + altitude DOUBLE PRECISION, + velocity DOUBLE PRECISION, + heading DOUBLE PRECISION, + vertical_rate DOUBLE PRECISION, + on_ground BOOLEAN DEFAULT FALSE, + category VARCHAR(16), + typecode VARCHAR(16), + type_desc VARCHAR(128), + registration VARCHAR(16), + operator VARCHAR(128), + squawk VARCHAR(4), + source VARCHAR(16) NOT NULL, + region VARCHAR(16) NOT NULL, + collected_at TIMESTAMP NOT NULL DEFAULT NOW(), + last_seen TIMESTAMP +); + +-- GiST 공간 인덱스 +CREATE INDEX IF NOT EXISTS idx_aircraft_pos_geom ON aircraft_positions USING GIST (position); + +-- 시간 기반 인덱스 (리플레이 쿼리 최적화) +CREATE INDEX IF NOT EXISTS idx_aircraft_pos_collected ON aircraft_positions (collected_at); +CREATE INDEX IF NOT EXISTS idx_aircraft_pos_region_time ON aircraft_positions (region, collected_at); + +-- 개별 항공기 추적 +CREATE INDEX IF NOT EXISTS idx_aircraft_pos_icao24 ON aircraft_positions (icao24, collected_at); diff --git a/deploy/kcg-backend.service b/deploy/kcg-backend.service index 36f471e..3367540 100644 --- a/deploy/kcg-backend.service +++ b/deploy/kcg-backend.service @@ -8,7 +8,7 @@ User=root Group=root WorkingDirectory=/devdata/services/kcg/backend EnvironmentFile=-/devdata/services/kcg/backend/.env -ExecStart=/usr/lib/jvm/java-17-openjdk-17.0.18.0.8-1.el9.x86_64/bin/java \ +ExecStart=/usr/lib/jvm/java-21-openjdk-21.0.10.0.7-1.el9.x86_64/bin/java \ -Xms2g -Xmx4g \ -Dspring.profiles.active=prod \ -Dspring.config.additional-location=file:/devdata/services/kcg/backend/ \ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7e5c671..0f0ccfc 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -13,10 +13,9 @@ import { LayerPanel } from './components/LayerPanel'; import { useReplay } from './hooks/useReplay'; import { useMonitor } from './hooks/useMonitor'; import { fetchEvents, fetchSensorData } from './services/api'; -import { fetchAircraftOpenSky } from './services/opensky'; -import { fetchMilitaryAircraft, fetchAllAircraftLive, fetchMilitaryAircraftKorea, fetchAllAircraftLiveKorea } from './services/airplaneslive'; +import { fetchAircraftFromBackend } from './services/aircraftApi'; +import { getSampleAircraft } from './data/sampleAircraft'; import { fetchSatelliteTLE, fetchSatelliteTLEKorea, propagateAll } from './services/celestrak'; -import { fetchAircraftOpenSkyKorea } from './services/opensky'; import { fetchShips, fetchShipsKorea } from './services/ships'; import { fetchOsintFeed } from './services/osint'; import { KOREA_SUBMARINE_CABLES } from './services/submarineCable'; @@ -217,52 +216,16 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) { }, [refreshKey]); // Fetch base aircraft data - // LIVE: OpenSky (민간기) + Airplanes.live (모든 항공기 + 군용기) → 실시간 병합 - // REPLAY: OpenSky (샘플 폴백) + military → 리플레이 + // LIVE: 백엔드 /api/kcg/aircraft?region=iran 호출 + // REPLAY: 하드코딩된 시나리오 샘플 데이터 사용 useEffect(() => { const load = async () => { if (appMode === 'live') { - // 라이브: 3개 소스 동시 가져오기 — OpenSky + Airplanes.live + Military - const [opensky, allLive, mil] = await Promise.all([ - fetchAircraftOpenSky().catch(() => [] as Aircraft[]), - fetchAllAircraftLive().catch(() => [] as Aircraft[]), - fetchMilitaryAircraft().catch(() => [] as Aircraft[]), - ]); - - // 1) Airplanes.live 기본 + mil 카테고리 보강 - const milMap = new Map(mil.map(a => [a.icao24, a])); - const merged = new Map(); - - for (const ac of allLive) { - const milAc = milMap.get(ac.icao24); - if (milAc) { - merged.set(ac.icao24, { ...ac, category: milAc.category, typecode: milAc.typecode || ac.typecode }); - } else { - merged.set(ac.icao24, ac); - } - } - - // 2) mil에만 있는 항공기 추가 - for (const m of mil) { - if (!merged.has(m.icao24)) merged.set(m.icao24, m); - } - - // 3) OpenSky 데이터 추가 (Airplanes.live에 없는 항공기만) - for (const ac of opensky) { - if (!merged.has(ac.icao24)) merged.set(ac.icao24, ac); - } - - const result = Array.from(merged.values()); + const result = await fetchAircraftFromBackend('iran'); if (result.length > 0) setBaseAircraft(result); } else { - // 리플레이: 기존 로직 (OpenSky 샘플 + military) - const [opensky, mil] = await Promise.all([ - fetchAircraftOpenSky(), - fetchMilitaryAircraft(), - ]); - const milIcaos = new Set(mil.map(a => a.icao24)); - const merged = [...mil, ...opensky.filter(a => !milIcaos.has(a.icao24))]; - setBaseAircraft(merged); + // 리플레이: 하드코딩 시나리오 샘플 + setBaseAircraft(getSampleAircraft()); } }; load(); @@ -308,20 +271,7 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) { // Fetch Korea aircraft data useEffect(() => { const load = async () => { - const [opensky, allLive, mil] = await Promise.all([ - fetchAircraftOpenSkyKorea().catch(() => [] as Aircraft[]), - fetchAllAircraftLiveKorea().catch(() => [] as Aircraft[]), - fetchMilitaryAircraftKorea().catch(() => [] as Aircraft[]), - ]); - const milMap = new Map(mil.map(a => [a.icao24, a])); - const merged = new Map(); - for (const ac of allLive) { - const milAc = milMap.get(ac.icao24); - merged.set(ac.icao24, milAc ? { ...ac, category: milAc.category, typecode: milAc.typecode || ac.typecode } : ac); - } - for (const m of mil) { if (!merged.has(m.icao24)) merged.set(m.icao24, m); } - for (const ac of opensky) { if (!merged.has(ac.icao24)) merged.set(ac.icao24, ac); } - const result = Array.from(merged.values()); + const result = await fetchAircraftFromBackend('korea'); if (result.length > 0) setBaseAircraftKorea(result); }; load(); diff --git a/frontend/src/services/opensky.ts b/frontend/src/data/sampleAircraft.ts similarity index 90% rename from frontend/src/services/opensky.ts rename to frontend/src/data/sampleAircraft.ts index c625a5a..7e04231 100644 --- a/frontend/src/services/opensky.ts +++ b/frontend/src/data/sampleAircraft.ts @@ -1,122 +1,4 @@ -import type { Aircraft, AircraftCategory } from '../types'; - -// OpenSky Network API - free tier, no auth needed for basic queries -const OPENSKY_BASE = '/api/opensky/api'; - -// Middle East bounding box (lat_min, lat_max, lng_min, lng_max) -const ME_BOUNDS = { - lamin: 24, - lamax: 42, - lomin: 30, - lomax: 62, -}; - -// Known military callsign prefixes -const MILITARY_PREFIXES: Record = { - 'RCH': 'cargo', // C-17 / C-5 AMC - 'REACH': 'cargo', - 'KING': 'tanker', // HC-130 rescue tanker - 'ETHYL': 'tanker', // KC-135 - 'STEEL': 'tanker', // KC-135 - 'PACK': 'tanker', // KC-135 - 'NCHO': 'tanker', // KC-10 - 'JULEP': 'tanker', - 'IRON': 'fighter', - 'VIPER': 'fighter', - 'RAGE': 'fighter', - 'TOXIN': 'surveillance', // RC-135 - 'OLIVE': 'surveillance', // RC-135 - 'COBRA': 'surveillance', - 'FORTE': 'surveillance', // RQ-4 Global Hawk - 'HAWK': 'surveillance', - 'GLOBAL': 'surveillance', - 'SNTRY': 'surveillance', // E-3 AWACS - 'WIZARD': 'surveillance', - 'DOOM': 'military', - 'EVAC': 'military', - 'SAM': 'military', // VIP/govt - 'EXEC': 'military', - 'NAVY': 'military', - 'TOPCT': 'military', - 'DEATH': 'fighter', // B-2 Spirit - 'REAPER': 'surveillance', // MQ-9 - 'DRAGON': 'surveillance', // U-2 -}; - -function classifyAircraft(callsign: string): AircraftCategory { - const cs = callsign.toUpperCase().trim(); - for (const [prefix, cat] of Object.entries(MILITARY_PREFIXES)) { - if (cs.startsWith(prefix)) return cat; - } - return 'civilian'; -} - -function parseOpenSkyResponse(data: { time: number; states: unknown[][] | null }): Aircraft[] { - if (!data.states) return []; - - return data.states - .filter(s => s[6] !== null && s[5] !== null) // must have position - .map(s => { - const callsign = ((s[1] as string) || '').trim(); - const category = classifyAircraft(callsign); - return { - icao24: s[0] as string, - callsign, - lat: s[6] as number, - lng: s[5] as number, - altitude: (s[7] as number) || 0, - velocity: (s[9] as number) || 0, - heading: (s[10] as number) || 0, - verticalRate: (s[11] as number) || 0, - onGround: s[8] as boolean, - category, - lastSeen: (s[4] as number) * 1000, - }; - }); -} - -// OpenSky free tier: ~1 request per 10s. Shared throttle to avoid 429. -let lastOpenSkyCall = 0; -const OPENSKY_MIN_INTERVAL = 12_000; // 12s between calls - -async function throttledOpenSkyFetch(url: string): Promise { - const now = Date.now(); - const wait = OPENSKY_MIN_INTERVAL - (now - lastOpenSkyCall); - if (wait > 0) await new Promise(r => setTimeout(r, wait)); - lastOpenSkyCall = Date.now(); - - const res = await fetch(url); - if (res.status === 429) { - console.warn('OpenSky rate limited (429), skipping'); - return []; - } - if (!res.ok) throw new Error(`OpenSky ${res.status}`); - const data = await res.json(); - return parseOpenSkyResponse(data); -} - -export async function fetchAircraftOpenSky(): Promise { - try { - const url = `${OPENSKY_BASE}/states/all?lamin=${ME_BOUNDS.lamin}&lomin=${ME_BOUNDS.lomin}&lamax=${ME_BOUNDS.lamax}&lomax=${ME_BOUNDS.lomax}`; - return await throttledOpenSkyFetch(url); - } catch (err) { - console.warn('OpenSky fetch failed, using sample data:', err); - return getSampleAircraft(); - } -} - -// ═══ Korea region ═══ -const KR_BOUNDS = { lamin: 20, lamax: 45, lomin: 115, lomax: 145 }; - -export async function fetchAircraftOpenSkyKorea(): Promise { - try { - const url = `${OPENSKY_BASE}/states/all?lamin=${KR_BOUNDS.lamin}&lomin=${KR_BOUNDS.lomin}&lamax=${KR_BOUNDS.lamax}&lomax=${KR_BOUNDS.lomax}`; - return await throttledOpenSkyFetch(url); - } catch (err) { - console.warn('OpenSky Korea fetch failed:', err); - return []; - } -} +import type { Aircraft } from '../types'; // T0 = main Iranian retaliation wave const T0 = new Date('2026-03-01T12:01:00Z').getTime(); @@ -125,7 +7,7 @@ const MIN = 60_000; // ── 2026 March 1 verified aircraft deployments ── // Based on OSINT: Operation Epic Fury order of battle -function getSampleAircraft(): Aircraft[] { +export function getSampleAircraft(): Aircraft[] { const now = Date.now(); return [ // ═══════════════════════════════════════════ diff --git a/frontend/src/services/aircraftApi.ts b/frontend/src/services/aircraftApi.ts new file mode 100644 index 0000000..951978d --- /dev/null +++ b/frontend/src/services/aircraftApi.ts @@ -0,0 +1,21 @@ +import type { Aircraft } from '../types'; + +interface AircraftApiResponse { + region: string; + count: number; + lastUpdated: number; + aircraft: Aircraft[]; +} + +export async function fetchAircraftFromBackend(region: 'iran' | 'korea'): Promise { + try { + const res = await fetch(`/api/kcg/aircraft?region=${region}`, { + credentials: 'include', + }); + if (!res.ok) return []; + const data: AircraftApiResponse = await res.json(); + return data.aircraft; + } catch { + return []; + } +} diff --git a/frontend/src/services/airplaneslive.ts b/frontend/src/services/airplaneslive.ts deleted file mode 100644 index 36d24ae..0000000 --- a/frontend/src/services/airplaneslive.ts +++ /dev/null @@ -1,318 +0,0 @@ -import type { Aircraft, AircraftCategory } from '../types'; - -// Airplanes.live API - specializes in military aircraft tracking -const ADSBX_BASE = '/api/airplaneslive/v2'; - -// Known military type codes -const MILITARY_TYPES: Record = { - 'F16': 'fighter', 'F15': 'fighter', 'F15E': 'fighter', 'FA18': 'fighter', - 'F22': 'fighter', 'F35': 'fighter', 'F14': 'fighter', 'EF2K': 'fighter', - 'RFAL': 'fighter', 'SU27': 'fighter', 'SU30': 'fighter', 'SU35': 'fighter', - 'KC10': 'tanker', 'KC30': 'tanker', 'KC46': 'tanker', 'K35R': 'tanker', - 'KC35': 'tanker', 'A332': 'tanker', - 'RC135': 'surveillance', 'E3': 'surveillance', 'E8': 'surveillance', - 'RQ4': 'surveillance', 'MQ9': 'surveillance', 'P8': 'surveillance', - 'EP3': 'surveillance', 'E6': 'surveillance', 'U2': 'surveillance', - 'C17': 'cargo', 'C5': 'cargo', 'C130': 'cargo', 'C2': 'cargo', -}; - -interface AirplanesLiveAc { - hex: string; - flight?: string; - r?: string; // registration (e.g. "A6-XWC") - lat?: number; - lon?: number; - alt_baro?: number | 'ground'; - gs?: number; - track?: number; - baro_rate?: number; - t?: string; // aircraft type code (e.g. "A35K") - desc?: string; // type description (e.g. "AIRBUS A-350-1000") - ownOp?: string; // owner/operator - squawk?: string; - category?: string; - nav_heading?: number; - seen?: number; - seen_pos?: number; - dbFlags?: number; - emergency?: string; -} - -function classifyFromType(type: string): AircraftCategory { - const t = type.toUpperCase(); - for (const [code, cat] of Object.entries(MILITARY_TYPES)) { - if (t.includes(code)) return cat; - } - return 'civilian'; // 군용 타입이 아니면 민간기 -} - -function parseAirplanesLive(data: { ac?: AirplanesLiveAc[] }): Aircraft[] { - if (!data.ac) return []; - - return data.ac - .filter(a => a.lat != null && a.lon != null) - .map(a => { - const typecode = a.t || ''; - const isMilDb = (a.dbFlags ?? 0) & 1; // military flag in database - let category = classifyFromType(typecode); - if (category === 'civilian' && isMilDb) category = 'military'; - - return { - icao24: a.hex, - callsign: (a.flight || '').trim(), - lat: a.lat!, - lng: a.lon!, - altitude: a.alt_baro === 'ground' ? 0 : (a.alt_baro ?? 0) * 0.3048, // ft->m - velocity: (a.gs ?? 0) * 0.5144, // knots -> m/s - heading: a.track ?? a.nav_heading ?? 0, - verticalRate: (a.baro_rate ?? 0) * 0.00508, // fpm -> m/s - onGround: a.alt_baro === 'ground', - category, - typecode: typecode || undefined, - typeDesc: a.desc || undefined, - registration: a.r || undefined, - operator: a.ownOp || undefined, - squawk: a.squawk || undefined, - lastSeen: Date.now() - (a.seen ?? 0) * 1000, - }; - }); -} - -export async function fetchMilitaryAircraft(): Promise { - try { - // Airplanes.live military endpoint - Middle East area - const url = `${ADSBX_BASE}/mil`; - const res = await fetch(url); - if (res.status === 429) return []; - if (!res.ok) throw new Error(`Airplanes.live ${res.status}`); - const data = await res.json(); - - // Filter to Middle East + surrounding region - return parseAirplanesLive(data).filter( - a => a.lat >= 12 && a.lat <= 42 && a.lng >= 25 && a.lng <= 68, - ); - } catch (err) { - console.warn('Airplanes.live fetch failed:', err); - return []; // Will fallback to OpenSky sample data - } -} - -// ═══ Korea region military aircraft ═══ -export async function fetchMilitaryAircraftKorea(): Promise { - try { - const url = `${ADSBX_BASE}/mil`; - const res = await fetch(url); - if (res.status === 429) return []; - if (!res.ok) throw new Error(`Airplanes.live mil ${res.status}`); - const data = await res.json(); - return parseAirplanesLive(data).filter( - a => a.lat >= 15 && a.lat <= 50 && a.lng >= 110 && a.lng <= 150, - ); - } catch (err) { - console.warn('Airplanes.live Korea mil failed:', err); - return []; - } -} - -// Korea region queries for all aircraft -const KR_QUERIES = [ - { lat: 37.5, lon: 127, radius: 250 }, // 서울 / 수도권 - { lat: 35, lon: 129, radius: 250 }, // 부산 / 경남 - { lat: 33.5, lon: 126.5, radius: 200 }, // 제주 - { lat: 36, lon: 127, radius: 250 }, // 충청 / 대전 - { lat: 38.5, lon: 128, radius: 200 }, // 동해안 / 강원 - { lat: 35.5, lon: 131, radius: 250 }, // 동해 / 울릉도 - { lat: 34, lon: 124, radius: 200 }, // 서해 / 황해 - { lat: 40, lon: 130, radius: 250 }, // 일본해 / 북방 -]; - -const krLiveCache = new Map(); -let krInitialDone = false; -let krQueryIdx = 0; -let krInitPromise: Promise | null = null; - -async function doKrInitialLoad(): Promise { - console.log('Airplanes.live Korea: initial load...'); - for (let i = 0; i < KR_QUERIES.length; i++) { - try { - if (i > 0) await delay(1500); - const ac = await fetchOneRegion(KR_QUERIES[i]); - krLiveCache.set(`kr-${i}`, { ac, ts: Date.now() }); - } catch { /* skip */ } - } - krInitialDone = true; - krInitPromise = null; -} - -export async function fetchAllAircraftLiveKorea(): Promise { - const now = Date.now(); - - if (!krInitialDone) { - if (!krInitPromise) krInitPromise = doKrInitialLoad(); - } else { - const toFetch: { idx: number; q: typeof KR_QUERIES[0] }[] = []; - for (let i = 0; i < 2; i++) { - const idx = (krQueryIdx + i) % KR_QUERIES.length; - const cached = krLiveCache.get(`kr-${idx}`); - if (!cached || now - cached.ts > CACHE_TTL) { - toFetch.push({ idx, q: KR_QUERIES[idx] }); - } - } - krQueryIdx = (krQueryIdx + 2) % KR_QUERIES.length; - - for (let i = 0; i < toFetch.length; i++) { - try { - if (i > 0) await delay(1200); - const ac = await fetchOneRegion(toFetch[i].q); - krLiveCache.set(`kr-${toFetch[i].idx}`, { ac, ts: Date.now() }); - } catch { /* skip */ } - } - } - - const seen = new Set(); - const merged: Aircraft[] = []; - for (const { ac } of krLiveCache.values()) { - for (const a of ac) { - if (!seen.has(a.icao24)) { seen.add(a.icao24); merged.push(a); } - } - } - return merged; -} - -// Fetch ALL aircraft (military + civilian) in Middle East using point/radius queries -// Airplanes.live /v2/point/{lat}/{lon}/{radius_nm} — CORS *, no auth -// Rate limit: ~1 req/5s — must query sequentially with delay - -const LIVE_QUERIES = [ - // ── 이란 ── - { lat: 35.5, lon: 51.5, radius: 250 }, // 0: 테헤란 / 북부 이란 - { lat: 30, lon: 52, radius: 250 }, // 1: 이란 남부 / 시라즈 / 부셰르 - { lat: 33, lon: 57, radius: 250 }, // 2: 이란 동부 / 이스파한 → 마슈하드 - // ── 이라크 / 시리아 ── - { lat: 33.5, lon: 44, radius: 250 }, // 3: 바그다드 / 이라크 중부 - // ── 이스라엘 / 동지중해 ── - { lat: 33, lon: 36, radius: 250 }, // 4: 레바논 / 이스라엘 / 시리아 - // ── 터키 남동부 ── - { lat: 38, lon: 40, radius: 250 }, // 5: 터키 SE / 인시를릭 AB - // ── 걸프 / UAE ── - { lat: 25, lon: 55, radius: 250 }, // 6: UAE / 오만 / 호르무즈 해협 - // ── 사우디 ── - { lat: 26, lon: 44, radius: 250 }, // 7: 사우디 중부 / 리야드 - // ── 예멘 / 홍해 ── - { lat: 16, lon: 44, radius: 250 }, // 8: 예멘 / 아덴만 - // ── 아라비아해 ── - { lat: 22, lon: 62, radius: 250 }, // 9: 아라비아해 / 파키스탄 연안 -]; - -// Accumulated aircraft cache — keeps all regions, refreshed per-region -const liveCache = new Map(); -const CACHE_TTL = 60_000; // 60s per region cache -let initialLoadDone = false; -let queryIndex = 0; -let initialLoadPromise: Promise | null = null; - -function delay(ms: number) { - return new Promise(r => setTimeout(r, ms)); -} - -async function fetchOneRegion(q: { lat: number; lon: number; radius: number }): Promise { - const url = `${ADSBX_BASE}/point/${q.lat}/${q.lon}/${q.radius}`; - const res = await fetch(url); - if (res.status === 429) { - // Rate limited — back off and return empty - console.warn('Airplanes.live rate limited (429), backing off'); - await delay(5000); - return []; - } - if (!res.ok) throw new Error(`Airplanes.live ${res.status}`); - const data = await res.json(); - return parseAirplanesLive(data); -} - -// Non-blocking initial load: fetch regions in background, return partial results immediately -async function doInitialLoad(): Promise { - console.log('Airplanes.live: initial load — fetching 10 regions in background...'); - for (let i = 0; i < LIVE_QUERIES.length; i++) { - try { - if (i > 0) await delay(1500); - const ac = await fetchOneRegion(LIVE_QUERIES[i]); - liveCache.set(`${i}`, { ac, ts: Date.now() }); - console.log(` Region ${i}: ${ac.length} aircraft`); - } catch (err) { - console.warn(` Region ${i} failed:`, err); - } - } - initialLoadDone = true; - initialLoadPromise = null; -} - -export async function fetchAllAircraftLive(): Promise { - const now = Date.now(); - - if (!initialLoadDone) { - // Start background load if not started yet - if (!initialLoadPromise) { - initialLoadPromise = doInitialLoad(); - } - // Don't block — return whatever we have so far - } else { - // ── 이후: 2개 지역씩 순환 갱신 (더 가볍게) ── - const toFetch: { idx: number; q: typeof LIVE_QUERIES[0] }[] = []; - - for (let i = 0; i < 2; i++) { - const idx = (queryIndex + i) % LIVE_QUERIES.length; - const cached = liveCache.get(`${idx}`); - if (!cached || now - cached.ts > CACHE_TTL) { - toFetch.push({ idx, q: LIVE_QUERIES[idx] }); - } - } - queryIndex = (queryIndex + 2) % LIVE_QUERIES.length; - - for (let i = 0; i < toFetch.length; i++) { - try { - if (i > 0) await delay(1200); - const ac = await fetchOneRegion(toFetch[i].q); - liveCache.set(`${toFetch[i].idx}`, { ac, ts: Date.now() }); - } catch (err) { - console.warn(`Region ${toFetch[i].idx} fetch failed:`, err); - } - } - } - - // Merge all cached regions, deduplicate by icao24 - const seen = new Set(); - const merged: Aircraft[] = []; - for (const { ac } of liveCache.values()) { - for (const a of ac) { - if (!seen.has(a.icao24)) { - seen.add(a.icao24); - merged.push(a); - } - } - } - return merged; -} - -export async function fetchByCallsign(callsign: string): Promise { - try { - const url = `${ADSBX_BASE}/callsign/${callsign}`; - const res = await fetch(url); - if (!res.ok) throw new Error(`Airplanes.live ${res.status}`); - const data = await res.json(); - return parseAirplanesLive(data); - } catch { - return []; - } -} - -export async function fetchByIcao(hex: string): Promise { - try { - const url = `${ADSBX_BASE}/hex/${hex}`; - const res = await fetch(url); - if (!res.ok) throw new Error(`Airplanes.live ${res.status}`); - const data = await res.json(); - return parseAirplanesLive(data); - } catch { - return []; - } -} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index b63f596..a1bceee 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -80,18 +80,6 @@ export default defineConfig(({ mode }): UserConfig => ({ rewrite: (path) => path.replace(/^\/api\/publish-twitter/, ''), secure: true, }, - '/api/airplaneslive': { - target: 'https://api.airplanes.live', - changeOrigin: true, - rewrite: (path) => path.replace(/^\/api\/airplaneslive/, ''), - secure: true, - }, - '/api/opensky': { - target: 'https://opensky-network.org', - changeOrigin: true, - rewrite: (path) => path.replace(/^\/api\/opensky/, ''), - secure: true, - }, '/api/celestrak': { target: 'https://celestrak.org', changeOrigin: true, From 910d664eb00c24764d2cdfcf4a345efb002a74d5 Mon Sep 17 00:00:00 2001 From: htlee Date: Tue, 17 Mar 2026 16:52:27 +0900 Subject: [PATCH 2/3] =?UTF-8?q?docs:=20=EB=A6=B4=EB=A6=AC=EC=A6=88=20?= =?UTF-8?q?=EB=85=B8=ED=8A=B8=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/RELEASE-NOTES.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index ad37ced..7c3c934 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -4,6 +4,19 @@ ## [Unreleased] +### 추가 +- 백엔드 항공기 수집기 (Airplanes.live + OpenSky, @Scheduled 60초 주기) +- 인메모리 캐시 + PostGIS DB 적재 (향후 리플레이 지원) +- `GET /api/aircraft?region=iran|korea` REST API +- 프론트엔드 LIVE 모드 백엔드 API 전환 (`aircraftApi.ts`) +- DB 마이그레이션: `aircraft_positions` 테이블 (geometry + GiST 인덱스) + +### 변경 +- JDK 17 → 21 업그레이드 (pom.xml, sdkmanrc, CI/CD, systemd) +- 프론트엔드 REPLAY 모드: 외부 API 호출 제거, 샘플 데이터 전용 +- 프론트엔드 airplaneslive.ts / opensky.ts 삭제 (백엔드로 대체) +- Vite 프록시에서 airplaneslive / opensky 항목 제거 + ## [2026-03-17.4] ### 추가 From 2abe119d8fe5a967bfc24bbce03d754ddc3505a3 Mon Sep 17 00:00:00 2001 From: htlee Date: Tue, 17 Mar 2026 16:55:51 +0900 Subject: [PATCH 3/3] =?UTF-8?q?docs:=20=EB=A6=B4=EB=A6=AC=EC=A6=88=20?= =?UTF-8?q?=EB=85=B8=ED=8A=B8=20=EC=A0=95=EB=A6=AC=20(2026-03-17.5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/RELEASE-NOTES.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index 7c3c934..da11eae 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -4,6 +4,8 @@ ## [Unreleased] +## [2026-03-17.5] + ### 추가 - 백엔드 항공기 수집기 (Airplanes.live + OpenSky, @Scheduled 60초 주기) - 인메모리 캐시 + PostGIS DB 적재 (향후 리플레이 지원)