feat(backend): OSINT/Satellite 수집기 + Caffeine 캐시 통일 + REST API
- OSINT: GDELT + Google News RSS 수집기 (@Scheduled 2분) - Satellite: CelesTrak TLE 수집기 (@Scheduled 10분) - Caffeine 캐시 TTL 2일 (Aircraft 포함 전체 통일) - 프론트: 백엔드 API 우선 호출 + CelesTrak/GDELT fallback
This commit is contained in:
부모
0c78ad8bb8
커밋
69b2aeb3b3
@ -1,6 +1,6 @@
|
||||
{
|
||||
"applied_global_version": "1.6.1",
|
||||
"applied_date": "2026-03-17",
|
||||
"applied_date": "2026-03-18",
|
||||
"project_type": "react-ts",
|
||||
"gitea_url": "https://gitea.gc-si.dev",
|
||||
"custom_pre_commit": true
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
#==============================================================================
|
||||
|
||||
COMMIT_MSG_FILE="$1"
|
||||
COMMIT_MSG=$(head -1 "$COMMIT_MSG_FILE")
|
||||
COMMIT_MSG=$(cat "$COMMIT_MSG_FILE")
|
||||
|
||||
# Merge 커밋은 검증 건너뜀
|
||||
if echo "$COMMIT_MSG" | head -1 | grep -qE "^Merge "; then
|
||||
|
||||
@ -37,6 +37,16 @@
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-validation</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-cache</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Caffeine Cache -->
|
||||
<dependency>
|
||||
<groupId>com.github.ben-manes.caffeine</groupId>
|
||||
<artifactId>caffeine</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Database -->
|
||||
<dependency>
|
||||
|
||||
@ -1,34 +1,33 @@
|
||||
package gc.mda.kcg.collector.aircraft;
|
||||
|
||||
import gc.mda.kcg.config.CacheConfig;
|
||||
import gc.mda.kcg.domain.aircraft.AircraftDto;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.cache.Cache;
|
||||
import org.springframework.cache.CacheManager;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* 항공기 데이터 인메모리 캐시.
|
||||
* 소스별 원본을 유지하고, 병합된 최종 결과를 Controller에 제공.
|
||||
* 항공기 데이터 캐시.
|
||||
* 소스별 원본을 유지하고, 병합된 최종 결과를 Caffeine 캐시로 제공.
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class AircraftCacheStore {
|
||||
|
||||
// 최종 병합 결과 (Controller가 읽는 데이터)
|
||||
private final Map<String, List<AircraftDto>> regionCache = new ConcurrentHashMap<>();
|
||||
private final CacheManager cacheManager;
|
||||
|
||||
// 소스별 원본 버퍼 (region → source → aircraft list)
|
||||
// 소스별 원본 버퍼 (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<>())
|
||||
@ -36,7 +35,7 @@ public class AircraftCacheStore {
|
||||
}
|
||||
|
||||
/**
|
||||
* 소스별 데이터를 병합하여 regionCache를 갱신.
|
||||
* 소스별 데이터를 병합하여 Caffeine 캐시를 갱신.
|
||||
* 병합 우선순위: live > mil > opensky (icao24 기준 중복제거)
|
||||
*/
|
||||
public void mergeAndUpdate(String region) {
|
||||
@ -48,16 +47,13 @@ public class AircraftCacheStore {
|
||||
|
||||
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())
|
||||
@ -81,25 +77,32 @@ public class AircraftCacheStore {
|
||||
}
|
||||
}
|
||||
|
||||
// 3순위: opensky — 없는 항목만 추가
|
||||
for (AircraftDto a : fromOpenSky) {
|
||||
merged.putIfAbsent(a.getIcao24(), a);
|
||||
}
|
||||
|
||||
regionCache.put(region, Collections.unmodifiableList(new ArrayList<>(merged.values())));
|
||||
List<AircraftDto> result = Collections.unmodifiableList(new ArrayList<>(merged.values()));
|
||||
String cacheName = "iran".equals(region) ? CacheConfig.AIRCRAFT_IRAN : CacheConfig.AIRCRAFT_KOREA;
|
||||
Cache cache = cacheManager.getCache(cacheName);
|
||||
if (cache != null) {
|
||||
cache.put("data", result);
|
||||
}
|
||||
lastUpdated.put(region, System.currentTimeMillis());
|
||||
}
|
||||
|
||||
/**
|
||||
* 병합된 최종 결과 조회.
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
public List<AircraftDto> get(String region) {
|
||||
return regionCache.getOrDefault(region, List.of());
|
||||
String cacheName = "iran".equals(region) ? CacheConfig.AIRCRAFT_IRAN : CacheConfig.AIRCRAFT_KOREA;
|
||||
Cache cache = cacheManager.getCache(cacheName);
|
||||
if (cache != null) {
|
||||
Cache.ValueWrapper wrapper = cache.get("data");
|
||||
if (wrapper != null) {
|
||||
return (List<AircraftDto>) wrapper.get();
|
||||
}
|
||||
}
|
||||
return List.of();
|
||||
}
|
||||
|
||||
/**
|
||||
* 마지막 갱신 시각 조회.
|
||||
*/
|
||||
public long getLastUpdated(String region) {
|
||||
return lastUpdated.getOrDefault(region, 0L);
|
||||
}
|
||||
|
||||
@ -0,0 +1,245 @@
|
||||
package gc.mda.kcg.collector.osint;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import gc.mda.kcg.config.AppProperties;
|
||||
import gc.mda.kcg.config.CacheConfig;
|
||||
import gc.mda.kcg.domain.osint.OsintDto;
|
||||
import gc.mda.kcg.domain.osint.OsintFeed;
|
||||
import gc.mda.kcg.domain.osint.OsintFeedRepository;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.cache.Cache;
|
||||
import org.springframework.cache.CacheManager;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
import org.w3c.dom.Document;
|
||||
import org.w3c.dom.Element;
|
||||
import org.w3c.dom.NodeList;
|
||||
|
||||
import javax.xml.parsers.DocumentBuilder;
|
||||
import javax.xml.parsers.DocumentBuilderFactory;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class OsintCollector {
|
||||
|
||||
private static final String IRAN_KEYWORDS =
|
||||
"\"Strait of Hormuz\" OR Hormuz OR \"Persian Gulf\" OR Iran OR IRGC";
|
||||
private static final String KOREA_KEYWORDS =
|
||||
"해양사고 OR 해경 OR 어선 OR NLL OR 독도 OR \"Korea maritime\"";
|
||||
|
||||
private static final DateTimeFormatter GDELT_FORMATTER =
|
||||
DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'").withZone(ZoneOffset.UTC);
|
||||
private static final DateTimeFormatter RSS_FORMATTER =
|
||||
DateTimeFormatter.ofPattern("EEE, dd MMM yyyy HH:mm:ss z", Locale.ENGLISH);
|
||||
|
||||
private final RestTemplate restTemplate;
|
||||
private final CacheManager cacheManager;
|
||||
private final OsintFeedRepository osintFeedRepository;
|
||||
private final AppProperties appProperties;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
Thread.ofVirtual().name("osint-init").start(() -> {
|
||||
log.info("OSINT 초기 캐시 로드 시작");
|
||||
refreshCache("iran");
|
||||
refreshCache("korea");
|
||||
log.info("OSINT 초기 캐시 로드 완료");
|
||||
});
|
||||
}
|
||||
|
||||
@Scheduled(initialDelay = 30_000, fixedDelay = 120_000)
|
||||
public void collectIran() {
|
||||
log.debug("OSINT 이란 수집 시작");
|
||||
collectGdelt("iran", IRAN_KEYWORDS);
|
||||
collectGoogleNews("iran", "Iran Hormuz military", "en");
|
||||
refreshCache("iran");
|
||||
log.debug("OSINT 이란 수집 완료");
|
||||
}
|
||||
|
||||
@Scheduled(initialDelay = 45_000, fixedDelay = 120_000)
|
||||
public void collectKorea() {
|
||||
log.debug("OSINT 한국 수집 시작");
|
||||
collectGdelt("korea", KOREA_KEYWORDS);
|
||||
collectGoogleNews("korea", KOREA_KEYWORDS, "ko");
|
||||
refreshCache("korea");
|
||||
log.debug("OSINT 한국 수집 완료");
|
||||
}
|
||||
|
||||
private void collectGdelt(String region, String keywords) {
|
||||
try {
|
||||
String url = String.format(
|
||||
"%s?query=%s&mode=ArtList&maxrecords=30&format=json&sort=DateDesc×pan=24h",
|
||||
appProperties.getCollector().getGdeltBaseUrl(),
|
||||
encodeQuery(keywords)
|
||||
);
|
||||
String body = restTemplate.getForObject(url, String.class);
|
||||
if (body == null || body.isBlank()) return;
|
||||
|
||||
JsonNode root = objectMapper.readTree(body);
|
||||
JsonNode articles = root.path("articles");
|
||||
if (!articles.isArray()) return;
|
||||
|
||||
int saved = 0;
|
||||
for (JsonNode article : articles) {
|
||||
String articleUrl = article.path("url").asText(null);
|
||||
String title = article.path("title").asText(null);
|
||||
if (articleUrl == null || title == null || title.isBlank()) continue;
|
||||
|
||||
if (osintFeedRepository.existsBySourceAndSourceUrl("gdelt", articleUrl)) continue;
|
||||
|
||||
String seendate = article.path("seendate").asText(null);
|
||||
Instant publishedAt = parseGdeltDate(seendate);
|
||||
|
||||
String language = article.path("language").asText("en");
|
||||
String imageUrl = article.path("socialimage").asText(null);
|
||||
if (imageUrl != null && imageUrl.isBlank()) imageUrl = null;
|
||||
|
||||
OsintFeed feed = OsintFeed.builder()
|
||||
.title(title)
|
||||
.source("gdelt")
|
||||
.sourceUrl(articleUrl)
|
||||
.category(classifyCategory(title))
|
||||
.language(language.toLowerCase())
|
||||
.region(region)
|
||||
.imageUrl(imageUrl)
|
||||
.position(null)
|
||||
.publishedAt(publishedAt)
|
||||
.build();
|
||||
|
||||
osintFeedRepository.save(feed);
|
||||
saved++;
|
||||
}
|
||||
log.debug("GDELT {} 저장: {}건", region, saved);
|
||||
} catch (Exception e) {
|
||||
log.warn("GDELT {} 수집 실패: {}", region, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void collectGoogleNews(String region, String query, String lang) {
|
||||
try {
|
||||
boolean isKorean = "ko".equals(lang);
|
||||
String hl = isKorean ? "ko" : "en";
|
||||
String gl = isKorean ? "KR" : "US";
|
||||
String ceid = isKorean ? "KR:ko" : "US:en";
|
||||
String sourceName = isKorean ? "google-news-ko" : "google-news-en";
|
||||
|
||||
String url = String.format(
|
||||
"%s?q=%s&hl=%s&gl=%s&ceid=%s",
|
||||
appProperties.getCollector().getGoogleNewsBaseUrl(),
|
||||
encodeQuery(query),
|
||||
hl, gl, ceid
|
||||
);
|
||||
|
||||
String body = restTemplate.getForObject(url, String.class);
|
||||
if (body == null || body.isBlank()) return;
|
||||
|
||||
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
|
||||
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
|
||||
DocumentBuilder builder = factory.newDocumentBuilder();
|
||||
Document doc = builder.parse(new ByteArrayInputStream(body.getBytes(StandardCharsets.UTF_8)));
|
||||
|
||||
NodeList items = doc.getElementsByTagName("item");
|
||||
int saved = 0;
|
||||
for (int i = 0; i < items.getLength(); i++) {
|
||||
Element item = (Element) items.item(i);
|
||||
String title = getTextContent(item, "title");
|
||||
String link = getTextContent(item, "link");
|
||||
String pubDate = getTextContent(item, "pubDate");
|
||||
|
||||
if (link == null || title == null || title.isBlank()) continue;
|
||||
if (osintFeedRepository.existsBySourceAndSourceUrl(sourceName, link)) continue;
|
||||
|
||||
Instant publishedAt = parseRssDate(pubDate);
|
||||
|
||||
OsintFeed feed = OsintFeed.builder()
|
||||
.title(title)
|
||||
.source(sourceName)
|
||||
.sourceUrl(link)
|
||||
.category(classifyCategory(title))
|
||||
.language(lang)
|
||||
.region(region)
|
||||
.imageUrl(null)
|
||||
.position(null)
|
||||
.publishedAt(publishedAt)
|
||||
.build();
|
||||
|
||||
osintFeedRepository.save(feed);
|
||||
saved++;
|
||||
}
|
||||
log.debug("Google News {} ({}) 저장: {}건", region, lang, saved);
|
||||
} catch (Exception e) {
|
||||
log.warn("Google News {} ({}) 수집 실패: {}", region, lang, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void refreshCache(String region) {
|
||||
Instant since = Instant.now().minus(24, ChronoUnit.HOURS);
|
||||
List<OsintDto> dtos = osintFeedRepository
|
||||
.findByRegionAndCollectedAtAfterOrderByPublishedAtDesc(region, since)
|
||||
.stream().map(OsintDto::from).toList();
|
||||
String cacheName = "iran".equals(region) ? CacheConfig.OSINT_IRAN : CacheConfig.OSINT_KOREA;
|
||||
Cache cache = cacheManager.getCache(cacheName);
|
||||
if (cache != null) {
|
||||
cache.put("data", dtos);
|
||||
}
|
||||
log.debug("OSINT {} 캐시 갱신: {}건", region, dtos.size());
|
||||
}
|
||||
|
||||
private String classifyCategory(String title) {
|
||||
String t = title.toLowerCase();
|
||||
if (t.matches(".*(strike|missile|attack|military|weapon|drone|전투|공습|미사일).*")) return "military";
|
||||
if (t.matches(".*(oil|crude|opec|유가|원유|석유).*")) return "oil";
|
||||
if (t.matches(".*(diplomat|sanction|treaty|외교|제재|협상).*")) return "diplomacy";
|
||||
if (t.matches(".*(ship|vessel|maritime|해운|선박|항만).*")) return "shipping";
|
||||
if (t.matches(".*(nuclear|uranium|핵|우라늄).*")) return "nuclear";
|
||||
if (t.matches(".*(해양사고|충돌|좌초|침몰|collision|capsiz).*")) return "maritime_accident";
|
||||
if (t.matches(".*(어선|어업|불법조업|fishing).*")) return "fishing";
|
||||
return "general";
|
||||
}
|
||||
|
||||
private Instant parseGdeltDate(String seendate) {
|
||||
if (seendate == null || seendate.isBlank()) return null;
|
||||
try {
|
||||
return Instant.from(GDELT_FORMATTER.parse(seendate));
|
||||
} catch (DateTimeParseException e) {
|
||||
log.debug("GDELT 날짜 파싱 실패: {}", seendate);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private Instant parseRssDate(String pubDate) {
|
||||
if (pubDate == null || pubDate.isBlank()) return null;
|
||||
try {
|
||||
return Instant.from(RSS_FORMATTER.parse(pubDate));
|
||||
} catch (DateTimeParseException e) {
|
||||
log.debug("RSS 날짜 파싱 실패: {}", pubDate);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private String getTextContent(Element parent, String tagName) {
|
||||
NodeList nodes = parent.getElementsByTagName(tagName);
|
||||
if (nodes.getLength() == 0) return null;
|
||||
String text = nodes.item(0).getTextContent();
|
||||
return (text == null || text.isBlank()) ? null : text.trim();
|
||||
}
|
||||
|
||||
private String encodeQuery(String query) {
|
||||
return query.replace(" ", "+");
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,199 @@
|
||||
package gc.mda.kcg.collector.satellite;
|
||||
|
||||
import gc.mda.kcg.config.AppProperties;
|
||||
import gc.mda.kcg.config.CacheConfig;
|
||||
import gc.mda.kcg.domain.satellite.SatelliteDto;
|
||||
import gc.mda.kcg.domain.satellite.SatelliteTle;
|
||||
import gc.mda.kcg.domain.satellite.SatelliteTleRepository;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.cache.Cache;
|
||||
import org.springframework.cache.CacheManager;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.*;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class SatelliteCollector {
|
||||
|
||||
private static final Map<String, String> TLE_GROUPS = Map.of(
|
||||
"military", "reconnaissance",
|
||||
"gps-ops", "navigation",
|
||||
"geo", "communications",
|
||||
"weather", "weather",
|
||||
"stations", "other"
|
||||
);
|
||||
|
||||
private static final Pattern RECON_PATTERN = Pattern.compile("SBIRS|DSP|STSS|NROL|USA");
|
||||
private static final Pattern COMMS_PATTERN = Pattern.compile("WGS|AEHF|MUOS|STARLINK|ONEWEB");
|
||||
private static final Pattern NAV_PATTERN = Pattern.compile("GPS|NAVSTAR");
|
||||
|
||||
public volatile long lastUpdated = 0L;
|
||||
|
||||
private final RestTemplate restTemplate;
|
||||
private final CacheManager cacheManager;
|
||||
private final SatelliteTleRepository repository;
|
||||
private final AppProperties appProperties;
|
||||
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
Thread.ofVirtual().name("satellite-init").start(() -> {
|
||||
log.info("위성 TLE 초기 캐시 로드 시작");
|
||||
loadCacheFromDb();
|
||||
log.info("위성 TLE 초기 캐시 로드 완료");
|
||||
});
|
||||
}
|
||||
|
||||
@Scheduled(initialDelay = 60_000, fixedDelay = 600_000)
|
||||
public void collect() {
|
||||
log.info("위성 TLE 수집 시작");
|
||||
String baseUrl = appProperties.getCollector().getCelestrakBaseUrl();
|
||||
List<SatelliteTle> allEntities = new ArrayList<>();
|
||||
Set<Integer> seenNoradIds = new LinkedHashSet<>();
|
||||
Instant now = Instant.now();
|
||||
|
||||
for (Map.Entry<String, String> entry : TLE_GROUPS.entrySet()) {
|
||||
String group = entry.getKey();
|
||||
String defaultCategory = entry.getValue();
|
||||
|
||||
try {
|
||||
String url = baseUrl + "?GROUP=" + group + "&FORMAT=tle";
|
||||
String body = restTemplate.getForObject(url, String.class);
|
||||
if (body == null || body.isBlank()) {
|
||||
log.warn("위성 TLE 응답 없음: group={}", group);
|
||||
continue;
|
||||
}
|
||||
|
||||
List<SatelliteTle> entities = parseTle(body, group, defaultCategory, now, seenNoradIds);
|
||||
allEntities.addAll(entities);
|
||||
log.debug("위성 TLE 파싱 완료: group={}, count={}", group, entities.size());
|
||||
|
||||
} catch (Exception e) {
|
||||
log.warn("위성 TLE 수집 실패: group={}, error={}", group, e.getMessage());
|
||||
}
|
||||
|
||||
sleep(1000);
|
||||
}
|
||||
|
||||
if (!allEntities.isEmpty()) {
|
||||
try {
|
||||
repository.saveAll(allEntities);
|
||||
log.info("위성 TLE DB 적재 완료: {} 건", allEntities.size());
|
||||
} catch (Exception e) {
|
||||
log.error("위성 TLE DB 적재 실패: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
loadCacheFromDb();
|
||||
}
|
||||
|
||||
public long getLastUpdated() {
|
||||
return lastUpdated;
|
||||
}
|
||||
|
||||
void loadCacheFromDb() {
|
||||
Instant since = Instant.now().minus(11, ChronoUnit.MINUTES);
|
||||
List<SatelliteDto> dtos = repository.findByCollectedAtAfterOrderByNoradIdAsc(since)
|
||||
.stream().map(SatelliteDto::from).toList();
|
||||
|
||||
if (dtos.isEmpty()) {
|
||||
since = Instant.now().minus(24, ChronoUnit.HOURS);
|
||||
dtos = repository.findByCollectedAtAfterOrderByNoradIdAsc(since)
|
||||
.stream().map(SatelliteDto::from).toList();
|
||||
}
|
||||
|
||||
if (!dtos.isEmpty()) {
|
||||
Cache cache = cacheManager.getCache(CacheConfig.SATELLITES);
|
||||
if (cache != null) cache.put("data", dtos);
|
||||
lastUpdated = System.currentTimeMillis();
|
||||
log.debug("위성 TLE 캐시 갱신 완료: {} 건", dtos.size());
|
||||
}
|
||||
}
|
||||
|
||||
private List<SatelliteTle> parseTle(String body, String group, String defaultCategory,
|
||||
Instant collectedAt, Set<Integer> seenNoradIds) {
|
||||
List<SatelliteTle> result = new ArrayList<>();
|
||||
String[] lines = body.lines()
|
||||
.map(String::trim)
|
||||
.filter(l -> !l.isBlank())
|
||||
.toArray(String[]::new);
|
||||
|
||||
for (int i = 0; i + 2 < lines.length; i += 3) {
|
||||
String nameLine = lines[i];
|
||||
String line1 = lines[i + 1];
|
||||
String line2 = lines[i + 2];
|
||||
|
||||
if (!line1.startsWith("1 ") || !line2.startsWith("2 ")) {
|
||||
log.debug("TLE 형식 불일치 스킵: name={}", nameLine);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
String name = nameLine.trim();
|
||||
int noradId = Integer.parseInt(line1.substring(2, 7).trim());
|
||||
|
||||
if (!seenNoradIds.add(noradId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Instant epoch = parseTleEpoch(line1);
|
||||
String category = resolveCategory(name, defaultCategory);
|
||||
|
||||
result.add(SatelliteTle.builder()
|
||||
.noradId(noradId)
|
||||
.name(name)
|
||||
.tleLine1(line1)
|
||||
.tleLine2(line2)
|
||||
.category(category)
|
||||
.tleGroup(group)
|
||||
.epoch(epoch)
|
||||
.collectedAt(collectedAt)
|
||||
.build());
|
||||
|
||||
} catch (Exception e) {
|
||||
log.debug("TLE 파싱 실패 스킵: name={}, error={}", nameLine, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private Instant parseTleEpoch(String line1) {
|
||||
String epochStr = line1.substring(18, 32).trim();
|
||||
int yy = Integer.parseInt(epochStr.substring(0, 2));
|
||||
int year = (yy < 57) ? 2000 + yy : 1900 + yy;
|
||||
double dayOfYearFraction = Double.parseDouble(epochStr.substring(2));
|
||||
int dayOfYear = (int) dayOfYearFraction;
|
||||
double fraction = dayOfYearFraction - dayOfYear;
|
||||
|
||||
return LocalDate.ofYearDay(year, dayOfYear)
|
||||
.atStartOfDay(ZoneOffset.UTC)
|
||||
.toInstant()
|
||||
.plusMillis((long) (fraction * 86_400_000));
|
||||
}
|
||||
|
||||
private String resolveCategory(String name, String defaultCategory) {
|
||||
if (RECON_PATTERN.matcher(name).find()) return "reconnaissance";
|
||||
if (NAV_PATTERN.matcher(name).find()) return "navigation";
|
||||
if (COMMS_PATTERN.matcher(name).find()) return "communications";
|
||||
return defaultCategory;
|
||||
}
|
||||
|
||||
private static void sleep(int ms) {
|
||||
try {
|
||||
Thread.sleep(ms);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -40,6 +40,9 @@ public class AppProperties {
|
||||
public static class Collector {
|
||||
private String airplanesLiveBaseUrl = "https://api.airplanes.live/v2";
|
||||
private String openSkyBaseUrl = "https://opensky-network.org/api";
|
||||
private String gdeltBaseUrl = "https://api.gdeltproject.org/api/v2/doc/doc";
|
||||
private String googleNewsBaseUrl = "https://news.google.com/rss/search";
|
||||
private String celestrakBaseUrl = "https://celestrak.org/NORAD/elements/gp.php";
|
||||
private int requestDelayMs = 1500;
|
||||
private int backoffMs = 5000;
|
||||
}
|
||||
|
||||
34
backend/src/main/java/gc/mda/kcg/config/CacheConfig.java
Normal file
34
backend/src/main/java/gc/mda/kcg/config/CacheConfig.java
Normal file
@ -0,0 +1,34 @@
|
||||
package gc.mda.kcg.config;
|
||||
|
||||
import com.github.benmanes.caffeine.cache.Caffeine;
|
||||
import org.springframework.cache.CacheManager;
|
||||
import org.springframework.cache.annotation.EnableCaching;
|
||||
import org.springframework.cache.caffeine.CaffeineCacheManager;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
@Configuration
|
||||
@EnableCaching
|
||||
public class CacheConfig {
|
||||
|
||||
public static final String AIRCRAFT_IRAN = "aircraft-iran";
|
||||
public static final String AIRCRAFT_KOREA = "aircraft-korea";
|
||||
public static final String OSINT_IRAN = "osint-iran";
|
||||
public static final String OSINT_KOREA = "osint-korea";
|
||||
public static final String SATELLITES = "satellites";
|
||||
|
||||
@Bean
|
||||
public CacheManager cacheManager() {
|
||||
CaffeineCacheManager manager = new CaffeineCacheManager(
|
||||
AIRCRAFT_IRAN, AIRCRAFT_KOREA,
|
||||
OSINT_IRAN, OSINT_KOREA,
|
||||
SATELLITES
|
||||
);
|
||||
manager.setCaffeine(Caffeine.newBuilder()
|
||||
.expireAfterWrite(2, TimeUnit.DAYS)
|
||||
.maximumSize(1));
|
||||
return manager;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,65 @@
|
||||
package gc.mda.kcg.domain.osint;
|
||||
|
||||
import gc.mda.kcg.config.CacheConfig;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.cache.Cache;
|
||||
import org.springframework.cache.CacheManager;
|
||||
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;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/osint")
|
||||
@RequiredArgsConstructor
|
||||
public class OsintController {
|
||||
|
||||
private static final Set<String> VALID_REGIONS = Set.of("iran", "korea");
|
||||
|
||||
private final CacheManager cacheManager;
|
||||
|
||||
private final Map<String, Long> lastUpdated = new ConcurrentHashMap<>();
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<Map<String, Object>> getOsint(
|
||||
@RequestParam(defaultValue = "iran") String region) {
|
||||
|
||||
if (!VALID_REGIONS.contains(region)) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(Map.of("error", "유효하지 않은 region: " + region));
|
||||
}
|
||||
|
||||
String cacheName = "iran".equals(region) ? CacheConfig.OSINT_IRAN : CacheConfig.OSINT_KOREA;
|
||||
List<OsintDto> items = getCachedItems(cacheName);
|
||||
long updatedAt = lastUpdated.getOrDefault(region, 0L);
|
||||
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"region", region,
|
||||
"count", items.size(),
|
||||
"lastUpdated", updatedAt,
|
||||
"items", items
|
||||
));
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private List<OsintDto> getCachedItems(String cacheName) {
|
||||
Cache cache = cacheManager.getCache(cacheName);
|
||||
if (cache != null) {
|
||||
Cache.ValueWrapper wrapper = cache.get("data");
|
||||
if (wrapper != null) {
|
||||
return (List<OsintDto>) wrapper.get();
|
||||
}
|
||||
}
|
||||
return List.of();
|
||||
}
|
||||
|
||||
public void markUpdated(String region) {
|
||||
lastUpdated.put(region, System.currentTimeMillis());
|
||||
}
|
||||
}
|
||||
39
backend/src/main/java/gc/mda/kcg/domain/osint/OsintDto.java
Normal file
39
backend/src/main/java/gc/mda/kcg/domain/osint/OsintDto.java
Normal file
@ -0,0 +1,39 @@
|
||||
package gc.mda.kcg.domain.osint;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
|
||||
@Getter
|
||||
@Builder
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class OsintDto {
|
||||
|
||||
private String id;
|
||||
private long timestamp;
|
||||
private String title;
|
||||
private String source;
|
||||
private String url;
|
||||
private String category;
|
||||
private String language;
|
||||
private String imageUrl;
|
||||
private Double lat;
|
||||
private Double lng;
|
||||
|
||||
public static OsintDto from(OsintFeed f) {
|
||||
return OsintDto.builder()
|
||||
.id(f.getSource() + "-" + f.getId())
|
||||
.timestamp(f.getPublishedAt() != null
|
||||
? f.getPublishedAt().toEpochMilli()
|
||||
: f.getCollectedAt().toEpochMilli())
|
||||
.title(f.getTitle())
|
||||
.source(f.getSource())
|
||||
.url(f.getSourceUrl())
|
||||
.category(f.getCategory())
|
||||
.language(f.getLanguage())
|
||||
.imageUrl(f.getImageUrl())
|
||||
.lat(f.getPosition() != null ? f.getPosition().getY() : null)
|
||||
.lng(f.getPosition() != null ? f.getPosition().getX() : null)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
62
backend/src/main/java/gc/mda/kcg/domain/osint/OsintFeed.java
Normal file
62
backend/src/main/java/gc/mda/kcg/domain/osint/OsintFeed.java
Normal file
@ -0,0 +1,62 @@
|
||||
package gc.mda.kcg.domain.osint;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
import org.locationtech.jts.geom.Point;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
@Entity
|
||||
@Table(
|
||||
name = "osint_feeds",
|
||||
schema = "kcg",
|
||||
uniqueConstraints = @UniqueConstraint(columnNames = {"source", "source_url"})
|
||||
)
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class OsintFeed {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false, columnDefinition = "TEXT")
|
||||
private String title;
|
||||
|
||||
@Column(nullable = false, length = 64)
|
||||
private String source;
|
||||
|
||||
@Column(name = "source_url", columnDefinition = "TEXT")
|
||||
private String sourceUrl;
|
||||
|
||||
@Column(length = 32)
|
||||
private String category;
|
||||
|
||||
@Column(length = 8)
|
||||
private String language;
|
||||
|
||||
@Column(name = "focus", length = 16)
|
||||
private String region;
|
||||
|
||||
@Column(name = "image_url", columnDefinition = "TEXT")
|
||||
private String imageUrl;
|
||||
|
||||
@Column(columnDefinition = "geometry(Point, 4326)")
|
||||
private Point position;
|
||||
|
||||
@Column(name = "published_at")
|
||||
private Instant publishedAt;
|
||||
|
||||
@Column(name = "collected_at", nullable = false)
|
||||
private Instant collectedAt;
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
if (collectedAt == null) {
|
||||
collectedAt = Instant.now();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,13 @@
|
||||
package gc.mda.kcg.domain.osint;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
public interface OsintFeedRepository extends JpaRepository<OsintFeed, Long> {
|
||||
|
||||
boolean existsBySourceAndSourceUrl(String source, String sourceUrl);
|
||||
|
||||
List<OsintFeed> findByRegionAndCollectedAtAfterOrderByPublishedAtDesc(String region, Instant since);
|
||||
}
|
||||
@ -0,0 +1,46 @@
|
||||
package gc.mda.kcg.domain.satellite;
|
||||
|
||||
import gc.mda.kcg.collector.satellite.SatelliteCollector;
|
||||
import gc.mda.kcg.config.CacheConfig;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.cache.Cache;
|
||||
import org.springframework.cache.CacheManager;
|
||||
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;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/satellites")
|
||||
@RequiredArgsConstructor
|
||||
public class SatelliteController {
|
||||
|
||||
private final CacheManager cacheManager;
|
||||
private final SatelliteCollector satelliteCollector;
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<Map<String, Object>> getSatellites(
|
||||
@RequestParam(defaultValue = "iran") String region) {
|
||||
|
||||
Cache cache = cacheManager.getCache(CacheConfig.SATELLITES);
|
||||
Cache.ValueWrapper wrapper = cache != null ? cache.get("data") : null;
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
List<SatelliteDto> sats = (wrapper != null && wrapper.get() instanceof List<?>)
|
||||
? (List<SatelliteDto>) wrapper.get()
|
||||
: List.of();
|
||||
|
||||
long lastUpdated = satelliteCollector.getLastUpdated();
|
||||
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"region", region,
|
||||
"count", sats.size(),
|
||||
"lastUpdated", lastUpdated,
|
||||
"satellites", sats
|
||||
));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,27 @@
|
||||
package gc.mda.kcg.domain.satellite;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
|
||||
@Getter
|
||||
@Builder
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class SatelliteDto {
|
||||
|
||||
private int noradId;
|
||||
private String name;
|
||||
private String tle1;
|
||||
private String tle2;
|
||||
private String category;
|
||||
|
||||
public static SatelliteDto from(SatelliteTle e) {
|
||||
return SatelliteDto.builder()
|
||||
.noradId(e.getNoradId())
|
||||
.name(e.getName())
|
||||
.tle1(e.getTleLine1())
|
||||
.tle2(e.getTleLine2())
|
||||
.category(e.getCategory())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,50 @@
|
||||
package gc.mda.kcg.domain.satellite;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
@Entity
|
||||
@Table(name = "satellite_tle", schema = "kcg")
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class SatelliteTle {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "norad_id", nullable = false)
|
||||
private Integer noradId;
|
||||
|
||||
@Column(nullable = false, length = 128)
|
||||
private String name;
|
||||
|
||||
@Column(name = "tle_line1", nullable = false, length = 70)
|
||||
private String tleLine1;
|
||||
|
||||
@Column(name = "tle_line2", nullable = false, length = 70)
|
||||
private String tleLine2;
|
||||
|
||||
@Column(length = 20)
|
||||
private String category;
|
||||
|
||||
@Column(name = "tle_group", length = 32)
|
||||
private String tleGroup;
|
||||
|
||||
private Instant epoch;
|
||||
|
||||
@Column(name = "collected_at", nullable = false)
|
||||
private Instant collectedAt;
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
if (collectedAt == null) {
|
||||
collectedAt = Instant.now();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
package gc.mda.kcg.domain.satellite;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
public interface SatelliteTleRepository extends JpaRepository<SatelliteTle, Long> {
|
||||
|
||||
List<SatelliteTle> findByCollectedAtAfterOrderByNoradIdAsc(Instant since);
|
||||
}
|
||||
@ -31,7 +31,6 @@ interface BreakingNews {
|
||||
const T0_NEWS = new Date('2026-03-01T12:01:00Z').getTime();
|
||||
const HOUR_MS = 3600_000;
|
||||
const DAY_MS = 24 * HOUR_MS;
|
||||
const _MIN_MS = 60_000;
|
||||
|
||||
const BREAKING_NEWS: BreakingNews[] = [
|
||||
// DAY 1
|
||||
@ -378,8 +377,8 @@ export function EventLog({ events, currentTime, totalShipCount: _totalShipCount,
|
||||
[events.length, currentTime],
|
||||
);
|
||||
|
||||
// Iran-related ships (military + Iranian flag)
|
||||
const _iranMilitaryShips = useMemo(() =>
|
||||
// Iran-related ships (military + Iranian flag) — reserved for ship status panel
|
||||
const iranMilitaryShips = useMemo(() =>
|
||||
ships.filter(s =>
|
||||
s.flag === 'IR' ||
|
||||
s.category === 'carrier' || s.category === 'destroyer' ||
|
||||
@ -390,6 +389,7 @@ export function EventLog({ events, currentTime, totalShipCount: _totalShipCount,
|
||||
}),
|
||||
[ships],
|
||||
);
|
||||
void iranMilitaryShips;
|
||||
|
||||
return (
|
||||
<div className="event-log">
|
||||
|
||||
@ -246,7 +246,7 @@ export function SatelliteMap({ events, currentTime, aircraft, satellites, ships,
|
||||
)}
|
||||
|
||||
{/* Overlay layers */}
|
||||
{layers.aircraft && <AircraftLayer aircraft={aircraft} />}
|
||||
{layers.aircraft && <AircraftLayer aircraft={aircraft} militaryOnly={layers.militaryOnly} />}
|
||||
{layers.satellites && <SatelliteLayer satellites={satellites} />}
|
||||
{layers.ships && <ShipLayer ships={ships} militaryOnly={layers.militaryOnly} koreanOnly={layers.koreanShips} />}
|
||||
<DamagedShipLayer currentTime={currentTime} />
|
||||
|
||||
@ -80,16 +80,21 @@ function isNearMiddleEast(sat: Satellite): boolean {
|
||||
let satCache: { sats: Satellite[]; ts: number } | null = null;
|
||||
const SAT_CACHE_TTL = 10 * 60_000;
|
||||
|
||||
export async function fetchSatelliteTLE(): Promise<Satellite[]> {
|
||||
// Return cache if fresh
|
||||
if (satCache && Date.now() - satCache.ts < SAT_CACHE_TTL) {
|
||||
return satCache.sats;
|
||||
async function fetchSatellitesFromBackend(region: 'iran' | 'korea' = 'iran'): Promise<Satellite[]> {
|
||||
try {
|
||||
const res = await fetch(`/api/kcg/satellites?region=${region}`, { credentials: 'include' });
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
return (data.satellites ?? []) as Satellite[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchSatelliteTLEFromCelesTrak(): Promise<Satellite[]> {
|
||||
const allSats: Satellite[] = [];
|
||||
const seenIds = new Set<number>();
|
||||
|
||||
// Fetch TLE groups from CelesTrak sequentially (avoid hammering)
|
||||
for (const { group, category } of CELESTRAK_GROUPS) {
|
||||
try {
|
||||
const url = `/api/celestrak/NORAD/elements/gp.php?GROUP=${group}&FORMAT=tle`;
|
||||
@ -111,12 +116,10 @@ export async function fetchSatelliteTLE(): Promise<Satellite[]> {
|
||||
}
|
||||
}
|
||||
|
||||
if (allSats.length === 0) {
|
||||
console.warn('CelesTrak: no data fetched, using fallback');
|
||||
return FALLBACK_SATELLITES;
|
||||
return allSats;
|
||||
}
|
||||
|
||||
// For GEO/MEO sats keep all, for LEO filter to Middle East region
|
||||
function filterSatellitesByRegion(allSats: Satellite[], isNearFn: (sat: Satellite) => boolean): Satellite[] {
|
||||
const filtered: Satellite[] = [];
|
||||
for (const sat of allSats) {
|
||||
try {
|
||||
@ -126,12 +129,10 @@ export async function fetchSatelliteTLE(): Promise<Satellite[]> {
|
||||
const geo = satellite.eciToGeodetic(pv.position, satellite.gstime(new Date()));
|
||||
const altKm = geo.height;
|
||||
|
||||
// GEO (>30000km) and MEO (>5000km): always include (they cover wide areas)
|
||||
if (altKm > 5000) {
|
||||
filtered.push(sat);
|
||||
} else {
|
||||
// LEO: only keep if passes near Middle East
|
||||
if (isNearMiddleEast(sat)) {
|
||||
if (isNearFn(sat)) {
|
||||
filtered.push(sat);
|
||||
}
|
||||
}
|
||||
@ -139,12 +140,30 @@ export async function fetchSatelliteTLE(): Promise<Satellite[]> {
|
||||
// skip bad TLE
|
||||
}
|
||||
}
|
||||
return filtered.slice(0, 100);
|
||||
}
|
||||
|
||||
// Cap at ~100 satellites to keep rendering performant
|
||||
const capped = filtered.slice(0, 100);
|
||||
export async function fetchSatelliteTLE(): Promise<Satellite[]> {
|
||||
if (satCache && Date.now() - satCache.ts < SAT_CACHE_TTL) {
|
||||
return satCache.sats;
|
||||
}
|
||||
|
||||
// 백엔드 API 우선
|
||||
let allSats = await fetchSatellitesFromBackend('iran');
|
||||
|
||||
// 백엔드 실패 시 CelesTrak 직접 호출 fallback
|
||||
if (allSats.length === 0) {
|
||||
allSats = await fetchSatelliteTLEFromCelesTrak();
|
||||
}
|
||||
|
||||
if (allSats.length === 0) {
|
||||
console.warn('CelesTrak: no data fetched, using fallback');
|
||||
return FALLBACK_SATELLITES;
|
||||
}
|
||||
|
||||
const capped = filterSatellitesByRegion(allSats, isNearMiddleEast);
|
||||
satCache = { sats: capped, ts: Date.now() };
|
||||
console.log(`CelesTrak: loaded ${capped.length} satellites (from ${allSats.length} total)`);
|
||||
console.log(`Satellites: loaded ${capped.length} (from ${allSats.length} total)`);
|
||||
return capped;
|
||||
}
|
||||
|
||||
@ -176,46 +195,19 @@ export async function fetchSatelliteTLEKorea(): Promise<Satellite[]> {
|
||||
return satCacheKorea.sats;
|
||||
}
|
||||
|
||||
const allSats: Satellite[] = [];
|
||||
const seenIds = new Set<number>();
|
||||
// 백엔드 API 우선
|
||||
let allSats = await fetchSatellitesFromBackend('korea');
|
||||
|
||||
for (const { group, category } of CELESTRAK_GROUPS) {
|
||||
try {
|
||||
const url = `/api/celestrak/NORAD/elements/gp.php?GROUP=${group}&FORMAT=tle`;
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) continue;
|
||||
const text = await res.text();
|
||||
const parsed = parseTLE(text, category);
|
||||
for (const sat of parsed) {
|
||||
if (!seenIds.has(sat.noradId)) {
|
||||
seenIds.add(sat.noradId);
|
||||
allSats.push(sat);
|
||||
}
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
// 백엔드 실패 시 CelesTrak 직접 호출 fallback
|
||||
if (allSats.length === 0) {
|
||||
allSats = await fetchSatelliteTLEFromCelesTrak();
|
||||
}
|
||||
|
||||
if (allSats.length === 0) return FALLBACK_SATELLITES;
|
||||
|
||||
const filtered: Satellite[] = [];
|
||||
for (const sat of allSats) {
|
||||
try {
|
||||
const satrec = satellite.twoline2satrec(sat.tle1, sat.tle2);
|
||||
const pv = satellite.propagate(satrec, new Date());
|
||||
if (!pv || typeof pv.position === 'boolean' || !pv.position) continue;
|
||||
const geo = satellite.eciToGeodetic(pv.position, satellite.gstime(new Date()));
|
||||
const altKm = geo.height;
|
||||
if (altKm > 5000) {
|
||||
filtered.push(sat);
|
||||
} else {
|
||||
if (isNearKorea(sat)) filtered.push(sat);
|
||||
}
|
||||
} catch { /* skip */ }
|
||||
}
|
||||
|
||||
const capped = filtered.slice(0, 100);
|
||||
const capped = filterSatellitesByRegion(allSats, isNearKorea);
|
||||
satCacheKorea = { sats: capped, ts: Date.now() };
|
||||
console.log(`CelesTrak Korea: loaded ${capped.length} satellites`);
|
||||
console.log(`Satellites Korea: loaded ${capped.length} (from ${allSats.length} total)`);
|
||||
return capped;
|
||||
}
|
||||
|
||||
|
||||
@ -45,12 +45,12 @@ function dms(d: number, m: number, s: number): number {
|
||||
}
|
||||
|
||||
/** Compute center of polygon */
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
function center(pts: [number, number][]): [number, number] {
|
||||
const lat = pts.reduce((s, p) => s + p[0], 0) / pts.length;
|
||||
const lng = pts.reduce((s, p) => s + p[1], 0) / pts.length;
|
||||
return [lat, lng];
|
||||
}
|
||||
void center;
|
||||
|
||||
// ═══════════════════════════════════════════════════════
|
||||
// 해상사격장 구역 데이터 (WGS-84)
|
||||
|
||||
@ -715,7 +715,25 @@ const PINNED_KOREA: OsintItem[] = [
|
||||
];
|
||||
|
||||
// ── Main fetch: merge all sources, deduplicate, sort by time ──
|
||||
async function fetchOsintFromBackend(region: 'iran' | 'korea'): Promise<OsintItem[]> {
|
||||
try {
|
||||
const res = await fetch(`/api/kcg/osint?region=${region}`, { credentials: 'include' });
|
||||
if (!res.ok) return [];
|
||||
const data = await res.json();
|
||||
return (data.items ?? []) as OsintItem[];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchOsintFeed(focus: 'iran' | 'korea' = 'iran'): Promise<OsintItem[]> {
|
||||
// 백엔드 API 우선 시도
|
||||
const backendItems = await fetchOsintFromBackend(focus);
|
||||
if (backendItems.length > 0) {
|
||||
return backendItems;
|
||||
}
|
||||
|
||||
// 백엔드 실패 시 직접 호출 fallback
|
||||
const gdeltKw = focus === 'korea' ? GDELT_KEYWORDS_KOREA : GDELT_KEYWORDS_IRAN;
|
||||
const gnKrKw = focus === 'korea' ? GNEWS_KR_KOREA : GNEWS_KR_IRAN;
|
||||
const gnEnKw = focus === 'korea' ? GNEWS_EN_KOREA : GNEWS_EN_IRAN;
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user