release: 2026-03-17.5 (5건 커밋) #23
@ -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> {
|
||||
}
|
||||
40
database/migration/002_aircraft_positions.sql
Normal file
40
database/migration/002_aircraft_positions.sql
Normal file
@ -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,21 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [2026-03-17.5]
|
||||
|
||||
### 추가
|
||||
- 백엔드 항공기 수집기 (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 [
|
||||
// ═══════════════════════════════════════════
|
||||
21
frontend/src/services/aircraftApi.ts
Normal file
21
frontend/src/services/aircraftApi.ts
Normal file
@ -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,
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user