feat(aircraft): 항공기 백엔드 수집기 구현 + 프론트엔드 전환 #21

병합
htlee feature/aircraft-backend-collector 에서 develop 로 2 commits 를 머지했습니다 2026-03-17 16:53:50 +09:00
24개의 변경된 파일1073개의 추가작업 그리고 513개의 파일을 삭제

파일 보기

@ -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

파일 보기

@ -1 +1 @@
java=17.0.18-amzn
java=21.0.9-amzn

파일 보기

@ -19,7 +19,7 @@
<description>KCG Monitoring Dashboard Backend</description>
<properties>
<java.version>17</java.version>
<java.version>21</java.version>
<jjwt.version>0.12.6</jjwt.version>
</properties>
@ -45,6 +45,12 @@
<scope>runtime</scope>
</dependency>
<!-- PostGIS / Hibernate Spatial -->
<dependency>
<groupId>org.hibernate.orm</groupId>
<artifactId>hibernate-spatial</artifactId>
</dependency>
<!-- Lombok -->
<dependency>
<groupId>org.projectlombok</groupId>

파일 보기

@ -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<String, List<AircraftDto>> regionCache = new ConcurrentHashMap<>();
// 소스별 원본 버퍼 (region source aircraft list)
private final Map<String, Map<String, List<AircraftDto>>> sourceBuffers = new ConcurrentHashMap<>();
// 마지막 갱신 시각
private final Map<String, Long> 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<AircraftDto> 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<String, List<AircraftDto>> sources = sourceBuffers.getOrDefault(region, Map.of());
List<AircraftDto> fromLive = sources.getOrDefault(SOURCE_LIVE, List.of());
List<AircraftDto> fromMil = sources.getOrDefault(SOURCE_MIL, List.of());
List<AircraftDto> fromOpenSky = sources.getOrDefault(SOURCE_OPENSKY, List.of());
Map<String, AircraftDto> 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<AircraftDto> get(String region) {
return regionCache.getOrDefault(region, List.of());
}
/**
* 마지막 갱신 시각 조회.
*/
public long getLastUpdated(String region) {
return lastUpdated.getOrDefault(region, 0L);
}
}

파일 보기

@ -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<String, AircraftCategory> 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<String, AircraftCategory> 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;
}
}

파일 보기

@ -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<RegionQuery> 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<RegionQuery> 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<String, List<AircraftDto>> iranRegionBuffers = new ConcurrentHashMap<>();
private final Map<String, List<AircraftDto>> 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<RegionQuery> queries, Map<String, List<AircraftDto>> buffers) {
log.info("Airplanes.live {} 초기 로드 시작 ({} 지점)", region, queries.size());
for (int i = 0; i < queries.size(); i++) {
if (i > 0) sleep(DELAY_MS);
List<AircraftDto> 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<AircraftDto> ac = fetchPoint(IRAN_QUERIES.get(idx));
iranRegionBuffers.put(String.valueOf(idx), ac);
}
mergePointResults("iran", iranRegionBuffers);
// mil 엔드포인트 (이란 bbox 필터)
sleep(DELAY_MS);
List<AircraftDto> 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<AircraftDto> ac = fetchPoint(KOREA_QUERIES.get(idx));
koreaRegionBuffers.put(String.valueOf(idx), ac);
}
mergePointResults("korea", koreaRegionBuffers);
sleep(DELAY_MS);
List<AircraftDto> 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<String, List<AircraftDto>> buffers) {
Set<String> seen = new HashSet<>();
List<AircraftDto> merged = new ArrayList<>();
for (List<AircraftDto> 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<AircraftDto> mergeForPersistence(Map<String, List<AircraftDto>> pointBuffers, List<AircraftDto> mil) {
Map<String, AircraftDto> map = new LinkedHashMap<>();
for (List<AircraftDto> 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<AircraftDto> fetchPoint(RegionQuery q) {
try {
String url = String.format("%s/point/%s/%s/%d", BASE_URL, q.lat(), q.lon(), q.radius());
ResponseEntity<String> 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<AircraftDto> fetchMilitary(double latMin, double latMax, double lngMin, double lngMax) {
try {
String url = BASE_URL + "/mil";
ResponseEntity<String> 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<AircraftDto> aircraft, String source, String region) {
if (aircraft.isEmpty()) return;
try {
Instant now = Instant.now();
List<AircraftPosition> 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) {}
}

파일 보기

@ -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<AircraftDto> parse(JsonNode root) {
JsonNode acArray = root.path("ac");
if (acArray.isMissingNode() || !acArray.isArray()) {
return List.of();
}
List<AircraftDto> 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;
}
}

파일 보기

@ -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<AircraftDto> 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<AircraftDto> 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<AircraftDto> fetchStates(String bboxParams) {
try {
String url = BASE_URL + "/states/all?" + bboxParams;
ResponseEntity<String> 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<AircraftDto> aircraft, String source, String region) {
try {
Instant now = Instant.now();
List<AircraftPosition> 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());
}
}
}

파일 보기

@ -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<AircraftDto> parse(JsonNode root) {
JsonNode states = root.path("states");
if (states.isMissingNode() || !states.isArray()) {
return List.of();
}
List<AircraftDto> 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;
}
}

파일 보기

@ -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;
}
}

파일 보기

@ -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();
}
}

