Merge pull request 'release: 2026-03-18 (8건 커밋)' (#28) from develop into main
All checks were successful
Deploy KCG / deploy (push) Successful in 1m7s

This commit is contained in:
htlee 2026-03-18 04:18:10 +09:00
커밋 ed9a2e3233
27개의 변경된 파일1135개의 추가작업 그리고 126개의 파일을 삭제

파일 보기

@ -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&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 {
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;
}

파일 보기

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

파일 보기

@ -1,7 +1,7 @@
-- 002: 항공기 위치 이력 테이블 (PostGIS)
-- 리플레이 기능을 위한 시계열 위치 데이터 저장
SET search_path TO kcg;
SET search_path TO kcg, public;
-- PostGIS 확장 활성화
CREATE EXTENSION IF NOT EXISTS postgis;

파일 보기

@ -0,0 +1,79 @@
-- 003: 선박 위치 이력 + OSINT 피드 + 위성 TLE 테이블
-- 리플레이 및 분석용 시계열 데이터 저장
SET search_path TO kcg, public;
-- ═══════════════════════════════════════════
-- 선박 위치 이력 (AIS / signal-batch)
-- ═══════════════════════════════════════════
CREATE TABLE IF NOT EXISTS ship_positions (
id BIGSERIAL PRIMARY KEY,
mmsi VARCHAR(9) NOT NULL,
name VARCHAR(128),
position geometry(Point, 4326) NOT NULL,
heading DOUBLE PRECISION,
speed DOUBLE PRECISION, -- knots
course DOUBLE PRECISION, -- COG
category VARCHAR(16), -- warship, carrier, destroyer, tanker, cargo, ...
flag VARCHAR(4), -- ISO 국가코드
typecode VARCHAR(16),
type_desc VARCHAR(128),
imo VARCHAR(16),
call_sign VARCHAR(16),
status VARCHAR(64), -- Under way, Anchored, ...
destination VARCHAR(128),
draught DOUBLE PRECISION,
length DOUBLE PRECISION,
width DOUBLE PRECISION,
source VARCHAR(16) NOT NULL, -- spg, signal-batch, sample
region VARCHAR(16) NOT NULL, -- middleeast, korea
collected_at TIMESTAMP NOT NULL DEFAULT NOW(),
last_seen TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_ship_pos_geom ON ship_positions USING GIST (position);
CREATE INDEX IF NOT EXISTS idx_ship_pos_collected ON ship_positions (collected_at);
CREATE INDEX IF NOT EXISTS idx_ship_pos_region_time ON ship_positions (region, collected_at);
CREATE INDEX IF NOT EXISTS idx_ship_pos_mmsi ON ship_positions (mmsi, collected_at);
-- ═══════════════════════════════════════════
-- OSINT 피드 (GDELT, Google News, CENTCOM)
-- ═══════════════════════════════════════════
CREATE TABLE IF NOT EXISTS osint_feeds (
id BIGSERIAL PRIMARY KEY,
title TEXT NOT NULL,
source VARCHAR(64) NOT NULL, -- gdelt, google-news-ko, google-news-en, centcom
source_url TEXT,
category VARCHAR(32), -- military, oil, diplomacy, shipping, nuclear, ...
language VARCHAR(8), -- ko, en
focus VARCHAR(16), -- iran, korea
image_url TEXT,
position geometry(Point, 4326), -- nullable (위치 추출 가능 시)
published_at TIMESTAMP, -- 원본 기사 발행 시각
collected_at TIMESTAMP NOT NULL DEFAULT NOW(),
UNIQUE(source, source_url) -- 중복 방지
);
CREATE INDEX IF NOT EXISTS idx_osint_feeds_collected ON osint_feeds (collected_at);
CREATE INDEX IF NOT EXISTS idx_osint_feeds_focus ON osint_feeds (focus, collected_at);
CREATE INDEX IF NOT EXISTS idx_osint_feeds_category ON osint_feeds (category);
CREATE INDEX IF NOT EXISTS idx_osint_feeds_geom ON osint_feeds USING GIST (position);
-- ═══════════════════════════════════════════
-- 위성 TLE (CelesTrak)
-- ═══════════════════════════════════════════
CREATE TABLE IF NOT EXISTS satellite_tle (
id BIGSERIAL PRIMARY KEY,
norad_id INTEGER NOT NULL,
name VARCHAR(128) NOT NULL,
tle_line1 VARCHAR(70) NOT NULL,
tle_line2 VARCHAR(70) NOT NULL,
category VARCHAR(20), -- reconnaissance, communications, navigation, weather, other
tle_group VARCHAR(32), -- military, gps-ops, geo, weather, stations
epoch TIMESTAMP, -- TLE epoch (궤도 기준 시각)
collected_at TIMESTAMP NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_satellite_tle_norad ON satellite_tle (norad_id, collected_at);
CREATE INDEX IF NOT EXISTS idx_satellite_tle_collected ON satellite_tle (collected_at);
CREATE INDEX IF NOT EXISTS idx_satellite_tle_category ON satellite_tle (category);

파일 보기

@ -0,0 +1,60 @@
-- 샘플 선박 데이터 적재
-- 리플레이 시나리오: 2026-03-01 이란 공습 당시 중동 해역 주요 함정
SET search_path TO kcg, public;
-- ═══════════════════════════════════════════
-- 중동 해역 (region: middleeast)
-- ═══════════════════════════════════════════
-- USS Abraham Lincoln CSG (CVN-72) — 아라비아해
INSERT INTO ship_positions (mmsi, name, position, heading, speed, course, category, flag, typecode, type_desc, source, region, last_seen)
VALUES
('369970072', 'USS ABRAHAM LINCOLN (CVN-72)', ST_SetSRID(ST_MakePoint(61.5, 23.5), 4326), 315, 18, 315, 'carrier', 'US', 'CVN', 'Aircraft Carrier', 'sample', 'middleeast', '2026-03-01 12:01:00'),
('369970073', 'USS SPRUANCE (DDG-111)', ST_SetSRID(ST_MakePoint(61.3, 23.7), 4326), 310, 20, 310, 'destroyer', 'US', 'DDG', 'Destroyer', 'sample', 'middleeast', '2026-03-01 12:01:00'),
('369970074', 'USS STERETT (DDG-104)', ST_SetSRID(ST_MakePoint(61.7, 23.3), 4326), 320, 19, 320, 'destroyer', 'US', 'DDG', 'Destroyer', 'sample', 'middleeast', '2026-03-01 12:01:00'),
('369970075', 'USS MOBILE BAY (CG-53)', ST_SetSRID(ST_MakePoint(61.4, 23.6), 4326), 315, 18, 315, 'warship', 'US', 'CG', 'Cruiser', 'sample', 'middleeast', '2026-03-01 12:01:00'),
-- USS Gerald R. Ford CSG (CVN-78) — 동지중해
('369970078', 'USS GERALD R. FORD (CVN-78)', ST_SetSRID(ST_MakePoint(33.5, 34.0), 4326), 90, 15, 90, 'carrier', 'US', 'CVN', 'Aircraft Carrier', 'sample', 'middleeast', '2026-03-01 12:01:00'),
('369970079', 'USS RAMAGE (DDG-61)', ST_SetSRID(ST_MakePoint(33.3, 34.2), 4326), 85, 17, 85, 'destroyer', 'US', 'DDG', 'Destroyer', 'sample', 'middleeast', '2026-03-01 12:01:00'),
('369970080', 'USS THOMAS HUDNER (DDG-116)', ST_SetSRID(ST_MakePoint(33.7, 33.8), 4326), 95, 16, 95, 'destroyer', 'US', 'DDG', 'Destroyer', 'sample', 'middleeast', '2026-03-01 12:01:00'),
-- 호르무즈 해협 IRGCN 쾌속정
('422100001', 'IRGCN PATROL-1', ST_SetSRID(ST_MakePoint(56.3, 26.5), 4326), 180, 25, 180, 'patrol', 'IR', 'FAC', 'Fast Attack Craft', 'sample', 'middleeast', '2026-03-01 12:01:00'),
('422100002', 'IRGCN PATROL-2', ST_SetSRID(ST_MakePoint(56.1, 26.7), 4326), 200, 22, 200, 'patrol', 'IR', 'FAC', 'Fast Attack Craft', 'sample', 'middleeast', '2026-03-01 12:01:00'),
('422100003', 'IRGCN PATROL-3', ST_SetSRID(ST_MakePoint(56.5, 26.3), 4326), 170, 28, 170, 'patrol', 'IR', 'FAC', 'Fast Attack Craft', 'sample', 'middleeast', '2026-03-01 12:01:00'),
-- 영국 HMS Queen Elizabeth CSG
('232009001', 'HMS QUEEN ELIZABETH (R08)', ST_SetSRID(ST_MakePoint(58.0, 25.0), 4326), 0, 16, 0, 'carrier', 'GB', 'CV', 'Aircraft Carrier', 'sample', 'middleeast', '2026-03-01 12:01:00'),
('232009002', 'HMS DIAMOND (D34)', ST_SetSRID(ST_MakePoint(58.2, 25.2), 4326), 5, 18, 5, 'destroyer', 'GB', 'DDG', 'Destroyer', 'sample', 'middleeast', '2026-03-01 12:01:00'),
-- 프랑스 FS Charles de Gaulle
('227000001', 'FS CHARLES DE GAULLE (R91)', ST_SetSRID(ST_MakePoint(32.0, 34.5), 4326), 90, 14, 90, 'carrier', 'FR', 'CV', 'Aircraft Carrier', 'sample', 'middleeast', '2026-03-01 12:01:00'),
-- 민간 유조선 / 화물선 (호르무즈 해협)
('538007001', 'FRONT ALTA (VLCC)', ST_SetSRID(ST_MakePoint(56.0, 26.0), 4326), 150, 12, 150, 'tanker', 'MH', 'VLCC', 'Very Large Crude Carrier', 'sample', 'middleeast', '2026-03-01 12:01:00'),
('538007002', 'SEAVIGOUR', ST_SetSRID(ST_MakePoint(55.5, 26.2), 4326), 330, 11, 330, 'tanker', 'MH', 'VLCC', 'Very Large Crude Carrier', 'sample', 'middleeast', '2026-03-01 12:01:00'),
('477000001', 'EVER GIVEN', ST_SetSRID(ST_MakePoint(56.8, 25.8), 4326), 120, 14, 120, 'cargo', 'HK', 'CONT', 'Container Ship', 'sample', 'middleeast', '2026-03-01 12:01:00');
-- ═══════════════════════════════════════════
-- 한국 해역 (region: korea)
-- ═══════════════════════════════════════════
INSERT INTO ship_positions (mmsi, name, position, heading, speed, course, category, flag, typecode, type_desc, source, region, last_seen)
VALUES
-- ROKN 제7기동전단
('440100001', 'ROKS CHOI YOUNG (DDH-981)', ST_SetSRID(ST_MakePoint(129.0, 35.0), 4326), 180, 16, 180, 'destroyer', 'KR', 'DDH', 'Destroyer', 'sample', 'korea', '2026-03-01 12:01:00'),
('440100002', 'ROKS SEJONG THE GREAT (DDG-991)', ST_SetSRID(ST_MakePoint(129.2, 35.2), 4326), 175, 18, 175, 'destroyer', 'KR', 'DDG', 'Aegis Destroyer', 'sample', 'korea', '2026-03-01 12:01:00'),
('440100003', 'ROKS DOKDO (LPH-6111)', ST_SetSRID(ST_MakePoint(128.8, 34.8), 4326), 190, 14, 190, 'warship', 'KR', 'LPH', 'Amphibious Assault Ship', 'sample', 'korea', '2026-03-01 12:01:00'),
-- 동해 해경 순시선
('440200001', 'SAMBONGHO (3000톤급)', ST_SetSRID(ST_MakePoint(129.5, 37.0), 4326), 0, 12, 0, 'patrol', 'KR', 'PCG', 'Coast Guard', 'sample', 'korea', '2026-03-01 12:01:00'),
-- 부산항 민간선박
('440300001', 'HMM ALGECIRAS', ST_SetSRID(ST_MakePoint(129.05, 35.1), 4326), 270, 8, 270, 'cargo', 'KR', 'CONT', 'Container Ship 24000TEU', 'sample', 'korea', '2026-03-01 12:01:00'),
('440300002', 'SK INNOVATION', ST_SetSRID(ST_MakePoint(129.3, 35.4), 4326), 90, 10, 90, 'tanker', 'KR', 'VLCC', 'VLCC Tanker', 'sample', 'korea', '2026-03-01 12:01:00'),
-- 미 제7함대 CVN-76
('369970076', 'USS RONALD REAGAN (CVN-76)', ST_SetSRID(ST_MakePoint(129.8, 35.1), 4326), 90, 15, 90, 'carrier', 'US', 'CVN', 'Aircraft Carrier', 'sample', 'korea', '2026-03-01 12:01:00'),
('369970077', 'USS BARRY (DDG-52)', ST_SetSRID(ST_MakePoint(130.0, 35.3), 4326), 85, 17, 85, 'destroyer', 'US', 'DDG', 'Destroyer', 'sample', 'korea', '2026-03-01 12:01:00');

파일 보기

@ -0,0 +1,31 @@
-- 샘플 OSINT 피드 데이터 적재
-- 리플레이 시나리오: 2026-03-01 이란 공습 관련 주요 뉴스
SET search_path TO kcg, public;
INSERT INTO osint_feeds (title, source, source_url, category, language, focus, position, published_at)
VALUES
-- 이란 관련 주요 뉴스 (한국어)
('미국·이스라엘, 이란 핵시설 정밀타격… 이스파한·나탄즈 동시 공습', 'google-news-ko', 'https://example.com/news/iran-strike-1', 'military', 'ko', 'iran', ST_SetSRID(ST_MakePoint(51.7, 32.6), 4326), '2026-03-01 12:30:00'),
('이란 혁명수비대, 호르무즈 해협 봉쇄 위협… 유가 급등', 'google-news-ko', 'https://example.com/news/hormuz-1', 'oil', 'ko', 'iran', ST_SetSRID(ST_MakePoint(56.3, 26.5), 4326), '2026-03-01 13:00:00'),
('한국 정부, 호르무즈 해역 한국 선박 안전 확보 비상 대책 가동', 'google-news-ko', 'https://example.com/news/korea-response-1', 'shipping', 'ko', 'iran', NULL, '2026-03-01 14:00:00'),
('이란 탄도미사일, 알 다프라 공군기지 타격… 미군 피해 현황 미확인', 'google-news-ko', 'https://example.com/news/al-dhafra-1', 'military', 'ko', 'iran', ST_SetSRID(ST_MakePoint(54.55, 24.25), 4326), '2026-03-01 13:30:00'),
('국제 유가, 브렌트유 120달러 돌파… 2022년 이후 최고', 'google-news-ko', 'https://example.com/news/oil-price-1', 'oil', 'ko', 'iran', NULL, '2026-03-01 15:00:00'),
('UN 안보리, 이란 사태 긴급 회의 소집', 'google-news-ko', 'https://example.com/news/unsc-1', 'diplomacy', 'ko', 'iran', NULL, '2026-03-01 16:00:00'),
-- 이란 관련 주요 뉴스 (영어)
('CENTCOM confirms strikes on Iranian nuclear facilities at Isfahan and Natanz', 'gdelt', 'https://example.com/news/centcom-confirm-1', 'military', 'en', 'iran', ST_SetSRID(ST_MakePoint(51.7, 32.6), 4326), '2026-03-01 12:15:00'),
('Iran retaliates with ballistic missiles targeting US bases in Gulf', 'gdelt', 'https://example.com/news/iran-retaliation-1', 'military', 'en', 'iran', ST_SetSRID(ST_MakePoint(54.55, 24.25), 4326), '2026-03-01 13:00:00'),
('Strait of Hormuz shipping disrupted as IRGCN deploys fast boats', 'gdelt', 'https://example.com/news/hormuz-shipping-1', 'shipping', 'en', 'iran', ST_SetSRID(ST_MakePoint(56.3, 26.5), 4326), '2026-03-01 14:30:00'),
('Brent crude surges past $120 on Middle East escalation fears', 'gdelt', 'https://example.com/news/oil-surge-1', 'oil', 'en', 'iran', NULL, '2026-03-01 15:00:00'),
-- CENTCOM 공식 발표
('CENTCOM: US and coalition forces conducted precision strikes on Iranian military targets', 'centcom', 'https://example.com/centcom/statement-1', 'military', 'en', 'iran', NULL, '2026-03-01 12:10:00'),
('CENTCOM: Iranian ballistic missile attack on Al Dhafra Air Base; damage assessment underway', 'centcom', 'https://example.com/centcom/statement-2', 'military', 'en', 'iran', ST_SetSRID(ST_MakePoint(54.55, 24.25), 4326), '2026-03-01 13:15:00'),
-- 한국 해역 관련 뉴스
('해경, 독도 인근 일본 순시선 접근 경고 조치', 'google-news-ko', 'https://example.com/news/dokdo-1', 'maritime_traffic', 'ko', 'korea', ST_SetSRID(ST_MakePoint(131.87, 37.24), 4326), '2026-03-15 09:00:00'),
('부산항 입출항 컨테이너선 1만 TEU 돌파… 물동량 회복세', 'google-news-ko', 'https://example.com/news/busan-port-1', 'shipping', 'ko', 'korea', ST_SetSRID(ST_MakePoint(129.05, 35.1), 4326), '2026-03-14 10:00:00'),
('서해 NLL 인근 중국 어선 불법조업 단속 강화', 'google-news-ko', 'https://example.com/news/nll-fishing-1', 'fishing', 'ko', 'korea', ST_SetSRID(ST_MakePoint(124.5, 37.5), 4326), '2026-03-13 11:00:00'),
('해군 제7기동전단, 동해 대잠수함 훈련 실시', 'google-news-ko', 'https://example.com/news/rokn-asw-1', 'military', 'ko', 'korea', ST_SetSRID(ST_MakePoint(130.0, 36.0), 4326), '2026-03-12 08:00:00'),
('여수 해상에서 유조선-화물선 충돌사고… 기름 유출 우려', 'google-news-ko', 'https://example.com/news/yeosu-accident-1', 'maritime_accident', 'ko', 'korea', ST_SetSRID(ST_MakePoint(127.7, 34.7), 4326), '2026-03-11 14:00:00');

파일 보기

@ -0,0 +1,36 @@
-- 샘플 위성 TLE 데이터 적재
-- 주요 정찰/통신/GPS 위성 TLE (CelesTrak 2026년 3월 기준)
SET search_path TO kcg, public;
INSERT INTO satellite_tle (norad_id, name, tle_line1, tle_line2, category, tle_group, epoch)
VALUES
-- ═══ 정찰/군사 위성 ═══
(25544, 'ISS (ZARYA)', '1 25544U 98067A 26060.54166667 .00016717 00000-0 29461-3 0 9991', '2 25544 51.6414 247.4627 0006703 130.5360 229.6130 15.50238364470812', 'other', 'stations', '2026-03-01 13:00:00'),
-- SBIRS GEO-1 (미사일 조기경보 위성)
(37481, 'SBIRS GEO-1', '1 37481U 11019A 26060.00000000 .00000088 00000-0 00000-0 0 9998', '2 37481 3.2500 75.1000 0003500 270.0000 90.0000 1.00274000 50001', 'reconnaissance', 'military', '2026-03-01 00:00:00'),
-- USA-224 (KH-11 광학 정찰위성)
(37348, 'USA-224 (KH-11)', '1 37348U 11002A 26060.50000000 .00000700 00000-0 30000-4 0 9996', '2 37348 97.9000 120.0000 0006000 250.0000 110.0000 14.56000000 50001', 'reconnaissance', 'military', '2026-03-01 12:00:00'),
-- USA-245 (합성개구레이더 위성)
(40258, 'USA-245 (TOPAZ)', '1 40258U 14068A 26060.50000000 .00002000 00000-0 60000-4 0 9994', '2 40258 97.4000 50.0000 0012000 200.0000 160.0000 15.19000000 40001', 'reconnaissance', 'military', '2026-03-01 12:00:00'),
-- Lacrosse-5 (SAR 정찰위성)
(28646, 'LACROSSE 5', '1 28646U 05016A 26060.50000000 .00001500 00000-0 40000-4 0 9993', '2 28646 57.0000 300.0000 0010000 180.0000 180.0000 14.98000000 50001', 'reconnaissance', 'military', '2026-03-01 12:00:00'),
-- ═══ GPS/항법 위성 ═══
(48859, 'GPS III-06 (SVN-79)', '1 48859U 21054A 26060.00000000 .00000010 00000-0 00000-0 0 9997', '2 48859 55.0200 175.5000 0050000 30.0000 330.0000 2.00565000 30001', 'navigation', 'gps-ops', '2026-03-01 00:00:00'),
(28874, 'GPS IIR-M 03 (SVN-58)', '1 28874U 05038A 26060.00000000 .00000005 00000-0 00000-0 0 9998', '2 28874 56.1000 60.0000 0060000 90.0000 270.0000 2.00570000 40001', 'navigation', 'gps-ops', '2026-03-01 00:00:00'),
-- ═══ 통신 위성 (GEO) ═══
(44479, 'WGS-10', '1 44479U 19052A 26060.00000000 .00000088 00000-0 00000-0 0 9995', '2 44479 0.0500 60.0000 0002000 270.0000 90.0000 1.00274000 20001', 'communications', 'geo', '2026-03-01 00:00:00'),
(36032, 'AEHF-1', '1 36032U 10039A 26060.00000000 .00000088 00000-0 00000-0 0 9996', '2 36032 3.0000 75.0000 0003000 270.0000 90.0000 1.00274000 30001', 'communications', 'geo', '2026-03-01 00:00:00'),
-- ═══ 기상 위성 ═══
(43689, 'METOP-C', '1 43689U 18087A 26060.50000000 .00000200 00000-0 50000-4 0 9992', '2 43689 98.7100 30.0000 0010000 300.0000 60.0000 14.21500000 30001', 'weather', 'weather', '2026-03-01 12:00:00'),
(37849, 'SUOMI NPP', '1 37849U 11061A 26060.50000000 .00000150 00000-0 40000-4 0 9991', '2 37849 98.7400 50.0000 0008000 280.0000 80.0000 14.19500000 50001', 'weather', 'weather', '2026-03-01 12:00:00');

파일 보기

@ -4,51 +4,18 @@
## [Unreleased]
## [2026-03-17.5]
## [2026-03-18]
### 추가
- 백엔드 항공기 수집기 (Airplanes.live + OpenSky, @Scheduled 60초 주기)
- 인메모리 캐시 + PostGIS DB 적재 (향후 리플레이 지원)
- `GET /api/aircraft?region=iran|korea` REST API
- 프론트엔드 LIVE 모드 백엔드 API 전환 (`aircraftApi.ts`)
- DB 마이그레이션: `aircraft_positions` 테이블 (geometry + GiST 인덱스)
### 변경
- JDK 17 → 21 업그레이드 (pom.xml, sdkmanrc, CI/CD, systemd)
- 프론트엔드 REPLAY 모드: 외부 API 호출 제거, 샘플 데이터 전용
- 프론트엔드 airplaneslive.ts / opensky.ts 삭제 (백엔드로 대체)
- Vite 프록시에서 airplaneslive / opensky 항목 제거
## [2026-03-17.4]
### 추가
- 헤더 우측 사용자 프로필/이름 + 로그아웃 버튼
- 로그인 화면 KCG 로고 적용 (kcg.svg)
- 브라우저 탭 favicon/제목 변경 (kcg-dashboard-demo)
- OSINT 수집기: GDELT + Google News RSS 백엔드 수집 (@Scheduled 2분)
- Satellite 수집기: CelesTrak TLE 백엔드 수집 (@Scheduled 10분)
- `GET /api/osint?region=iran|korea`, `GET /api/satellites?region=iran|korea` REST API
- Caffeine 캐시 TTL 2일 (Aircraft 포함 전체 통일)
- DB 마이그레이션: `ship_positions`, `osint_feeds`, `satellite_tle` 테이블 + 샘플 데이터
- 프론트엔드 OSINT/위성 데이터 백엔드 API 우선 호출 + 직접 호출 fallback
### 수정
- 항공기 API 폴링 주기 15초/25초 → 60초 (Rate Limit 대응)
- CORS: CorsFilter 최우선 순위 등록 (프로덕션 도메인 허용)
- 프로덕션 빌드 시 console/debugger 자동 제거
### 변경
- deploy.yml: Gitea secrets → .env 파일로 백엔드 환경변수 배포
- systemd/nginx: 배포 경로 /devdata/services/kcg/ 반영
## [2026-03-17.3]
### 수정
- CI/CD 워크플로우 전면 재구성: act 컨테이너(node:24) 환경 대응
- `sudo` 제거, `apt-get`으로 JDK/Maven 직접 설치
- `systemctl``.deploy-trigger` + systemd path unit 패턴 전환
- act-runner 볼륨 마운트 추가 (`/deploy/kcg`, `/deploy/kcg-backend`)
## [2026-03-17.2]
### 수정
- CI 빌드 실패 해결: `@rollup/rollup-darwin-arm64` 직접 의존성 제거 (플랫폼별 optional 자동 관리)
- CI 워크플로우 `npm ci` 복원 (lockfile 기반 정확한 설치)
- 모노레포 pre-commit hook `frontend/` 디렉토리 기준 실행
- 002 마이그레이션 search_path에 public 추가 (PostGIS 타입 참조)
## [2026-03-17]
@ -60,18 +27,37 @@
- 레이어 패널 트리 구조 재설계 (카테고리별 온/오프, 접이식 범례)
- Google OAuth 로그인 + DEV LOGIN 인증 우회 (개발 모드)
- 선박 이미지 탭 전환 UI (signal-batch / MarineTraffic)
- 백엔드 Spring Boot 3.2 스켈레톤 (Java 17)
- 백엔드 Spring Boot 3.2 스켈레톤 (JDK 21)
- Google OAuth + JWT 인증 API (`@gcsc.co.kr` 도메인 제한)
- 데이터 수집기 placeholder (GDELT, Google News, CENTCOM)
- 백엔드 항공기 수집기 (Airplanes.live + OpenSky, @Scheduled 60초 주기)
- 인메모리 캐시 + PostGIS DB 적재 (향후 리플레이 지원)
- `GET /api/aircraft?region=iran|korea` REST API
- DB 마이그레이션: `aircraft_positions` 테이블 (geometry + GiST 인덱스)
- 헤더 우측 사용자 프로필/이름 + 로그아웃 버튼
- 로그인 화면 KCG 로고 적용 (kcg.svg)
- 브라우저 탭 favicon/제목 변경 (kcg-dashboard-demo)
- PostgreSQL 스키마 (events, news, osint, users, login_history)
- Python FastAPI 분석서버 placeholder
- Gitea Actions CI/CD 파이프라인 (main merge 시 자동 배포)
- nginx 설정 (SPA + API 프록시 + 외부 API CORS 프록시)
- systemd 서비스 (kcg-backend, JDK 17, 2~4GB 힙)
- systemd 서비스 (kcg-backend, JDK 21, 2~4GB 힙)
### 수정
- 항공기 API 폴링 주기 15초/25초 → 60초 (Rate Limit 대응)
- CORS: CorsFilter 최우선 순위 등록 (프로덕션 도메인 허용)
- 프로덕션 빌드 시 console/debugger 자동 제거
- CI/CD 워크플로우 전면 재구성: act 컨테이너(node:24) 환경 대응
- CI 빌드 실패 해결: `@rollup/rollup-darwin-arm64` 직접 의존성 제거
- 모노레포 pre-commit hook `frontend/` 디렉토리 기준 실행
### 변경
- JDK 17 → 21 업그레이드 (pom.xml, sdkmanrc, CI/CD, systemd)
- 프론트엔드 REPLAY 모드: 외부 API 호출 제거, 샘플 데이터 전용
- 프론트엔드 airplaneslive.ts / opensky.ts 삭제 (백엔드로 대체)
- Vite 프록시에서 airplaneslive / opensky 항목 제거
- deploy.yml: Gitea secrets → .env 파일로 백엔드 환경변수 배포
- systemd/nginx: 배포 경로 /devdata/services/kcg/ 반영
- 외부 API 호출 CORS 프록시 전환 (Airplanes.live, OpenSky, CelesTrak)
- App.css 하드코딩 색상 → CSS 변수 토큰 전환 (테마 반응)
- 선박 분류 체계 AIS shipTy 파싱 개선
- 한국 선박 데이터 폴링 주기 15초 → 4분
- 범례 카운트 MT 분류 기준으로 동기화

파일 보기

@ -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;