Merge pull request 'release: 2026-03-17.5 (5건 커밋)' (#23) from develop into main
Some checks failed
Deploy KCG / deploy (push) Failing after 23s
Some checks failed
Deploy KCG / deploy (push) Failing after 23s
This commit is contained in:
커밋
ddc7d542dc
@ -31,10 +31,10 @@ jobs:
|
|||||||
echo "Frontend deployed at $(date '+%Y-%m-%d %H:%M:%S')"
|
echo "Frontend deployed at $(date '+%Y-%m-%d %H:%M:%S')"
|
||||||
|
|
||||||
# ═══ Backend ═══
|
# ═══ Backend ═══
|
||||||
- name: Install JDK 17 + Maven
|
- name: Install JDK 21 + Maven
|
||||||
run: |
|
run: |
|
||||||
apt-get update -qq
|
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
|
java -version
|
||||||
mvn --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>
|
<description>KCG Monitoring Dashboard Backend</description>
|
||||||
|
|
||||||
<properties>
|
<properties>
|
||||||
<java.version>17</java.version>
|
<java.version>21</java.version>
|
||||||
<jjwt.version>0.12.6</jjwt.version>
|
<jjwt.version>0.12.6</jjwt.version>
|
||||||
</properties>
|
</properties>
|
||||||
|
|
||||||
@ -45,6 +45,12 @@
|
|||||||
<scope>runtime</scope>
|
<scope>runtime</scope>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
|
||||||
|
<!-- PostGIS / Hibernate Spatial -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.hibernate.orm</groupId>
|
||||||
|
<artifactId>hibernate-spatial</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
<!-- Lombok -->
|
<!-- Lombok -->
|
||||||
<dependency>
|
<dependency>
|
||||||
<groupId>org.projectlombok</groupId>
|
<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 Jwt jwt = new Jwt();
|
||||||
private Google google = new Google();
|
private Google google = new Google();
|
||||||
private Auth auth = new Auth();
|
private Auth auth = new Auth();
|
||||||
|
private Collector collector = new Collector();
|
||||||
|
|
||||||
@Getter
|
@Getter
|
||||||
@Setter
|
@Setter
|
||||||
@ -33,4 +34,13 @@ public class AppProperties {
|
|||||||
public static class Auth {
|
public static class Auth {
|
||||||
private String allowedDomain;
|
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
|
Group=root
|
||||||
WorkingDirectory=/devdata/services/kcg/backend
|
WorkingDirectory=/devdata/services/kcg/backend
|
||||||
EnvironmentFile=-/devdata/services/kcg/backend/.env
|
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 \
|
-Xms2g -Xmx4g \
|
||||||
-Dspring.profiles.active=prod \
|
-Dspring.profiles.active=prod \
|
||||||
-Dspring.config.additional-location=file:/devdata/services/kcg/backend/ \
|
-Dspring.config.additional-location=file:/devdata/services/kcg/backend/ \
|
||||||
|
|||||||
@ -4,6 +4,21 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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]
|
## [2026-03-17.4]
|
||||||
|
|
||||||
### 추가
|
### 추가
|
||||||
|
|||||||
@ -13,10 +13,9 @@ import { LayerPanel } from './components/LayerPanel';
|
|||||||
import { useReplay } from './hooks/useReplay';
|
import { useReplay } from './hooks/useReplay';
|
||||||
import { useMonitor } from './hooks/useMonitor';
|
import { useMonitor } from './hooks/useMonitor';
|
||||||
import { fetchEvents, fetchSensorData } from './services/api';
|
import { fetchEvents, fetchSensorData } from './services/api';
|
||||||
import { fetchAircraftOpenSky } from './services/opensky';
|
import { fetchAircraftFromBackend } from './services/aircraftApi';
|
||||||
import { fetchMilitaryAircraft, fetchAllAircraftLive, fetchMilitaryAircraftKorea, fetchAllAircraftLiveKorea } from './services/airplaneslive';
|
import { getSampleAircraft } from './data/sampleAircraft';
|
||||||
import { fetchSatelliteTLE, fetchSatelliteTLEKorea, propagateAll } from './services/celestrak';
|
import { fetchSatelliteTLE, fetchSatelliteTLEKorea, propagateAll } from './services/celestrak';
|
||||||
import { fetchAircraftOpenSkyKorea } from './services/opensky';
|
|
||||||
import { fetchShips, fetchShipsKorea } from './services/ships';
|
import { fetchShips, fetchShipsKorea } from './services/ships';
|
||||||
import { fetchOsintFeed } from './services/osint';
|
import { fetchOsintFeed } from './services/osint';
|
||||||
import { KOREA_SUBMARINE_CABLES } from './services/submarineCable';
|
import { KOREA_SUBMARINE_CABLES } from './services/submarineCable';
|
||||||
@ -217,52 +216,16 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
|
|||||||
}, [refreshKey]);
|
}, [refreshKey]);
|
||||||
|
|
||||||
// Fetch base aircraft data
|
// Fetch base aircraft data
|
||||||
// LIVE: OpenSky (민간기) + Airplanes.live (모든 항공기 + 군용기) → 실시간 병합
|
// LIVE: 백엔드 /api/kcg/aircraft?region=iran 호출
|
||||||
// REPLAY: OpenSky (샘플 폴백) + military → 리플레이
|
// REPLAY: 하드코딩된 시나리오 샘플 데이터 사용
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
if (appMode === 'live') {
|
if (appMode === 'live') {
|
||||||
// 라이브: 3개 소스 동시 가져오기 — OpenSky + Airplanes.live + Military
|
const result = await fetchAircraftFromBackend('iran');
|
||||||
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());
|
|
||||||
if (result.length > 0) setBaseAircraft(result);
|
if (result.length > 0) setBaseAircraft(result);
|
||||||
} else {
|
} else {
|
||||||
// 리플레이: 기존 로직 (OpenSky 샘플 + military)
|
// 리플레이: 하드코딩 시나리오 샘플
|
||||||
const [opensky, mil] = await Promise.all([
|
setBaseAircraft(getSampleAircraft());
|
||||||
fetchAircraftOpenSky(),
|
|
||||||
fetchMilitaryAircraft(),
|
|
||||||
]);
|
|
||||||
const milIcaos = new Set(mil.map(a => a.icao24));
|
|
||||||
const merged = [...mil, ...opensky.filter(a => !milIcaos.has(a.icao24))];
|
|
||||||
setBaseAircraft(merged);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
load();
|
load();
|
||||||
@ -308,20 +271,7 @@ function AuthenticatedApp({ user, onLogout }: AuthenticatedAppProps) {
|
|||||||
// Fetch Korea aircraft data
|
// Fetch Korea aircraft data
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const load = async () => {
|
const load = async () => {
|
||||||
const [opensky, allLive, mil] = await Promise.all([
|
const result = await fetchAircraftFromBackend('korea');
|
||||||
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());
|
|
||||||
if (result.length > 0) setBaseAircraftKorea(result);
|
if (result.length > 0) setBaseAircraftKorea(result);
|
||||||
};
|
};
|
||||||
load();
|
load();
|
||||||
|
|||||||
@ -1,122 +1,4 @@
|
|||||||
import type { Aircraft, AircraftCategory } from '../types';
|
import type { Aircraft } 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 [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// T0 = main Iranian retaliation wave
|
// T0 = main Iranian retaliation wave
|
||||||
const T0 = new Date('2026-03-01T12:01:00Z').getTime();
|
const T0 = new Date('2026-03-01T12:01:00Z').getTime();
|
||||||
@ -125,7 +7,7 @@ const MIN = 60_000;
|
|||||||
|
|
||||||
// ── 2026 March 1 verified aircraft deployments ──
|
// ── 2026 March 1 verified aircraft deployments ──
|
||||||
// Based on OSINT: Operation Epic Fury order of battle
|
// Based on OSINT: Operation Epic Fury order of battle
|
||||||
function getSampleAircraft(): Aircraft[] {
|
export function getSampleAircraft(): Aircraft[] {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
return [
|
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/, ''),
|
rewrite: (path) => path.replace(/^\/api\/publish-twitter/, ''),
|
||||||
secure: true,
|
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': {
|
'/api/celestrak': {
|
||||||
target: 'https://celestrak.org',
|
target: 'https://celestrak.org',
|
||||||
changeOrigin: true,
|
changeOrigin: true,
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user