파일 보기

@ -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;
}
}
}

파일 보기

@ -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<String> VALID_REGIONS = Set.of("iran", "korea");
private final AircraftCacheStore cacheStore;
@GetMapping
public ResponseEntity<Map<String, Object>> getAircraft(
@RequestParam(defaultValue = "iran") String region) {
if (!VALID_REGIONS.contains(region)) {
return ResponseEntity.badRequest()
.body(Map.of("error", "유효하지 않은 region: " + region));
}
List<AircraftDto> aircraft = cacheStore.get(region);
long lastUpdated = cacheStore.getLastUpdated(region);
return ResponseEntity.ok(Map.of(
"region", region,
"count", aircraft.size(),
"lastUpdated", lastUpdated,
"aircraft", aircraft
));
}
}

파일 보기

@ -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;
}

파일 보기

@ -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();
}
}
}

파일 보기

@ -0,0 +1,6 @@
package gc.mda.kcg.domain.aircraft;
import org.springframework.data.jpa.repository.JpaRepository;
public interface AircraftPositionRepository extends JpaRepository<AircraftPosition, Long> {
}

파일 보기

@ -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);

파일 보기

@ -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/ \

파일 보기

@ -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]
### 추가

파일 보기

@ -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<string, Aircraft>();
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<string, Aircraft>();
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();

파일 보기

@ -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<string, AircraftCategory> = {
'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<Aircraft[]> {
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<Aircraft[]> {
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<Aircraft[]> {
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 [
// ═══════════════════════════════════════════

파일 보기

@ -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<Aircraft[]> {
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 [];
}
}

파일 보기

@ -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<string, AircraftCategory> = {
'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<Aircraft[]> {
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<Aircraft[]> {
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<string, { ac: Aircraft[]; ts: number }>();
let krInitialDone = false;
let krQueryIdx = 0;
let krInitPromise: Promise<void> | null = null;
async function doKrInitialLoad(): Promise<void> {
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<Aircraft[]> {
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<string>();
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<string, { ac: Aircraft[]; ts: number }>();
const CACHE_TTL = 60_000; // 60s per region cache
let initialLoadDone = false;
let queryIndex = 0;
let initialLoadPromise: Promise<void> | null = null;
function delay(ms: number) {
return new Promise(r => setTimeout(r, ms));
}
async function fetchOneRegion(q: { lat: number; lon: number; radius: number }): Promise<Aircraft[]> {
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<void> {
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<Aircraft[]> {
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<string>();
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<Aircraft[]> {
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<Aircraft[]> {
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 [];
}
}

파일 보기

@ -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,