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:
htlee 2026-03-18 04:04:18 +09:00
부모 0c78ad8bb8
커밋 69b2aeb3b3
21개의 변경된 파일897개의 추가작업 그리고 80개의 파일을 삭제

파일 보기

@ -1,6 +1,6 @@
{ {
"applied_global_version": "1.6.1", "applied_global_version": "1.6.1",
"applied_date": "2026-03-17", "applied_date": "2026-03-18",
"project_type": "react-ts", "project_type": "react-ts",
"gitea_url": "https://gitea.gc-si.dev", "gitea_url": "https://gitea.gc-si.dev",
"custom_pre_commit": true "custom_pre_commit": true

파일 보기

@ -5,7 +5,7 @@
#============================================================================== #==============================================================================
COMMIT_MSG_FILE="$1" COMMIT_MSG_FILE="$1"
COMMIT_MSG=$(head -1 "$COMMIT_MSG_FILE") COMMIT_MSG=$(cat "$COMMIT_MSG_FILE")
# Merge 커밋은 검증 건너뜀 # Merge 커밋은 검증 건너뜀
if echo "$COMMIT_MSG" | head -1 | grep -qE "^Merge "; then if echo "$COMMIT_MSG" | head -1 | grep -qE "^Merge "; then

파일 보기

@ -37,6 +37,16 @@
<groupId>org.springframework.boot</groupId> <groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId> <artifactId>spring-boot-starter-validation</artifactId>
</dependency> </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 --> <!-- Database -->
<dependency> <dependency>

파일 보기

@ -1,34 +1,33 @@
package gc.mda.kcg.collector.aircraft; package gc.mda.kcg.collector.aircraft;
import gc.mda.kcg.config.CacheConfig;
import gc.mda.kcg.domain.aircraft.AircraftDto; 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 org.springframework.stereotype.Component;
import java.util.*; import java.util.*;
import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentHashMap;
/** /**
* 항공기 데이터 인메모리 캐시. * 항공기 데이터 캐시.
* 소스별 원본을 유지하고, 병합된 최종 결과를 Controller에 제공. * 소스별 원본을 유지하고, 병합된 최종 결과를 Caffeine 캐시로 제공.
*/ */
@Component @Component
@RequiredArgsConstructor
public class AircraftCacheStore { public class AircraftCacheStore {
// 최종 병합 결과 (Controller가 읽는 데이터) private final CacheManager cacheManager;
private final Map<String, List<AircraftDto>> regionCache = new ConcurrentHashMap<>();
// 소스별 원본 버퍼 (region source aircraft list) // 소스별 원본 버퍼 (region source aircraft list) 병합 임시 저장
private final Map<String, Map<String, List<AircraftDto>>> sourceBuffers = new ConcurrentHashMap<>(); private final Map<String, Map<String, List<AircraftDto>>> sourceBuffers = new ConcurrentHashMap<>();
// 마지막 갱신 시각
private final Map<String, Long> lastUpdated = new ConcurrentHashMap<>(); private final Map<String, Long> lastUpdated = new ConcurrentHashMap<>();
public static final String SOURCE_LIVE = "live"; public static final String SOURCE_LIVE = "live";
public static final String SOURCE_MIL = "mil"; public static final String SOURCE_MIL = "mil";
public static final String SOURCE_OPENSKY = "opensky"; public static final String SOURCE_OPENSKY = "opensky";
/**
* 특정 소스의 데이터 갱신.
*/
public void updateSource(String region, String source, List<AircraftDto> aircraft) { public void updateSource(String region, String source, List<AircraftDto> aircraft) {
sourceBuffers sourceBuffers
.computeIfAbsent(region, k -> new ConcurrentHashMap<>()) .computeIfAbsent(region, k -> new ConcurrentHashMap<>())
@ -36,7 +35,7 @@ public class AircraftCacheStore {
} }
/** /**
* 소스별 데이터를 병합하여 regionCache 갱신. * 소스별 데이터를 병합하여 Caffeine 캐시 갱신.
* 병합 우선순위: live > mil > opensky (icao24 기준 중복제거) * 병합 우선순위: live > mil > opensky (icao24 기준 중복제거)
*/ */
public void mergeAndUpdate(String region) { public void mergeAndUpdate(String region) {
@ -48,16 +47,13 @@ public class AircraftCacheStore {
Map<String, AircraftDto> merged = new LinkedHashMap<>(); Map<String, AircraftDto> merged = new LinkedHashMap<>();
// 1순위: live (point/radius 가장 상세)
for (AircraftDto a : fromLive) { for (AircraftDto a : fromLive) {
merged.put(a.getIcao24(), a); merged.put(a.getIcao24(), a);
} }
// 2순위: mil 기존 항목은 category/typecode 보강, 없는 항목은 추가
for (AircraftDto m : fromMil) { for (AircraftDto m : fromMil) {
AircraftDto existing = merged.get(m.getIcao24()); AircraftDto existing = merged.get(m.getIcao24());
if (existing != null) { if (existing != null) {
// mil 데이터로 category/typecode 보강
merged.put(m.getIcao24(), AircraftDto.builder() merged.put(m.getIcao24(), AircraftDto.builder()
.icao24(existing.getIcao24()) .icao24(existing.getIcao24())
.callsign(existing.getCallsign()) .callsign(existing.getCallsign())
@ -81,25 +77,32 @@ public class AircraftCacheStore {
} }
} }
// 3순위: opensky 없는 항목만 추가
for (AircraftDto a : fromOpenSky) { for (AircraftDto a : fromOpenSky) {
merged.putIfAbsent(a.getIcao24(), a); 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()); lastUpdated.put(region, System.currentTimeMillis());
} }
/** @SuppressWarnings("unchecked")
* 병합된 최종 결과 조회.
*/
public List<AircraftDto> get(String region) { 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) { public long getLastUpdated(String region) {
return lastUpdated.getOrDefault(region, 0L); 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&timespan=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 { public static class Collector {
private String airplanesLiveBaseUrl = "https://api.airplanes.live/v2"; private String airplanesLiveBaseUrl = "https://api.airplanes.live/v2";
private String openSkyBaseUrl = "https://opensky-network.org/api"; 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 requestDelayMs = 1500;
private int backoffMs = 5000; private int backoffMs = 5000;
} }

파일 보기

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

파일 보기

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

파일 보기

@ -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 T0_NEWS = new Date('2026-03-01T12:01:00Z').getTime();
const HOUR_MS = 3600_000; const HOUR_MS = 3600_000;
const DAY_MS = 24 * HOUR_MS; const DAY_MS = 24 * HOUR_MS;
const _MIN_MS = 60_000;
const BREAKING_NEWS: BreakingNews[] = [ const BREAKING_NEWS: BreakingNews[] = [
// DAY 1 // DAY 1
@ -378,8 +377,8 @@ export function EventLog({ events, currentTime, totalShipCount: _totalShipCount,
[events.length, currentTime], [events.length, currentTime],
); );
// Iran-related ships (military + Iranian flag) // Iran-related ships (military + Iranian flag) — reserved for ship status panel
const _iranMilitaryShips = useMemo(() => const iranMilitaryShips = useMemo(() =>
ships.filter(s => ships.filter(s =>
s.flag === 'IR' || s.flag === 'IR' ||
s.category === 'carrier' || s.category === 'destroyer' || s.category === 'carrier' || s.category === 'destroyer' ||
@ -390,6 +389,7 @@ export function EventLog({ events, currentTime, totalShipCount: _totalShipCount,
}), }),
[ships], [ships],
); );
void iranMilitaryShips;
return ( return (
<div className="event-log"> <div className="event-log">

파일 보기

@ -246,7 +246,7 @@ export function SatelliteMap({ events, currentTime, aircraft, satellites, ships,
)} )}
{/* Overlay layers */} {/* Overlay layers */}
{layers.aircraft && <AircraftLayer aircraft={aircraft} />} {layers.aircraft && <AircraftLayer aircraft={aircraft} militaryOnly={layers.militaryOnly} />}
{layers.satellites && <SatelliteLayer satellites={satellites} />} {layers.satellites && <SatelliteLayer satellites={satellites} />}
{layers.ships && <ShipLayer ships={ships} militaryOnly={layers.militaryOnly} koreanOnly={layers.koreanShips} />} {layers.ships && <ShipLayer ships={ships} militaryOnly={layers.militaryOnly} koreanOnly={layers.koreanShips} />}
<DamagedShipLayer currentTime={currentTime} /> <DamagedShipLayer currentTime={currentTime} />

파일 보기

@ -80,16 +80,21 @@ function isNearMiddleEast(sat: Satellite): boolean {
let satCache: { sats: Satellite[]; ts: number } | null = null; let satCache: { sats: Satellite[]; ts: number } | null = null;
const SAT_CACHE_TTL = 10 * 60_000; const SAT_CACHE_TTL = 10 * 60_000;
export async function fetchSatelliteTLE(): Promise<Satellite[]> { async function fetchSatellitesFromBackend(region: 'iran' | 'korea' = 'iran'): Promise<Satellite[]> {
// Return cache if fresh try {
if (satCache && Date.now() - satCache.ts < SAT_CACHE_TTL) { const res = await fetch(`/api/kcg/satellites?region=${region}`, { credentials: 'include' });
return satCache.sats; 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 allSats: Satellite[] = [];
const seenIds = new Set<number>(); const seenIds = new Set<number>();
// Fetch TLE groups from CelesTrak sequentially (avoid hammering)
for (const { group, category } of CELESTRAK_GROUPS) { for (const { group, category } of CELESTRAK_GROUPS) {
try { try {
const url = `/api/celestrak/NORAD/elements/gp.php?GROUP=${group}&FORMAT=tle`; 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) { return allSats;
console.warn('CelesTrak: no data fetched, using fallback');
return FALLBACK_SATELLITES;
} }
// 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[] = []; const filtered: Satellite[] = [];
for (const sat of allSats) { for (const sat of allSats) {
try { try {
@ -126,12 +129,10 @@ export async function fetchSatelliteTLE(): Promise<Satellite[]> {
const geo = satellite.eciToGeodetic(pv.position, satellite.gstime(new Date())); const geo = satellite.eciToGeodetic(pv.position, satellite.gstime(new Date()));
const altKm = geo.height; const altKm = geo.height;
// GEO (>30000km) and MEO (>5000km): always include (they cover wide areas)
if (altKm > 5000) { if (altKm > 5000) {
filtered.push(sat); filtered.push(sat);
} else { } else {
// LEO: only keep if passes near Middle East if (isNearFn(sat)) {
if (isNearMiddleEast(sat)) {
filtered.push(sat); filtered.push(sat);
} }
} }
@ -139,12 +140,30 @@ export async function fetchSatelliteTLE(): Promise<Satellite[]> {
// skip bad TLE // skip bad TLE
} }
} }
return filtered.slice(0, 100);
}
// Cap at ~100 satellites to keep rendering performant export async function fetchSatelliteTLE(): Promise<Satellite[]> {
const capped = filtered.slice(0, 100); 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() }; 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; return capped;
} }
@ -176,46 +195,19 @@ export async function fetchSatelliteTLEKorea(): Promise<Satellite[]> {
return satCacheKorea.sats; return satCacheKorea.sats;
} }
const allSats: Satellite[] = []; // 백엔드 API 우선
const seenIds = new Set<number>(); let allSats = await fetchSatellitesFromBackend('korea');
for (const { group, category } of CELESTRAK_GROUPS) { // 백엔드 실패 시 CelesTrak 직접 호출 fallback
try { if (allSats.length === 0) {
const url = `/api/celestrak/NORAD/elements/gp.php?GROUP=${group}&FORMAT=tle`; allSats = await fetchSatelliteTLEFromCelesTrak();
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 */ }
} }
if (allSats.length === 0) return FALLBACK_SATELLITES; if (allSats.length === 0) return FALLBACK_SATELLITES;
const filtered: Satellite[] = []; const capped = filterSatellitesByRegion(allSats, isNearKorea);
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);
satCacheKorea = { sats: capped, ts: Date.now() }; 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; return capped;
} }

파일 보기

@ -45,12 +45,12 @@ function dms(d: number, m: number, s: number): number {
} }
/** Compute center of polygon */ /** Compute center of polygon */
// eslint-disable-next-line @typescript-eslint/no-unused-vars
function center(pts: [number, number][]): [number, number] { function center(pts: [number, number][]): [number, number] {
const lat = pts.reduce((s, p) => s + p[0], 0) / pts.length; const lat = pts.reduce((s, p) => s + p[0], 0) / pts.length;
const lng = pts.reduce((s, p) => s + p[1], 0) / pts.length; const lng = pts.reduce((s, p) => s + p[1], 0) / pts.length;
return [lat, lng]; return [lat, lng];
} }
void center;
// ═══════════════════════════════════════════════════════ // ═══════════════════════════════════════════════════════
// 해상사격장 구역 데이터 (WGS-84) // 해상사격장 구역 데이터 (WGS-84)

파일 보기

@ -715,7 +715,25 @@ const PINNED_KOREA: OsintItem[] = [
]; ];
// ── Main fetch: merge all sources, deduplicate, sort by time ── // ── 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[]> { 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 gdeltKw = focus === 'korea' ? GDELT_KEYWORDS_KOREA : GDELT_KEYWORDS_IRAN;
const gnKrKw = focus === 'korea' ? GNEWS_KR_KOREA : GNEWS_KR_IRAN; const gnKrKw = focus === 'korea' ? GNEWS_KR_KOREA : GNEWS_KR_IRAN;
const gnEnKw = focus === 'korea' ? GNEWS_EN_KOREA : GNEWS_EN_IRAN; const gnEnKw = focus === 'korea' ? GNEWS_EN_KOREA : GNEWS_EN_IRAN;