From 69b2aeb3b3e8758315e1bd2e0dbdc65778a91a98 Mon Sep 17 00:00:00 2001 From: htlee Date: Wed, 18 Mar 2026 04:04:18 +0900 Subject: [PATCH] =?UTF-8?q?feat(backend):=20OSINT/Satellite=20=EC=88=98?= =?UTF-8?q?=EC=A7=91=EA=B8=B0=20+=20Caffeine=20=EC=BA=90=EC=8B=9C=20?= =?UTF-8?q?=ED=86=B5=EC=9D=BC=20+=20REST=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - OSINT: GDELT + Google News RSS 수집기 (@Scheduled 2분) - Satellite: CelesTrak TLE 수집기 (@Scheduled 10분) - Caffeine 캐시 TTL 2일 (Aircraft 포함 전체 통일) - 프론트: 백엔드 API 우선 호출 + CelesTrak/GDELT fallback --- .claude/workflow-version.json | 2 +- .githooks/commit-msg | 2 +- backend/pom.xml | 10 + .../aircraft/AircraftCacheStore.java | 49 ++-- .../kcg/collector/osint/OsintCollector.java | 245 ++++++++++++++++++ .../satellite/SatelliteCollector.java | 199 ++++++++++++++ .../java/gc/mda/kcg/config/AppProperties.java | 3 + .../java/gc/mda/kcg/config/CacheConfig.java | 34 +++ .../mda/kcg/domain/osint/OsintController.java | 65 +++++ .../gc/mda/kcg/domain/osint/OsintDto.java | 39 +++ .../gc/mda/kcg/domain/osint/OsintFeed.java | 62 +++++ .../kcg/domain/osint/OsintFeedRepository.java | 13 + .../domain/satellite/SatelliteController.java | 46 ++++ .../kcg/domain/satellite/SatelliteDto.java | 27 ++ .../kcg/domain/satellite/SatelliteTle.java | 50 ++++ .../satellite/SatelliteTleRepository.java | 11 + frontend/src/components/EventLog.tsx | 6 +- frontend/src/components/SatelliteMap.tsx | 2 +- frontend/src/services/celestrak.ts | 92 +++---- frontend/src/services/navWarning.ts | 2 +- frontend/src/services/osint.ts | 18 ++ 21 files changed, 897 insertions(+), 80 deletions(-) create mode 100644 backend/src/main/java/gc/mda/kcg/collector/osint/OsintCollector.java create mode 100644 backend/src/main/java/gc/mda/kcg/collector/satellite/SatelliteCollector.java create mode 100644 backend/src/main/java/gc/mda/kcg/config/CacheConfig.java create mode 100644 backend/src/main/java/gc/mda/kcg/domain/osint/OsintController.java create mode 100644 backend/src/main/java/gc/mda/kcg/domain/osint/OsintDto.java create mode 100644 backend/src/main/java/gc/mda/kcg/domain/osint/OsintFeed.java create mode 100644 backend/src/main/java/gc/mda/kcg/domain/osint/OsintFeedRepository.java create mode 100644 backend/src/main/java/gc/mda/kcg/domain/satellite/SatelliteController.java create mode 100644 backend/src/main/java/gc/mda/kcg/domain/satellite/SatelliteDto.java create mode 100644 backend/src/main/java/gc/mda/kcg/domain/satellite/SatelliteTle.java create mode 100644 backend/src/main/java/gc/mda/kcg/domain/satellite/SatelliteTleRepository.java diff --git a/.claude/workflow-version.json b/.claude/workflow-version.json index c2ddc90..8e28c8b 100644 --- a/.claude/workflow-version.json +++ b/.claude/workflow-version.json @@ -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 diff --git a/.githooks/commit-msg b/.githooks/commit-msg index 8e65d67..93bb350 100755 --- a/.githooks/commit-msg +++ b/.githooks/commit-msg @@ -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 diff --git a/backend/pom.xml b/backend/pom.xml index 48d4a08..e7a3698 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -37,6 +37,16 @@ org.springframework.boot spring-boot-starter-validation + + org.springframework.boot + spring-boot-starter-cache + + + + + com.github.ben-manes.caffeine + caffeine + diff --git a/backend/src/main/java/gc/mda/kcg/collector/aircraft/AircraftCacheStore.java b/backend/src/main/java/gc/mda/kcg/collector/aircraft/AircraftCacheStore.java index 965ea60..6ab6e77 100644 --- a/backend/src/main/java/gc/mda/kcg/collector/aircraft/AircraftCacheStore.java +++ b/backend/src/main/java/gc/mda/kcg/collector/aircraft/AircraftCacheStore.java @@ -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> regionCache = new ConcurrentHashMap<>(); + private final CacheManager cacheManager; - // 소스별 원본 버퍼 (region → source → aircraft list) + // 소스별 원본 버퍼 (region → source → aircraft list) — 병합 전 임시 저장 private final Map>> sourceBuffers = new ConcurrentHashMap<>(); - - // 마지막 갱신 시각 private final Map lastUpdated = new ConcurrentHashMap<>(); public static final String SOURCE_LIVE = "live"; public static final String SOURCE_MIL = "mil"; public static final String SOURCE_OPENSKY = "opensky"; - /** - * 특정 소스의 데이터 갱신. - */ public void updateSource(String region, String source, List aircraft) { sourceBuffers .computeIfAbsent(region, k -> new ConcurrentHashMap<>()) @@ -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 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 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 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) wrapper.get(); + } + } + return List.of(); } - /** - * 마지막 갱신 시각 조회. - */ public long getLastUpdated(String region) { return lastUpdated.getOrDefault(region, 0L); } diff --git a/backend/src/main/java/gc/mda/kcg/collector/osint/OsintCollector.java b/backend/src/main/java/gc/mda/kcg/collector/osint/OsintCollector.java new file mode 100644 index 0000000..c7ee6f9 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/collector/osint/OsintCollector.java @@ -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 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(" ", "+"); + } +} diff --git a/backend/src/main/java/gc/mda/kcg/collector/satellite/SatelliteCollector.java b/backend/src/main/java/gc/mda/kcg/collector/satellite/SatelliteCollector.java new file mode 100644 index 0000000..b35cddd --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/collector/satellite/SatelliteCollector.java @@ -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 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 allEntities = new ArrayList<>(); + Set seenNoradIds = new LinkedHashSet<>(); + Instant now = Instant.now(); + + for (Map.Entry 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 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 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 parseTle(String body, String group, String defaultCategory, + Instant collectedAt, Set seenNoradIds) { + List 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(); + } + } +} diff --git a/backend/src/main/java/gc/mda/kcg/config/AppProperties.java b/backend/src/main/java/gc/mda/kcg/config/AppProperties.java index 18acc07..5fb83d8 100644 --- a/backend/src/main/java/gc/mda/kcg/config/AppProperties.java +++ b/backend/src/main/java/gc/mda/kcg/config/AppProperties.java @@ -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; } diff --git a/backend/src/main/java/gc/mda/kcg/config/CacheConfig.java b/backend/src/main/java/gc/mda/kcg/config/CacheConfig.java new file mode 100644 index 0000000..525a01c --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/config/CacheConfig.java @@ -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; + } +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/osint/OsintController.java b/backend/src/main/java/gc/mda/kcg/domain/osint/OsintController.java new file mode 100644 index 0000000..bed9fb7 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/osint/OsintController.java @@ -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 VALID_REGIONS = Set.of("iran", "korea"); + + private final CacheManager cacheManager; + + private final Map lastUpdated = new ConcurrentHashMap<>(); + + @GetMapping + public ResponseEntity> 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 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 getCachedItems(String cacheName) { + Cache cache = cacheManager.getCache(cacheName); + if (cache != null) { + Cache.ValueWrapper wrapper = cache.get("data"); + if (wrapper != null) { + return (List) wrapper.get(); + } + } + return List.of(); + } + + public void markUpdated(String region) { + lastUpdated.put(region, System.currentTimeMillis()); + } +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/osint/OsintDto.java b/backend/src/main/java/gc/mda/kcg/domain/osint/OsintDto.java new file mode 100644 index 0000000..53a97f8 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/osint/OsintDto.java @@ -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(); + } +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/osint/OsintFeed.java b/backend/src/main/java/gc/mda/kcg/domain/osint/OsintFeed.java new file mode 100644 index 0000000..10bd1c7 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/osint/OsintFeed.java @@ -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(); + } + } +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/osint/OsintFeedRepository.java b/backend/src/main/java/gc/mda/kcg/domain/osint/OsintFeedRepository.java new file mode 100644 index 0000000..95cf034 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/osint/OsintFeedRepository.java @@ -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 { + + boolean existsBySourceAndSourceUrl(String source, String sourceUrl); + + List findByRegionAndCollectedAtAfterOrderByPublishedAtDesc(String region, Instant since); +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/satellite/SatelliteController.java b/backend/src/main/java/gc/mda/kcg/domain/satellite/SatelliteController.java new file mode 100644 index 0000000..179211c --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/satellite/SatelliteController.java @@ -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> getSatellites( + @RequestParam(defaultValue = "iran") String region) { + + Cache cache = cacheManager.getCache(CacheConfig.SATELLITES); + Cache.ValueWrapper wrapper = cache != null ? cache.get("data") : null; + + @SuppressWarnings("unchecked") + List sats = (wrapper != null && wrapper.get() instanceof List) + ? (List) wrapper.get() + : List.of(); + + long lastUpdated = satelliteCollector.getLastUpdated(); + + return ResponseEntity.ok(Map.of( + "region", region, + "count", sats.size(), + "lastUpdated", lastUpdated, + "satellites", sats + )); + } +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/satellite/SatelliteDto.java b/backend/src/main/java/gc/mda/kcg/domain/satellite/SatelliteDto.java new file mode 100644 index 0000000..8754d9d --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/satellite/SatelliteDto.java @@ -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(); + } +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/satellite/SatelliteTle.java b/backend/src/main/java/gc/mda/kcg/domain/satellite/SatelliteTle.java new file mode 100644 index 0000000..105f912 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/satellite/SatelliteTle.java @@ -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(); + } + } +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/satellite/SatelliteTleRepository.java b/backend/src/main/java/gc/mda/kcg/domain/satellite/SatelliteTleRepository.java new file mode 100644 index 0000000..4f485ba --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/satellite/SatelliteTleRepository.java @@ -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 { + + List findByCollectedAtAfterOrderByNoradIdAsc(Instant since); +} diff --git a/frontend/src/components/EventLog.tsx b/frontend/src/components/EventLog.tsx index 6b96d85..01e6b70 100644 --- a/frontend/src/components/EventLog.tsx +++ b/frontend/src/components/EventLog.tsx @@ -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 (
diff --git a/frontend/src/components/SatelliteMap.tsx b/frontend/src/components/SatelliteMap.tsx index 7905edb..e7414b9 100644 --- a/frontend/src/components/SatelliteMap.tsx +++ b/frontend/src/components/SatelliteMap.tsx @@ -246,7 +246,7 @@ export function SatelliteMap({ events, currentTime, aircraft, satellites, ships, )} {/* Overlay layers */} - {layers.aircraft && } + {layers.aircraft && } {layers.satellites && } {layers.ships && } diff --git a/frontend/src/services/celestrak.ts b/frontend/src/services/celestrak.ts index 0d9b6ce..913f189 100644 --- a/frontend/src/services/celestrak.ts +++ b/frontend/src/services/celestrak.ts @@ -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 { - // Return cache if fresh - if (satCache && Date.now() - satCache.ts < SAT_CACHE_TTL) { - return satCache.sats; +async function fetchSatellitesFromBackend(region: 'iran' | 'korea' = 'iran'): Promise { + 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 { const allSats: Satellite[] = []; const seenIds = new Set(); - // 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 { } } - 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 { 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 { // 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 { + 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 { return satCacheKorea.sats; } - const allSats: Satellite[] = []; - const seenIds = new Set(); + // 백엔드 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; } diff --git a/frontend/src/services/navWarning.ts b/frontend/src/services/navWarning.ts index 720abaa..5f10d33 100644 --- a/frontend/src/services/navWarning.ts +++ b/frontend/src/services/navWarning.ts @@ -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) diff --git a/frontend/src/services/osint.ts b/frontend/src/services/osint.ts index b5d1456..16d05f2 100644 --- a/frontend/src/services/osint.ts +++ b/frontend/src/services/osint.ts @@ -715,7 +715,25 @@ const PINNED_KOREA: OsintItem[] = [ ]; // ── Main fetch: merge all sources, deduplicate, sort by time ── +async function fetchOsintFromBackend(region: 'iran' | 'korea'): Promise { + 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 { + // 백엔드 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;