Merge pull request 'release: 2026-03-18 (8건 커밋)' (#28) from develop into main
All checks were successful
Deploy KCG / deploy (push) Successful in 1m7s
All checks were successful
Deploy KCG / deploy (push) Successful in 1m7s
This commit is contained in:
커밋
ed9a2e3233
@ -1,6 +1,6 @@
|
||||
{
|
||||
"applied_global_version": "1.6.1",
|
||||
"applied_date": "2026-03-17",
|
||||
"applied_date": "2026-03-18",
|
||||
"project_type": "react-ts",
|
||||
"gitea_url": "https://gitea.gc-si.dev",
|
||||
"custom_pre_commit": true
|
||||
|
||||
@ -5,7 +5,7 @@
|
||||
#==============================================================================
|
||||
|
||||
COMMIT_MSG_FILE="$1"
|
||||
COMMIT_MSG=$(head -1 "$COMMIT_MSG_FILE")
|
||||
COMMIT_MSG=$(cat "$COMMIT_MSG_FILE")
|
||||
|
||||
# Merge 커밋은 검증 건너뜀
|
||||
if echo "$COMMIT_MSG" | head -1 | grep -qE "^Merge "; then
|
||||
|
||||
@ -37,6 +37,16 @@
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-validation</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-cache</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Caffeine Cache -->
|
||||
<dependency>
|
||||
<groupId>com.github.ben-manes.caffeine</groupId>
|
||||
<artifactId>caffeine</artifactId>
|
||||
</dependency>
|
||||
|
||||
<!-- Database -->
|
||||
<dependency>
|
||||
|
||||
@ -1,34 +1,33 @@
|
||||
package gc.mda.kcg.collector.aircraft;
|
||||
|
||||
import gc.mda.kcg.config.CacheConfig;
|
||||
import gc.mda.kcg.domain.aircraft.AircraftDto;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.cache.Cache;
|
||||
import org.springframework.cache.CacheManager;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import java.util.*;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
/**
|
||||
* 항공기 데이터 인메모리 캐시.
|
||||
* 소스별 원본을 유지하고, 병합된 최종 결과를 Controller에 제공.
|
||||
* 항공기 데이터 캐시.
|
||||
* 소스별 원본을 유지하고, 병합된 최종 결과를 Caffeine 캐시로 제공.
|
||||
*/
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class AircraftCacheStore {
|
||||
|
||||
// 최종 병합 결과 (Controller가 읽는 데이터)
|
||||
private final Map<String, List<AircraftDto>> regionCache = new ConcurrentHashMap<>();
|
||||
private final CacheManager cacheManager;
|
||||
|
||||
// 소스별 원본 버퍼 (region → source → aircraft list)
|
||||
// 소스별 원본 버퍼 (region → source → aircraft list) — 병합 전 임시 저장
|
||||
private final Map<String, Map<String, List<AircraftDto>>> sourceBuffers = new ConcurrentHashMap<>();
|
||||
|
||||
// 마지막 갱신 시각
|
||||
private final Map<String, Long> lastUpdated = new ConcurrentHashMap<>();
|
||||
|
||||
public static final String SOURCE_LIVE = "live";
|
||||
public static final String SOURCE_MIL = "mil";
|
||||
public static final String SOURCE_OPENSKY = "opensky";
|
||||
|
||||
/**
|
||||
* 특정 소스의 데이터 갱신.
|
||||
*/
|
||||
public void updateSource(String region, String source, List<AircraftDto> aircraft) {
|
||||
sourceBuffers
|
||||
.computeIfAbsent(region, k -> new ConcurrentHashMap<>())
|
||||
@ -36,7 +35,7 @@ public class AircraftCacheStore {
|
||||
}
|
||||
|
||||
/**
|
||||
* 소스별 데이터를 병합하여 regionCache를 갱신.
|
||||
* 소스별 데이터를 병합하여 Caffeine 캐시를 갱신.
|
||||
* 병합 우선순위: live > mil > opensky (icao24 기준 중복제거)
|
||||
*/
|
||||
public void mergeAndUpdate(String region) {
|
||||
@ -48,16 +47,13 @@ public class AircraftCacheStore {
|
||||
|
||||
Map<String, AircraftDto> merged = new LinkedHashMap<>();
|
||||
|
||||
// 1순위: live (point/radius — 가장 상세)
|
||||
for (AircraftDto a : fromLive) {
|
||||
merged.put(a.getIcao24(), a);
|
||||
}
|
||||
|
||||
// 2순위: mil — 기존 항목은 category/typecode 보강, 없는 항목은 추가
|
||||
for (AircraftDto m : fromMil) {
|
||||
AircraftDto existing = merged.get(m.getIcao24());
|
||||
if (existing != null) {
|
||||
// mil 데이터로 category/typecode 보강
|
||||
merged.put(m.getIcao24(), AircraftDto.builder()
|
||||
.icao24(existing.getIcao24())
|
||||
.callsign(existing.getCallsign())
|
||||
@ -81,25 +77,32 @@ public class AircraftCacheStore {
|
||||
}
|
||||
}
|
||||
|
||||
// 3순위: opensky — 없는 항목만 추가
|
||||
for (AircraftDto a : fromOpenSky) {
|
||||
merged.putIfAbsent(a.getIcao24(), a);
|
||||
}
|
||||
|
||||
regionCache.put(region, Collections.unmodifiableList(new ArrayList<>(merged.values())));
|
||||
List<AircraftDto> result = Collections.unmodifiableList(new ArrayList<>(merged.values()));
|
||||
String cacheName = "iran".equals(region) ? CacheConfig.AIRCRAFT_IRAN : CacheConfig.AIRCRAFT_KOREA;
|
||||
Cache cache = cacheManager.getCache(cacheName);
|
||||
if (cache != null) {
|
||||
cache.put("data", result);
|
||||
}
|
||||
lastUpdated.put(region, System.currentTimeMillis());
|
||||
}
|
||||
|
||||
/**
|
||||
* 병합된 최종 결과 조회.
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
public List<AircraftDto> get(String region) {
|
||||
return regionCache.getOrDefault(region, List.of());
|
||||
String cacheName = "iran".equals(region) ? CacheConfig.AIRCRAFT_IRAN : CacheConfig.AIRCRAFT_KOREA;
|
||||
Cache cache = cacheManager.getCache(cacheName);
|
||||
if (cache != null) {
|
||||
Cache.ValueWrapper wrapper = cache.get("data");
|
||||
if (wrapper != null) {
|
||||
return (List<AircraftDto>) wrapper.get();
|
||||
}
|
||||
}
|
||||
return List.of();
|
||||
}
|
||||
|
||||
/**
|
||||
* 마지막 갱신 시각 조회.
|
||||
*/
|
||||
public long getLastUpdated(String region) {
|
||||
return lastUpdated.getOrDefault(region, 0L);
|
||||
}
|
||||
|
||||
@ -0,0 +1,245 @@
|
||||
package gc.mda.kcg.collector.osint;
|
||||
|
||||
import com.fasterxml.jackson.databind.JsonNode;
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import gc.mda.kcg.config.AppProperties;
|
||||
import gc.mda.kcg.config.CacheConfig;
|
||||
import gc.mda.kcg.domain.osint.OsintDto;
|
||||
import gc.mda.kcg.domain.osint.OsintFeed;
|
||||
import gc.mda.kcg.domain.osint.OsintFeedRepository;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.cache.Cache;
|
||||
import org.springframework.cache.CacheManager;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
import org.w3c.dom.Document;
|
||||
import org.w3c.dom.Element;
|
||||
import org.w3c.dom.NodeList;
|
||||
|
||||
import javax.xml.parsers.DocumentBuilder;
|
||||
import javax.xml.parsers.DocumentBuilderFactory;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.time.Instant;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.time.format.DateTimeParseException;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class OsintCollector {
|
||||
|
||||
private static final String IRAN_KEYWORDS =
|
||||
"\"Strait of Hormuz\" OR Hormuz OR \"Persian Gulf\" OR Iran OR IRGC";
|
||||
private static final String KOREA_KEYWORDS =
|
||||
"해양사고 OR 해경 OR 어선 OR NLL OR 독도 OR \"Korea maritime\"";
|
||||
|
||||
private static final DateTimeFormatter GDELT_FORMATTER =
|
||||
DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'").withZone(ZoneOffset.UTC);
|
||||
private static final DateTimeFormatter RSS_FORMATTER =
|
||||
DateTimeFormatter.ofPattern("EEE, dd MMM yyyy HH:mm:ss z", Locale.ENGLISH);
|
||||
|
||||
private final RestTemplate restTemplate;
|
||||
private final CacheManager cacheManager;
|
||||
private final OsintFeedRepository osintFeedRepository;
|
||||
private final AppProperties appProperties;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
Thread.ofVirtual().name("osint-init").start(() -> {
|
||||
log.info("OSINT 초기 캐시 로드 시작");
|
||||
refreshCache("iran");
|
||||
refreshCache("korea");
|
||||
log.info("OSINT 초기 캐시 로드 완료");
|
||||
});
|
||||
}
|
||||
|
||||
@Scheduled(initialDelay = 30_000, fixedDelay = 120_000)
|
||||
public void collectIran() {
|
||||
log.debug("OSINT 이란 수집 시작");
|
||||
collectGdelt("iran", IRAN_KEYWORDS);
|
||||
collectGoogleNews("iran", "Iran Hormuz military", "en");
|
||||
refreshCache("iran");
|
||||
log.debug("OSINT 이란 수집 완료");
|
||||
}
|
||||
|
||||
@Scheduled(initialDelay = 45_000, fixedDelay = 120_000)
|
||||
public void collectKorea() {
|
||||
log.debug("OSINT 한국 수집 시작");
|
||||
collectGdelt("korea", KOREA_KEYWORDS);
|
||||
collectGoogleNews("korea", KOREA_KEYWORDS, "ko");
|
||||
refreshCache("korea");
|
||||
log.debug("OSINT 한국 수집 완료");
|
||||
}
|
||||
|
||||
private void collectGdelt(String region, String keywords) {
|
||||
try {
|
||||
String url = String.format(
|
||||
"%s?query=%s&mode=ArtList&maxrecords=30&format=json&sort=DateDesc×pan=24h",
|
||||
appProperties.getCollector().getGdeltBaseUrl(),
|
||||
encodeQuery(keywords)
|
||||
);
|
||||
String body = restTemplate.getForObject(url, String.class);
|
||||
if (body == null || body.isBlank()) return;
|
||||
|
||||
JsonNode root = objectMapper.readTree(body);
|
||||
JsonNode articles = root.path("articles");
|
||||
if (!articles.isArray()) return;
|
||||
|
||||
int saved = 0;
|
||||
for (JsonNode article : articles) {
|
||||
String articleUrl = article.path("url").asText(null);
|
||||
String title = article.path("title").asText(null);
|
||||
if (articleUrl == null || title == null || title.isBlank()) continue;
|
||||
|
||||
if (osintFeedRepository.existsBySourceAndSourceUrl("gdelt", articleUrl)) continue;
|
||||
|
||||
String seendate = article.path("seendate").asText(null);
|
||||
Instant publishedAt = parseGdeltDate(seendate);
|
||||
|
||||
String language = article.path("language").asText("en");
|
||||
String imageUrl = article.path("socialimage").asText(null);
|
||||
if (imageUrl != null && imageUrl.isBlank()) imageUrl = null;
|
||||
|
||||
OsintFeed feed = OsintFeed.builder()
|
||||
.title(title)
|
||||
.source("gdelt")
|
||||
.sourceUrl(articleUrl)
|
||||
.category(classifyCategory(title))
|
||||
.language(language.toLowerCase())
|
||||
.region(region)
|
||||
.imageUrl(imageUrl)
|
||||
.position(null)
|
||||
.publishedAt(publishedAt)
|
||||
.build();
|
||||
|
||||
osintFeedRepository.save(feed);
|
||||
saved++;
|
||||
}
|
||||
log.debug("GDELT {} 저장: {}건", region, saved);
|
||||
} catch (Exception e) {
|
||||
log.warn("GDELT {} 수집 실패: {}", region, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void collectGoogleNews(String region, String query, String lang) {
|
||||
try {
|
||||
boolean isKorean = "ko".equals(lang);
|
||||
String hl = isKorean ? "ko" : "en";
|
||||
String gl = isKorean ? "KR" : "US";
|
||||
String ceid = isKorean ? "KR:ko" : "US:en";
|
||||
String sourceName = isKorean ? "google-news-ko" : "google-news-en";
|
||||
|
||||
String url = String.format(
|
||||
"%s?q=%s&hl=%s&gl=%s&ceid=%s",
|
||||
appProperties.getCollector().getGoogleNewsBaseUrl(),
|
||||
encodeQuery(query),
|
||||
hl, gl, ceid
|
||||
);
|
||||
|
||||
String body = restTemplate.getForObject(url, String.class);
|
||||
if (body == null || body.isBlank()) return;
|
||||
|
||||
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
|
||||
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
|
||||
DocumentBuilder builder = factory.newDocumentBuilder();
|
||||
Document doc = builder.parse(new ByteArrayInputStream(body.getBytes(StandardCharsets.UTF_8)));
|
||||
|
||||
NodeList items = doc.getElementsByTagName("item");
|
||||
int saved = 0;
|
||||
for (int i = 0; i < items.getLength(); i++) {
|
||||
Element item = (Element) items.item(i);
|
||||
String title = getTextContent(item, "title");
|
||||
String link = getTextContent(item, "link");
|
||||
String pubDate = getTextContent(item, "pubDate");
|
||||
|
||||
if (link == null || title == null || title.isBlank()) continue;
|
||||
if (osintFeedRepository.existsBySourceAndSourceUrl(sourceName, link)) continue;
|
||||
|
||||
Instant publishedAt = parseRssDate(pubDate);
|
||||
|
||||
OsintFeed feed = OsintFeed.builder()
|
||||
.title(title)
|
||||
.source(sourceName)
|
||||
.sourceUrl(link)
|
||||
.category(classifyCategory(title))
|
||||
.language(lang)
|
||||
.region(region)
|
||||
.imageUrl(null)
|
||||
.position(null)
|
||||
.publishedAt(publishedAt)
|
||||
.build();
|
||||
|
||||
osintFeedRepository.save(feed);
|
||||
saved++;
|
||||
}
|
||||
log.debug("Google News {} ({}) 저장: {}건", region, lang, saved);
|
||||
} catch (Exception e) {
|
||||
log.warn("Google News {} ({}) 수집 실패: {}", region, lang, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
private void refreshCache(String region) {
|
||||
Instant since = Instant.now().minus(24, ChronoUnit.HOURS);
|
||||
List<OsintDto> dtos = osintFeedRepository
|
||||
.findByRegionAndCollectedAtAfterOrderByPublishedAtDesc(region, since)
|
||||
.stream().map(OsintDto::from).toList();
|
||||
String cacheName = "iran".equals(region) ? CacheConfig.OSINT_IRAN : CacheConfig.OSINT_KOREA;
|
||||
Cache cache = cacheManager.getCache(cacheName);
|
||||
if (cache != null) {
|
||||
cache.put("data", dtos);
|
||||
}
|
||||
log.debug("OSINT {} 캐시 갱신: {}건", region, dtos.size());
|
||||
}
|
||||
|
||||
private String classifyCategory(String title) {
|
||||
String t = title.toLowerCase();
|
||||
if (t.matches(".*(strike|missile|attack|military|weapon|drone|전투|공습|미사일).*")) return "military";
|
||||
if (t.matches(".*(oil|crude|opec|유가|원유|석유).*")) return "oil";
|
||||
if (t.matches(".*(diplomat|sanction|treaty|외교|제재|협상).*")) return "diplomacy";
|
||||
if (t.matches(".*(ship|vessel|maritime|해운|선박|항만).*")) return "shipping";
|
||||
if (t.matches(".*(nuclear|uranium|핵|우라늄).*")) return "nuclear";
|
||||
if (t.matches(".*(해양사고|충돌|좌초|침몰|collision|capsiz).*")) return "maritime_accident";
|
||||
if (t.matches(".*(어선|어업|불법조업|fishing).*")) return "fishing";
|
||||
return "general";
|
||||
}
|
||||
|
||||
private Instant parseGdeltDate(String seendate) {
|
||||
if (seendate == null || seendate.isBlank()) return null;
|
||||
try {
|
||||
return Instant.from(GDELT_FORMATTER.parse(seendate));
|
||||
} catch (DateTimeParseException e) {
|
||||
log.debug("GDELT 날짜 파싱 실패: {}", seendate);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private Instant parseRssDate(String pubDate) {
|
||||
if (pubDate == null || pubDate.isBlank()) return null;
|
||||
try {
|
||||
return Instant.from(RSS_FORMATTER.parse(pubDate));
|
||||
} catch (DateTimeParseException e) {
|
||||
log.debug("RSS 날짜 파싱 실패: {}", pubDate);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private String getTextContent(Element parent, String tagName) {
|
||||
NodeList nodes = parent.getElementsByTagName(tagName);
|
||||
if (nodes.getLength() == 0) return null;
|
||||
String text = nodes.item(0).getTextContent();
|
||||
return (text == null || text.isBlank()) ? null : text.trim();
|
||||
}
|
||||
|
||||
private String encodeQuery(String query) {
|
||||
return query.replace(" ", "+");
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,199 @@
|
||||
package gc.mda.kcg.collector.satellite;
|
||||
|
||||
import gc.mda.kcg.config.AppProperties;
|
||||
import gc.mda.kcg.config.CacheConfig;
|
||||
import gc.mda.kcg.domain.satellite.SatelliteDto;
|
||||
import gc.mda.kcg.domain.satellite.SatelliteTle;
|
||||
import gc.mda.kcg.domain.satellite.SatelliteTleRepository;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.cache.Cache;
|
||||
import org.springframework.cache.CacheManager;
|
||||
import org.springframework.scheduling.annotation.Scheduled;
|
||||
import org.springframework.stereotype.Component;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.time.LocalDate;
|
||||
import java.time.ZoneOffset;
|
||||
import java.time.temporal.ChronoUnit;
|
||||
import java.util.*;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
@Slf4j
|
||||
@Component
|
||||
@RequiredArgsConstructor
|
||||
public class SatelliteCollector {
|
||||
|
||||
private static final Map<String, String> TLE_GROUPS = Map.of(
|
||||
"military", "reconnaissance",
|
||||
"gps-ops", "navigation",
|
||||
"geo", "communications",
|
||||
"weather", "weather",
|
||||
"stations", "other"
|
||||
);
|
||||
|
||||
private static final Pattern RECON_PATTERN = Pattern.compile("SBIRS|DSP|STSS|NROL|USA");
|
||||
private static final Pattern COMMS_PATTERN = Pattern.compile("WGS|AEHF|MUOS|STARLINK|ONEWEB");
|
||||
private static final Pattern NAV_PATTERN = Pattern.compile("GPS|NAVSTAR");
|
||||
|
||||
public volatile long lastUpdated = 0L;
|
||||
|
||||
private final RestTemplate restTemplate;
|
||||
private final CacheManager cacheManager;
|
||||
private final SatelliteTleRepository repository;
|
||||
private final AppProperties appProperties;
|
||||
|
||||
@PostConstruct
|
||||
public void init() {
|
||||
Thread.ofVirtual().name("satellite-init").start(() -> {
|
||||
log.info("위성 TLE 초기 캐시 로드 시작");
|
||||
loadCacheFromDb();
|
||||
log.info("위성 TLE 초기 캐시 로드 완료");
|
||||
});
|
||||
}
|
||||
|
||||
@Scheduled(initialDelay = 60_000, fixedDelay = 600_000)
|
||||
public void collect() {
|
||||
log.info("위성 TLE 수집 시작");
|
||||
String baseUrl = appProperties.getCollector().getCelestrakBaseUrl();
|
||||
List<SatelliteTle> allEntities = new ArrayList<>();
|
||||
Set<Integer> seenNoradIds = new LinkedHashSet<>();
|
||||
Instant now = Instant.now();
|
||||
|
||||
for (Map.Entry<String, String> entry : TLE_GROUPS.entrySet()) {
|
||||
String group = entry.getKey();
|
||||
String defaultCategory = entry.getValue();
|
||||
|
||||
try {
|
||||
String url = baseUrl + "?GROUP=" + group + "&FORMAT=tle";
|
||||
String body = restTemplate.getForObject(url, String.class);
|
||||
if (body == null || body.isBlank()) {
|
||||
log.warn("위성 TLE 응답 없음: group={}", group);
|
||||
continue;
|
||||
}
|
||||
|
||||
List<SatelliteTle> entities = parseTle(body, group, defaultCategory, now, seenNoradIds);
|
||||
allEntities.addAll(entities);
|
||||
log.debug("위성 TLE 파싱 완료: group={}, count={}", group, entities.size());
|
||||
|
||||
} catch (Exception e) {
|
||||
log.warn("위성 TLE 수집 실패: group={}, error={}", group, e.getMessage());
|
||||
}
|
||||
|
||||
sleep(1000);
|
||||
}
|
||||
|
||||
if (!allEntities.isEmpty()) {
|
||||
try {
|
||||
repository.saveAll(allEntities);
|
||||
log.info("위성 TLE DB 적재 완료: {} 건", allEntities.size());
|
||||
} catch (Exception e) {
|
||||
log.error("위성 TLE DB 적재 실패: {}", e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
loadCacheFromDb();
|
||||
}
|
||||
|
||||
public long getLastUpdated() {
|
||||
return lastUpdated;
|
||||
}
|
||||
|
||||
void loadCacheFromDb() {
|
||||
Instant since = Instant.now().minus(11, ChronoUnit.MINUTES);
|
||||
List<SatelliteDto> dtos = repository.findByCollectedAtAfterOrderByNoradIdAsc(since)
|
||||
.stream().map(SatelliteDto::from).toList();
|
||||
|
||||
if (dtos.isEmpty()) {
|
||||
since = Instant.now().minus(24, ChronoUnit.HOURS);
|
||||
dtos = repository.findByCollectedAtAfterOrderByNoradIdAsc(since)
|
||||
.stream().map(SatelliteDto::from).toList();
|
||||
}
|
||||
|
||||
if (!dtos.isEmpty()) {
|
||||
Cache cache = cacheManager.getCache(CacheConfig.SATELLITES);
|
||||
if (cache != null) cache.put("data", dtos);
|
||||
lastUpdated = System.currentTimeMillis();
|
||||
log.debug("위성 TLE 캐시 갱신 완료: {} 건", dtos.size());
|
||||
}
|
||||
}
|
||||
|
||||
private List<SatelliteTle> parseTle(String body, String group, String defaultCategory,
|
||||
Instant collectedAt, Set<Integer> seenNoradIds) {
|
||||
List<SatelliteTle> result = new ArrayList<>();
|
||||
String[] lines = body.lines()
|
||||
.map(String::trim)
|
||||
.filter(l -> !l.isBlank())
|
||||
.toArray(String[]::new);
|
||||
|
||||
for (int i = 0; i + 2 < lines.length; i += 3) {
|
||||
String nameLine = lines[i];
|
||||
String line1 = lines[i + 1];
|
||||
String line2 = lines[i + 2];
|
||||
|
||||
if (!line1.startsWith("1 ") || !line2.startsWith("2 ")) {
|
||||
log.debug("TLE 형식 불일치 스킵: name={}", nameLine);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
String name = nameLine.trim();
|
||||
int noradId = Integer.parseInt(line1.substring(2, 7).trim());
|
||||
|
||||
if (!seenNoradIds.add(noradId)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
Instant epoch = parseTleEpoch(line1);
|
||||
String category = resolveCategory(name, defaultCategory);
|
||||
|
||||
result.add(SatelliteTle.builder()
|
||||
.noradId(noradId)
|
||||
.name(name)
|
||||
.tleLine1(line1)
|
||||
.tleLine2(line2)
|
||||
.category(category)
|
||||
.tleGroup(group)
|
||||
.epoch(epoch)
|
||||
.collectedAt(collectedAt)
|
||||
.build());
|
||||
|
||||
} catch (Exception e) {
|
||||
log.debug("TLE 파싱 실패 스킵: name={}, error={}", nameLine, e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
private Instant parseTleEpoch(String line1) {
|
||||
String epochStr = line1.substring(18, 32).trim();
|
||||
int yy = Integer.parseInt(epochStr.substring(0, 2));
|
||||
int year = (yy < 57) ? 2000 + yy : 1900 + yy;
|
||||
double dayOfYearFraction = Double.parseDouble(epochStr.substring(2));
|
||||
int dayOfYear = (int) dayOfYearFraction;
|
||||
double fraction = dayOfYearFraction - dayOfYear;
|
||||
|
||||
return LocalDate.ofYearDay(year, dayOfYear)
|
||||
.atStartOfDay(ZoneOffset.UTC)
|
||||
.toInstant()
|
||||
.plusMillis((long) (fraction * 86_400_000));
|
||||
}
|
||||
|
||||
private String resolveCategory(String name, String defaultCategory) {
|
||||
if (RECON_PATTERN.matcher(name).find()) return "reconnaissance";
|
||||
if (NAV_PATTERN.matcher(name).find()) return "navigation";
|
||||
if (COMMS_PATTERN.matcher(name).find()) return "communications";
|
||||
return defaultCategory;
|
||||
}
|
||||
|
||||
private static void sleep(int ms) {
|
||||
try {
|
||||
Thread.sleep(ms);
|
||||
} catch (InterruptedException e) {
|
||||
Thread.currentThread().interrupt();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -40,6 +40,9 @@ public class AppProperties {
|
||||
public static class Collector {
|
||||
private String airplanesLiveBaseUrl = "https://api.airplanes.live/v2";
|
||||
private String openSkyBaseUrl = "https://opensky-network.org/api";
|
||||
private String gdeltBaseUrl = "https://api.gdeltproject.org/api/v2/doc/doc";
|
||||
private String googleNewsBaseUrl = "https://news.google.com/rss/search";
|
||||
private String celestrakBaseUrl = "https://celestrak.org/NORAD/elements/gp.php";
|
||||
private int requestDelayMs = 1500;
|
||||
private int backoffMs = 5000;
|
||||
}
|
||||
|
||||
34
backend/src/main/java/gc/mda/kcg/config/CacheConfig.java
Normal file
34
backend/src/main/java/gc/mda/kcg/config/CacheConfig.java
Normal file
@ -0,0 +1,34 @@
|
||||
package gc.mda.kcg.config;
|
||||
|
||||
import com.github.benmanes.caffeine.cache.Caffeine;
|
||||
import org.springframework.cache.CacheManager;
|
||||
import org.springframework.cache.annotation.EnableCaching;
|
||||
import org.springframework.cache.caffeine.CaffeineCacheManager;
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
|
||||
import java.util.concurrent.TimeUnit;
|
||||
|
||||
@Configuration
|
||||
@EnableCaching
|
||||
public class CacheConfig {
|
||||
|
||||
public static final String AIRCRAFT_IRAN = "aircraft-iran";
|
||||
public static final String AIRCRAFT_KOREA = "aircraft-korea";
|
||||
public static final String OSINT_IRAN = "osint-iran";
|
||||
public static final String OSINT_KOREA = "osint-korea";
|
||||
public static final String SATELLITES = "satellites";
|
||||
|
||||
@Bean
|
||||
public CacheManager cacheManager() {
|
||||
CaffeineCacheManager manager = new CaffeineCacheManager(
|
||||
AIRCRAFT_IRAN, AIRCRAFT_KOREA,
|
||||
OSINT_IRAN, OSINT_KOREA,
|
||||
SATELLITES
|
||||
);
|
||||
manager.setCaffeine(Caffeine.newBuilder()
|
||||
.expireAfterWrite(2, TimeUnit.DAYS)
|
||||
.maximumSize(1));
|
||||
return manager;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,65 @@
|
||||
package gc.mda.kcg.domain.osint;
|
||||
|
||||
import gc.mda.kcg.config.CacheConfig;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.cache.Cache;
|
||||
import org.springframework.cache.CacheManager;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.concurrent.ConcurrentHashMap;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/osint")
|
||||
@RequiredArgsConstructor
|
||||
public class OsintController {
|
||||
|
||||
private static final Set<String> VALID_REGIONS = Set.of("iran", "korea");
|
||||
|
||||
private final CacheManager cacheManager;
|
||||
|
||||
private final Map<String, Long> lastUpdated = new ConcurrentHashMap<>();
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<Map<String, Object>> getOsint(
|
||||
@RequestParam(defaultValue = "iran") String region) {
|
||||
|
||||
if (!VALID_REGIONS.contains(region)) {
|
||||
return ResponseEntity.badRequest()
|
||||
.body(Map.of("error", "유효하지 않은 region: " + region));
|
||||
}
|
||||
|
||||
String cacheName = "iran".equals(region) ? CacheConfig.OSINT_IRAN : CacheConfig.OSINT_KOREA;
|
||||
List<OsintDto> items = getCachedItems(cacheName);
|
||||
long updatedAt = lastUpdated.getOrDefault(region, 0L);
|
||||
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"region", region,
|
||||
"count", items.size(),
|
||||
"lastUpdated", updatedAt,
|
||||
"items", items
|
||||
));
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private List<OsintDto> getCachedItems(String cacheName) {
|
||||
Cache cache = cacheManager.getCache(cacheName);
|
||||
if (cache != null) {
|
||||
Cache.ValueWrapper wrapper = cache.get("data");
|
||||
if (wrapper != null) {
|
||||
return (List<OsintDto>) wrapper.get();
|
||||
}
|
||||
}
|
||||
return List.of();
|
||||
}
|
||||
|
||||
public void markUpdated(String region) {
|
||||
lastUpdated.put(region, System.currentTimeMillis());
|
||||
}
|
||||
}
|
||||
39
backend/src/main/java/gc/mda/kcg/domain/osint/OsintDto.java
Normal file
39
backend/src/main/java/gc/mda/kcg/domain/osint/OsintDto.java
Normal file
@ -0,0 +1,39 @@
|
||||
package gc.mda.kcg.domain.osint;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
|
||||
@Getter
|
||||
@Builder
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class OsintDto {
|
||||
|
||||
private String id;
|
||||
private long timestamp;
|
||||
private String title;
|
||||
private String source;
|
||||
private String url;
|
||||
private String category;
|
||||
private String language;
|
||||
private String imageUrl;
|
||||
private Double lat;
|
||||
private Double lng;
|
||||
|
||||
public static OsintDto from(OsintFeed f) {
|
||||
return OsintDto.builder()
|
||||
.id(f.getSource() + "-" + f.getId())
|
||||
.timestamp(f.getPublishedAt() != null
|
||||
? f.getPublishedAt().toEpochMilli()
|
||||
: f.getCollectedAt().toEpochMilli())
|
||||
.title(f.getTitle())
|
||||
.source(f.getSource())
|
||||
.url(f.getSourceUrl())
|
||||
.category(f.getCategory())
|
||||
.language(f.getLanguage())
|
||||
.imageUrl(f.getImageUrl())
|
||||
.lat(f.getPosition() != null ? f.getPosition().getY() : null)
|
||||
.lng(f.getPosition() != null ? f.getPosition().getX() : null)
|
||||
.build();
|
||||
}
|
||||
}
|
||||
62
backend/src/main/java/gc/mda/kcg/domain/osint/OsintFeed.java
Normal file
62
backend/src/main/java/gc/mda/kcg/domain/osint/OsintFeed.java
Normal file
@ -0,0 +1,62 @@
|
||||
package gc.mda.kcg.domain.osint;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
import org.locationtech.jts.geom.Point;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
@Entity
|
||||
@Table(
|
||||
name = "osint_feeds",
|
||||
schema = "kcg",
|
||||
uniqueConstraints = @UniqueConstraint(columnNames = {"source", "source_url"})
|
||||
)
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class OsintFeed {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(nullable = false, columnDefinition = "TEXT")
|
||||
private String title;
|
||||
|
||||
@Column(nullable = false, length = 64)
|
||||
private String source;
|
||||
|
||||
@Column(name = "source_url", columnDefinition = "TEXT")
|
||||
private String sourceUrl;
|
||||
|
||||
@Column(length = 32)
|
||||
private String category;
|
||||
|
||||
@Column(length = 8)
|
||||
private String language;
|
||||
|
||||
@Column(name = "focus", length = 16)
|
||||
private String region;
|
||||
|
||||
@Column(name = "image_url", columnDefinition = "TEXT")
|
||||
private String imageUrl;
|
||||
|
||||
@Column(columnDefinition = "geometry(Point, 4326)")
|
||||
private Point position;
|
||||
|
||||
@Column(name = "published_at")
|
||||
private Instant publishedAt;
|
||||
|
||||
@Column(name = "collected_at", nullable = false)
|
||||
private Instant collectedAt;
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
if (collectedAt == null) {
|
||||
collectedAt = Instant.now();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,13 @@
|
||||
package gc.mda.kcg.domain.osint;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
public interface OsintFeedRepository extends JpaRepository<OsintFeed, Long> {
|
||||
|
||||
boolean existsBySourceAndSourceUrl(String source, String sourceUrl);
|
||||
|
||||
List<OsintFeed> findByRegionAndCollectedAtAfterOrderByPublishedAtDesc(String region, Instant since);
|
||||
}
|
||||
@ -0,0 +1,46 @@
|
||||
package gc.mda.kcg.domain.satellite;
|
||||
|
||||
import gc.mda.kcg.collector.satellite.SatelliteCollector;
|
||||
import gc.mda.kcg.config.CacheConfig;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import org.springframework.cache.Cache;
|
||||
import org.springframework.cache.CacheManager;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RequestParam;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/satellites")
|
||||
@RequiredArgsConstructor
|
||||
public class SatelliteController {
|
||||
|
||||
private final CacheManager cacheManager;
|
||||
private final SatelliteCollector satelliteCollector;
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<Map<String, Object>> getSatellites(
|
||||
@RequestParam(defaultValue = "iran") String region) {
|
||||
|
||||
Cache cache = cacheManager.getCache(CacheConfig.SATELLITES);
|
||||
Cache.ValueWrapper wrapper = cache != null ? cache.get("data") : null;
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
List<SatelliteDto> sats = (wrapper != null && wrapper.get() instanceof List<?>)
|
||||
? (List<SatelliteDto>) wrapper.get()
|
||||
: List.of();
|
||||
|
||||
long lastUpdated = satelliteCollector.getLastUpdated();
|
||||
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"region", region,
|
||||
"count", sats.size(),
|
||||
"lastUpdated", lastUpdated,
|
||||
"satellites", sats
|
||||
));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,27 @@
|
||||
package gc.mda.kcg.domain.satellite;
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonInclude;
|
||||
import lombok.Builder;
|
||||
import lombok.Getter;
|
||||
|
||||
@Getter
|
||||
@Builder
|
||||
@JsonInclude(JsonInclude.Include.NON_NULL)
|
||||
public class SatelliteDto {
|
||||
|
||||
private int noradId;
|
||||
private String name;
|
||||
private String tle1;
|
||||
private String tle2;
|
||||
private String category;
|
||||
|
||||
public static SatelliteDto from(SatelliteTle e) {
|
||||
return SatelliteDto.builder()
|
||||
.noradId(e.getNoradId())
|
||||
.name(e.getName())
|
||||
.tle1(e.getTleLine1())
|
||||
.tle2(e.getTleLine2())
|
||||
.category(e.getCategory())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,50 @@
|
||||
package gc.mda.kcg.domain.satellite;
|
||||
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
|
||||
import java.time.Instant;
|
||||
|
||||
@Entity
|
||||
@Table(name = "satellite_tle", schema = "kcg")
|
||||
@Getter
|
||||
@Setter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class SatelliteTle {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
@Column(name = "norad_id", nullable = false)
|
||||
private Integer noradId;
|
||||
|
||||
@Column(nullable = false, length = 128)
|
||||
private String name;
|
||||
|
||||
@Column(name = "tle_line1", nullable = false, length = 70)
|
||||
private String tleLine1;
|
||||
|
||||
@Column(name = "tle_line2", nullable = false, length = 70)
|
||||
private String tleLine2;
|
||||
|
||||
@Column(length = 20)
|
||||
private String category;
|
||||
|
||||
@Column(name = "tle_group", length = 32)
|
||||
private String tleGroup;
|
||||
|
||||
private Instant epoch;
|
||||
|
||||
@Column(name = "collected_at", nullable = false)
|
||||
private Instant collectedAt;
|
||||
|
||||
@PrePersist
|
||||
protected void onCreate() {
|
||||
if (collectedAt == null) {
|
||||
collectedAt = Instant.now();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,11 @@
|
||||
package gc.mda.kcg.domain.satellite;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
|
||||
public interface SatelliteTleRepository extends JpaRepository<SatelliteTle, Long> {
|
||||
|
||||
List<SatelliteTle> findByCollectedAtAfterOrderByNoradIdAsc(Instant since);
|
||||
}
|
||||
@ -1,7 +1,7 @@
|
||||
-- 002: 항공기 위치 이력 테이블 (PostGIS)
|
||||
-- 리플레이 기능을 위한 시계열 위치 데이터 저장
|
||||
|
||||
SET search_path TO kcg;
|
||||
SET search_path TO kcg, public;
|
||||
|
||||
-- PostGIS 확장 활성화
|
||||
CREATE EXTENSION IF NOT EXISTS postgis;
|
||||
|
||||
79
database/migration/003_ships_osint_satellites.sql
Normal file
79
database/migration/003_ships_osint_satellites.sql
Normal file
@ -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);
|
||||
60
database/seed/001_sample_ships.sql
Normal file
60
database/seed/001_sample_ships.sql
Normal file
@ -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');
|
||||
31
database/seed/002_sample_osint.sql
Normal file
31
database/seed/002_sample_osint.sql
Normal file
@ -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');
|
||||
36
database/seed/003_sample_satellites.sql
Normal file
36
database/seed/003_sample_satellites.sql
Normal file
@ -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;
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user