From f40a0789a24fdd87ce59333f5927b531a796c6a0 Mon Sep 17 00:00:00 2001 From: htlee Date: Wed, 18 Mar 2026 02:39:40 +0900 Subject: [PATCH 1/5] =?UTF-8?q?fix(db):=20002=20=EB=A7=88=EC=9D=B4?= =?UTF-8?q?=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98=20search=5Fpath=EC=97=90?= =?UTF-8?q?=20public=20=EC=B6=94=EA=B0=80=20(PostGIS=20=ED=83=80=EC=9E=85?= =?UTF-8?q?=20=EC=B0=B8=EC=A1=B0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- database/migration/002_aircraft_positions.sql | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/database/migration/002_aircraft_positions.sql b/database/migration/002_aircraft_positions.sql index 95f131a..84a3640 100644 --- a/database/migration/002_aircraft_positions.sql +++ b/database/migration/002_aircraft_positions.sql @@ -1,7 +1,7 @@ -- 002: 항공기 위치 이력 테이블 (PostGIS) -- 리플레이 기능을 위한 시계열 위치 데이터 저장 -SET search_path TO kcg; +SET search_path TO kcg, public; -- PostGIS 확장 활성화 CREATE EXTENSION IF NOT EXISTS postgis; From 0c78ad8bb853323e3743eb3c5c3da4513c375216 Mon Sep 17 00:00:00 2001 From: htlee Date: Wed, 18 Mar 2026 02:59:54 +0900 Subject: [PATCH 2/5] =?UTF-8?q?feat(db):=20ships/osint/satellites=20?= =?UTF-8?q?=ED=85=8C=EC=9D=B4=EB=B8=94=20=EC=83=9D=EC=84=B1=20+=20?= =?UTF-8?q?=EC=83=98=ED=94=8C=20=EB=8D=B0=EC=9D=B4=ED=84=B0=20=EC=A0=81?= =?UTF-8?q?=EC=9E=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ship_positions: AIS/signal-batch 선박 위치 이력 (PostGIS) - osint_feeds: GDELT/Google News/CENTCOM 피드 (UNIQUE 중복방지) - satellite_tle: CelesTrak TLE 위성 궤도 데이터 - 샘플: 중동 함정 16척, 한국 해역 8척, OSINT 17건, 위성 11기 --- .../migration/003_ships_osint_satellites.sql | 79 +++++++++++++++++++ database/seed/001_sample_ships.sql | 60 ++++++++++++++ database/seed/002_sample_osint.sql | 31 ++++++++ database/seed/003_sample_satellites.sql | 36 +++++++++ 4 files changed, 206 insertions(+) create mode 100644 database/migration/003_ships_osint_satellites.sql create mode 100644 database/seed/001_sample_ships.sql create mode 100644 database/seed/002_sample_osint.sql create mode 100644 database/seed/003_sample_satellites.sql diff --git a/database/migration/003_ships_osint_satellites.sql b/database/migration/003_ships_osint_satellites.sql new file mode 100644 index 0000000..0c58763 --- /dev/null +++ b/database/migration/003_ships_osint_satellites.sql @@ -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); diff --git a/database/seed/001_sample_ships.sql b/database/seed/001_sample_ships.sql new file mode 100644 index 0000000..aa1628d --- /dev/null +++ b/database/seed/001_sample_ships.sql @@ -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'); diff --git a/database/seed/002_sample_osint.sql b/database/seed/002_sample_osint.sql new file mode 100644 index 0000000..2eb1133 --- /dev/null +++ b/database/seed/002_sample_osint.sql @@ -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'); diff --git a/database/seed/003_sample_satellites.sql b/database/seed/003_sample_satellites.sql new file mode 100644 index 0000000..5508125 --- /dev/null +++ b/database/seed/003_sample_satellites.sql @@ -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'); From 69b2aeb3b3e8758315e1bd2e0dbdc65778a91a98 Mon Sep 17 00:00:00 2001 From: htlee Date: Wed, 18 Mar 2026 04:04:18 +0900 Subject: [PATCH 3/5] =?UTF-8?q?feat(backend):=20OSINT/Satellite=20?= =?UTF-8?q?=EC=88=98=EC=A7=91=EA=B8=B0=20+=20Caffeine=20=EC=BA=90=EC=8B=9C?= =?UTF-8?q?=20=ED=86=B5=EC=9D=BC=20+=20REST=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - OSINT: GDELT + Google News RSS 수집기 (@Scheduled 2분) - Satellite: CelesTrak TLE 수집기 (@Scheduled 10분) - Caffeine 캐시 TTL 2일 (Aircraft 포함 전체 통일) - 프론트: 백엔드 API 우선 호출 + CelesTrak/GDELT fallback --- .claude/workflow-version.json | 2 +- .githooks/commit-msg | 2 +- backend/pom.xml | 10 + .../aircraft/AircraftCacheStore.java | 49 ++-- .../kcg/collector/osint/OsintCollector.java | 245 ++++++++++++++++++ .../satellite/SatelliteCollector.java | 199 ++++++++++++++ .../java/gc/mda/kcg/config/AppProperties.java | 3 + .../java/gc/mda/kcg/config/CacheConfig.java | 34 +++ .../mda/kcg/domain/osint/OsintController.java | 65 +++++ .../gc/mda/kcg/domain/osint/OsintDto.java | 39 +++ .../gc/mda/kcg/domain/osint/OsintFeed.java | 62 +++++ .../kcg/domain/osint/OsintFeedRepository.java | 13 + .../domain/satellite/SatelliteController.java | 46 ++++ .../kcg/domain/satellite/SatelliteDto.java | 27 ++ .../kcg/domain/satellite/SatelliteTle.java | 50 ++++ .../satellite/SatelliteTleRepository.java | 11 + frontend/src/components/EventLog.tsx | 6 +- frontend/src/components/SatelliteMap.tsx | 2 +- frontend/src/services/celestrak.ts | 92 +++---- frontend/src/services/navWarning.ts | 2 +- frontend/src/services/osint.ts | 18 ++ 21 files changed, 897 insertions(+), 80 deletions(-) create mode 100644 backend/src/main/java/gc/mda/kcg/collector/osint/OsintCollector.java create mode 100644 backend/src/main/java/gc/mda/kcg/collector/satellite/SatelliteCollector.java create mode 100644 backend/src/main/java/gc/mda/kcg/config/CacheConfig.java create mode 100644 backend/src/main/java/gc/mda/kcg/domain/osint/OsintController.java create mode 100644 backend/src/main/java/gc/mda/kcg/domain/osint/OsintDto.java create mode 100644 backend/src/main/java/gc/mda/kcg/domain/osint/OsintFeed.java create mode 100644 backend/src/main/java/gc/mda/kcg/domain/osint/OsintFeedRepository.java create mode 100644 backend/src/main/java/gc/mda/kcg/domain/satellite/SatelliteController.java create mode 100644 backend/src/main/java/gc/mda/kcg/domain/satellite/SatelliteDto.java create mode 100644 backend/src/main/java/gc/mda/kcg/domain/satellite/SatelliteTle.java create mode 100644 backend/src/main/java/gc/mda/kcg/domain/satellite/SatelliteTleRepository.java diff --git a/.claude/workflow-version.json b/.claude/workflow-version.json index c2ddc90..8e28c8b 100644 --- a/.claude/workflow-version.json +++ b/.claude/workflow-version.json @@ -1,6 +1,6 @@ { "applied_global_version": "1.6.1", - "applied_date": "2026-03-17", + "applied_date": "2026-03-18", "project_type": "react-ts", "gitea_url": "https://gitea.gc-si.dev", "custom_pre_commit": true diff --git a/.githooks/commit-msg b/.githooks/commit-msg index 8e65d67..93bb350 100755 --- a/.githooks/commit-msg +++ b/.githooks/commit-msg @@ -5,7 +5,7 @@ #============================================================================== COMMIT_MSG_FILE="$1" -COMMIT_MSG=$(head -1 "$COMMIT_MSG_FILE") +COMMIT_MSG=$(cat "$COMMIT_MSG_FILE") # Merge 커밋은 검증 건너뜀 if echo "$COMMIT_MSG" | head -1 | grep -qE "^Merge "; then diff --git a/backend/pom.xml b/backend/pom.xml index 48d4a08..e7a3698 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -37,6 +37,16 @@ org.springframework.boot spring-boot-starter-validation + + org.springframework.boot + spring-boot-starter-cache + + + + + com.github.ben-manes.caffeine + caffeine + diff --git a/backend/src/main/java/gc/mda/kcg/collector/aircraft/AircraftCacheStore.java b/backend/src/main/java/gc/mda/kcg/collector/aircraft/AircraftCacheStore.java index 965ea60..6ab6e77 100644 --- a/backend/src/main/java/gc/mda/kcg/collector/aircraft/AircraftCacheStore.java +++ b/backend/src/main/java/gc/mda/kcg/collector/aircraft/AircraftCacheStore.java @@ -1,34 +1,33 @@ package gc.mda.kcg.collector.aircraft; +import gc.mda.kcg.config.CacheConfig; import gc.mda.kcg.domain.aircraft.AircraftDto; +import lombok.RequiredArgsConstructor; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; import org.springframework.stereotype.Component; import java.util.*; import java.util.concurrent.ConcurrentHashMap; /** - * 항공기 데이터 인메모리 캐시. - * 소스별 원본을 유지하고, 병합된 최종 결과를 Controller에 제공. + * 항공기 데이터 캐시. + * 소스별 원본을 유지하고, 병합된 최종 결과를 Caffeine 캐시로 제공. */ @Component +@RequiredArgsConstructor public class AircraftCacheStore { - // 최종 병합 결과 (Controller가 읽는 데이터) - private final Map> regionCache = new ConcurrentHashMap<>(); + private final CacheManager cacheManager; - // 소스별 원본 버퍼 (region → source → aircraft list) + // 소스별 원본 버퍼 (region → source → aircraft list) — 병합 전 임시 저장 private final Map>> sourceBuffers = new ConcurrentHashMap<>(); - - // 마지막 갱신 시각 private final Map lastUpdated = new ConcurrentHashMap<>(); public static final String SOURCE_LIVE = "live"; public static final String SOURCE_MIL = "mil"; public static final String SOURCE_OPENSKY = "opensky"; - /** - * 특정 소스의 데이터 갱신. - */ public void updateSource(String region, String source, List aircraft) { sourceBuffers .computeIfAbsent(region, k -> new ConcurrentHashMap<>()) @@ -36,7 +35,7 @@ public class AircraftCacheStore { } /** - * 소스별 데이터를 병합하여 regionCache를 갱신. + * 소스별 데이터를 병합하여 Caffeine 캐시를 갱신. * 병합 우선순위: live > mil > opensky (icao24 기준 중복제거) */ public void mergeAndUpdate(String region) { @@ -48,16 +47,13 @@ public class AircraftCacheStore { Map merged = new LinkedHashMap<>(); - // 1순위: live (point/radius — 가장 상세) for (AircraftDto a : fromLive) { merged.put(a.getIcao24(), a); } - // 2순위: mil — 기존 항목은 category/typecode 보강, 없는 항목은 추가 for (AircraftDto m : fromMil) { AircraftDto existing = merged.get(m.getIcao24()); if (existing != null) { - // mil 데이터로 category/typecode 보강 merged.put(m.getIcao24(), AircraftDto.builder() .icao24(existing.getIcao24()) .callsign(existing.getCallsign()) @@ -81,25 +77,32 @@ public class AircraftCacheStore { } } - // 3순위: opensky — 없는 항목만 추가 for (AircraftDto a : fromOpenSky) { merged.putIfAbsent(a.getIcao24(), a); } - regionCache.put(region, Collections.unmodifiableList(new ArrayList<>(merged.values()))); + List result = Collections.unmodifiableList(new ArrayList<>(merged.values())); + String cacheName = "iran".equals(region) ? CacheConfig.AIRCRAFT_IRAN : CacheConfig.AIRCRAFT_KOREA; + Cache cache = cacheManager.getCache(cacheName); + if (cache != null) { + cache.put("data", result); + } lastUpdated.put(region, System.currentTimeMillis()); } - /** - * 병합된 최종 결과 조회. - */ + @SuppressWarnings("unchecked") public List get(String region) { - return regionCache.getOrDefault(region, List.of()); + String cacheName = "iran".equals(region) ? CacheConfig.AIRCRAFT_IRAN : CacheConfig.AIRCRAFT_KOREA; + Cache cache = cacheManager.getCache(cacheName); + if (cache != null) { + Cache.ValueWrapper wrapper = cache.get("data"); + if (wrapper != null) { + return (List) wrapper.get(); + } + } + return List.of(); } - /** - * 마지막 갱신 시각 조회. - */ public long getLastUpdated(String region) { return lastUpdated.getOrDefault(region, 0L); } diff --git a/backend/src/main/java/gc/mda/kcg/collector/osint/OsintCollector.java b/backend/src/main/java/gc/mda/kcg/collector/osint/OsintCollector.java new file mode 100644 index 0000000..c7ee6f9 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/collector/osint/OsintCollector.java @@ -0,0 +1,245 @@ +package gc.mda.kcg.collector.osint; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import gc.mda.kcg.config.AppProperties; +import gc.mda.kcg.config.CacheConfig; +import gc.mda.kcg.domain.osint.OsintDto; +import gc.mda.kcg.domain.osint.OsintFeed; +import gc.mda.kcg.domain.osint.OsintFeedRepository; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; +import org.w3c.dom.Document; +import org.w3c.dom.Element; +import org.w3c.dom.NodeList; + +import javax.xml.parsers.DocumentBuilder; +import javax.xml.parsers.DocumentBuilderFactory; +import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; +import java.time.Instant; +import java.time.ZoneOffset; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.Locale; + +@Slf4j +@Component +@RequiredArgsConstructor +public class OsintCollector { + + private static final String IRAN_KEYWORDS = + "\"Strait of Hormuz\" OR Hormuz OR \"Persian Gulf\" OR Iran OR IRGC"; + private static final String KOREA_KEYWORDS = + "해양사고 OR 해경 OR 어선 OR NLL OR 독도 OR \"Korea maritime\""; + + private static final DateTimeFormatter GDELT_FORMATTER = + DateTimeFormatter.ofPattern("yyyyMMdd'T'HHmmss'Z'").withZone(ZoneOffset.UTC); + private static final DateTimeFormatter RSS_FORMATTER = + DateTimeFormatter.ofPattern("EEE, dd MMM yyyy HH:mm:ss z", Locale.ENGLISH); + + private final RestTemplate restTemplate; + private final CacheManager cacheManager; + private final OsintFeedRepository osintFeedRepository; + private final AppProperties appProperties; + private final ObjectMapper objectMapper; + + @PostConstruct + public void init() { + Thread.ofVirtual().name("osint-init").start(() -> { + log.info("OSINT 초기 캐시 로드 시작"); + refreshCache("iran"); + refreshCache("korea"); + log.info("OSINT 초기 캐시 로드 완료"); + }); + } + + @Scheduled(initialDelay = 30_000, fixedDelay = 120_000) + public void collectIran() { + log.debug("OSINT 이란 수집 시작"); + collectGdelt("iran", IRAN_KEYWORDS); + collectGoogleNews("iran", "Iran Hormuz military", "en"); + refreshCache("iran"); + log.debug("OSINT 이란 수집 완료"); + } + + @Scheduled(initialDelay = 45_000, fixedDelay = 120_000) + public void collectKorea() { + log.debug("OSINT 한국 수집 시작"); + collectGdelt("korea", KOREA_KEYWORDS); + collectGoogleNews("korea", KOREA_KEYWORDS, "ko"); + refreshCache("korea"); + log.debug("OSINT 한국 수집 완료"); + } + + private void collectGdelt(String region, String keywords) { + try { + String url = String.format( + "%s?query=%s&mode=ArtList&maxrecords=30&format=json&sort=DateDesc×pan=24h", + appProperties.getCollector().getGdeltBaseUrl(), + encodeQuery(keywords) + ); + String body = restTemplate.getForObject(url, String.class); + if (body == null || body.isBlank()) return; + + JsonNode root = objectMapper.readTree(body); + JsonNode articles = root.path("articles"); + if (!articles.isArray()) return; + + int saved = 0; + for (JsonNode article : articles) { + String articleUrl = article.path("url").asText(null); + String title = article.path("title").asText(null); + if (articleUrl == null || title == null || title.isBlank()) continue; + + if (osintFeedRepository.existsBySourceAndSourceUrl("gdelt", articleUrl)) continue; + + String seendate = article.path("seendate").asText(null); + Instant publishedAt = parseGdeltDate(seendate); + + String language = article.path("language").asText("en"); + String imageUrl = article.path("socialimage").asText(null); + if (imageUrl != null && imageUrl.isBlank()) imageUrl = null; + + OsintFeed feed = OsintFeed.builder() + .title(title) + .source("gdelt") + .sourceUrl(articleUrl) + .category(classifyCategory(title)) + .language(language.toLowerCase()) + .region(region) + .imageUrl(imageUrl) + .position(null) + .publishedAt(publishedAt) + .build(); + + osintFeedRepository.save(feed); + saved++; + } + log.debug("GDELT {} 저장: {}건", region, saved); + } catch (Exception e) { + log.warn("GDELT {} 수집 실패: {}", region, e.getMessage()); + } + } + + private void collectGoogleNews(String region, String query, String lang) { + try { + boolean isKorean = "ko".equals(lang); + String hl = isKorean ? "ko" : "en"; + String gl = isKorean ? "KR" : "US"; + String ceid = isKorean ? "KR:ko" : "US:en"; + String sourceName = isKorean ? "google-news-ko" : "google-news-en"; + + String url = String.format( + "%s?q=%s&hl=%s&gl=%s&ceid=%s", + appProperties.getCollector().getGoogleNewsBaseUrl(), + encodeQuery(query), + hl, gl, ceid + ); + + String body = restTemplate.getForObject(url, String.class); + if (body == null || body.isBlank()) return; + + DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance(); + factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); + DocumentBuilder builder = factory.newDocumentBuilder(); + Document doc = builder.parse(new ByteArrayInputStream(body.getBytes(StandardCharsets.UTF_8))); + + NodeList items = doc.getElementsByTagName("item"); + int saved = 0; + for (int i = 0; i < items.getLength(); i++) { + Element item = (Element) items.item(i); + String title = getTextContent(item, "title"); + String link = getTextContent(item, "link"); + String pubDate = getTextContent(item, "pubDate"); + + if (link == null || title == null || title.isBlank()) continue; + if (osintFeedRepository.existsBySourceAndSourceUrl(sourceName, link)) continue; + + Instant publishedAt = parseRssDate(pubDate); + + OsintFeed feed = OsintFeed.builder() + .title(title) + .source(sourceName) + .sourceUrl(link) + .category(classifyCategory(title)) + .language(lang) + .region(region) + .imageUrl(null) + .position(null) + .publishedAt(publishedAt) + .build(); + + osintFeedRepository.save(feed); + saved++; + } + log.debug("Google News {} ({}) 저장: {}건", region, lang, saved); + } catch (Exception e) { + log.warn("Google News {} ({}) 수집 실패: {}", region, lang, e.getMessage()); + } + } + + private void refreshCache(String region) { + Instant since = Instant.now().minus(24, ChronoUnit.HOURS); + List dtos = osintFeedRepository + .findByRegionAndCollectedAtAfterOrderByPublishedAtDesc(region, since) + .stream().map(OsintDto::from).toList(); + String cacheName = "iran".equals(region) ? CacheConfig.OSINT_IRAN : CacheConfig.OSINT_KOREA; + Cache cache = cacheManager.getCache(cacheName); + if (cache != null) { + cache.put("data", dtos); + } + log.debug("OSINT {} 캐시 갱신: {}건", region, dtos.size()); + } + + private String classifyCategory(String title) { + String t = title.toLowerCase(); + if (t.matches(".*(strike|missile|attack|military|weapon|drone|전투|공습|미사일).*")) return "military"; + if (t.matches(".*(oil|crude|opec|유가|원유|석유).*")) return "oil"; + if (t.matches(".*(diplomat|sanction|treaty|외교|제재|협상).*")) return "diplomacy"; + if (t.matches(".*(ship|vessel|maritime|해운|선박|항만).*")) return "shipping"; + if (t.matches(".*(nuclear|uranium|핵|우라늄).*")) return "nuclear"; + if (t.matches(".*(해양사고|충돌|좌초|침몰|collision|capsiz).*")) return "maritime_accident"; + if (t.matches(".*(어선|어업|불법조업|fishing).*")) return "fishing"; + return "general"; + } + + private Instant parseGdeltDate(String seendate) { + if (seendate == null || seendate.isBlank()) return null; + try { + return Instant.from(GDELT_FORMATTER.parse(seendate)); + } catch (DateTimeParseException e) { + log.debug("GDELT 날짜 파싱 실패: {}", seendate); + return null; + } + } + + private Instant parseRssDate(String pubDate) { + if (pubDate == null || pubDate.isBlank()) return null; + try { + return Instant.from(RSS_FORMATTER.parse(pubDate)); + } catch (DateTimeParseException e) { + log.debug("RSS 날짜 파싱 실패: {}", pubDate); + return null; + } + } + + private String getTextContent(Element parent, String tagName) { + NodeList nodes = parent.getElementsByTagName(tagName); + if (nodes.getLength() == 0) return null; + String text = nodes.item(0).getTextContent(); + return (text == null || text.isBlank()) ? null : text.trim(); + } + + private String encodeQuery(String query) { + return query.replace(" ", "+"); + } +} diff --git a/backend/src/main/java/gc/mda/kcg/collector/satellite/SatelliteCollector.java b/backend/src/main/java/gc/mda/kcg/collector/satellite/SatelliteCollector.java new file mode 100644 index 0000000..b35cddd --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/collector/satellite/SatelliteCollector.java @@ -0,0 +1,199 @@ +package gc.mda.kcg.collector.satellite; + +import gc.mda.kcg.config.AppProperties; +import gc.mda.kcg.config.CacheConfig; +import gc.mda.kcg.domain.satellite.SatelliteDto; +import gc.mda.kcg.domain.satellite.SatelliteTle; +import gc.mda.kcg.domain.satellite.SatelliteTleRepository; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +import java.time.Instant; +import java.time.LocalDate; +import java.time.ZoneOffset; +import java.time.temporal.ChronoUnit; +import java.util.*; +import java.util.regex.Pattern; + +@Slf4j +@Component +@RequiredArgsConstructor +public class SatelliteCollector { + + private static final Map TLE_GROUPS = Map.of( + "military", "reconnaissance", + "gps-ops", "navigation", + "geo", "communications", + "weather", "weather", + "stations", "other" + ); + + private static final Pattern RECON_PATTERN = Pattern.compile("SBIRS|DSP|STSS|NROL|USA"); + private static final Pattern COMMS_PATTERN = Pattern.compile("WGS|AEHF|MUOS|STARLINK|ONEWEB"); + private static final Pattern NAV_PATTERN = Pattern.compile("GPS|NAVSTAR"); + + public volatile long lastUpdated = 0L; + + private final RestTemplate restTemplate; + private final CacheManager cacheManager; + private final SatelliteTleRepository repository; + private final AppProperties appProperties; + + @PostConstruct + public void init() { + Thread.ofVirtual().name("satellite-init").start(() -> { + log.info("위성 TLE 초기 캐시 로드 시작"); + loadCacheFromDb(); + log.info("위성 TLE 초기 캐시 로드 완료"); + }); + } + + @Scheduled(initialDelay = 60_000, fixedDelay = 600_000) + public void collect() { + log.info("위성 TLE 수집 시작"); + String baseUrl = appProperties.getCollector().getCelestrakBaseUrl(); + List allEntities = new ArrayList<>(); + Set seenNoradIds = new LinkedHashSet<>(); + Instant now = Instant.now(); + + for (Map.Entry entry : TLE_GROUPS.entrySet()) { + String group = entry.getKey(); + String defaultCategory = entry.getValue(); + + try { + String url = baseUrl + "?GROUP=" + group + "&FORMAT=tle"; + String body = restTemplate.getForObject(url, String.class); + if (body == null || body.isBlank()) { + log.warn("위성 TLE 응답 없음: group={}", group); + continue; + } + + List entities = parseTle(body, group, defaultCategory, now, seenNoradIds); + allEntities.addAll(entities); + log.debug("위성 TLE 파싱 완료: group={}, count={}", group, entities.size()); + + } catch (Exception e) { + log.warn("위성 TLE 수집 실패: group={}, error={}", group, e.getMessage()); + } + + sleep(1000); + } + + if (!allEntities.isEmpty()) { + try { + repository.saveAll(allEntities); + log.info("위성 TLE DB 적재 완료: {} 건", allEntities.size()); + } catch (Exception e) { + log.error("위성 TLE DB 적재 실패: {}", e.getMessage()); + } + } + + loadCacheFromDb(); + } + + public long getLastUpdated() { + return lastUpdated; + } + + void loadCacheFromDb() { + Instant since = Instant.now().minus(11, ChronoUnit.MINUTES); + List dtos = repository.findByCollectedAtAfterOrderByNoradIdAsc(since) + .stream().map(SatelliteDto::from).toList(); + + if (dtos.isEmpty()) { + since = Instant.now().minus(24, ChronoUnit.HOURS); + dtos = repository.findByCollectedAtAfterOrderByNoradIdAsc(since) + .stream().map(SatelliteDto::from).toList(); + } + + if (!dtos.isEmpty()) { + Cache cache = cacheManager.getCache(CacheConfig.SATELLITES); + if (cache != null) cache.put("data", dtos); + lastUpdated = System.currentTimeMillis(); + log.debug("위성 TLE 캐시 갱신 완료: {} 건", dtos.size()); + } + } + + private List parseTle(String body, String group, String defaultCategory, + Instant collectedAt, Set seenNoradIds) { + List result = new ArrayList<>(); + String[] lines = body.lines() + .map(String::trim) + .filter(l -> !l.isBlank()) + .toArray(String[]::new); + + for (int i = 0; i + 2 < lines.length; i += 3) { + String nameLine = lines[i]; + String line1 = lines[i + 1]; + String line2 = lines[i + 2]; + + if (!line1.startsWith("1 ") || !line2.startsWith("2 ")) { + log.debug("TLE 형식 불일치 스킵: name={}", nameLine); + continue; + } + + try { + String name = nameLine.trim(); + int noradId = Integer.parseInt(line1.substring(2, 7).trim()); + + if (!seenNoradIds.add(noradId)) { + continue; + } + + Instant epoch = parseTleEpoch(line1); + String category = resolveCategory(name, defaultCategory); + + result.add(SatelliteTle.builder() + .noradId(noradId) + .name(name) + .tleLine1(line1) + .tleLine2(line2) + .category(category) + .tleGroup(group) + .epoch(epoch) + .collectedAt(collectedAt) + .build()); + + } catch (Exception e) { + log.debug("TLE 파싱 실패 스킵: name={}, error={}", nameLine, e.getMessage()); + } + } + + return result; + } + + private Instant parseTleEpoch(String line1) { + String epochStr = line1.substring(18, 32).trim(); + int yy = Integer.parseInt(epochStr.substring(0, 2)); + int year = (yy < 57) ? 2000 + yy : 1900 + yy; + double dayOfYearFraction = Double.parseDouble(epochStr.substring(2)); + int dayOfYear = (int) dayOfYearFraction; + double fraction = dayOfYearFraction - dayOfYear; + + return LocalDate.ofYearDay(year, dayOfYear) + .atStartOfDay(ZoneOffset.UTC) + .toInstant() + .plusMillis((long) (fraction * 86_400_000)); + } + + private String resolveCategory(String name, String defaultCategory) { + if (RECON_PATTERN.matcher(name).find()) return "reconnaissance"; + if (NAV_PATTERN.matcher(name).find()) return "navigation"; + if (COMMS_PATTERN.matcher(name).find()) return "communications"; + return defaultCategory; + } + + private static void sleep(int ms) { + try { + Thread.sleep(ms); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + } +} diff --git a/backend/src/main/java/gc/mda/kcg/config/AppProperties.java b/backend/src/main/java/gc/mda/kcg/config/AppProperties.java index 18acc07..5fb83d8 100644 --- a/backend/src/main/java/gc/mda/kcg/config/AppProperties.java +++ b/backend/src/main/java/gc/mda/kcg/config/AppProperties.java @@ -40,6 +40,9 @@ public class AppProperties { public static class Collector { private String airplanesLiveBaseUrl = "https://api.airplanes.live/v2"; private String openSkyBaseUrl = "https://opensky-network.org/api"; + private String gdeltBaseUrl = "https://api.gdeltproject.org/api/v2/doc/doc"; + private String googleNewsBaseUrl = "https://news.google.com/rss/search"; + private String celestrakBaseUrl = "https://celestrak.org/NORAD/elements/gp.php"; private int requestDelayMs = 1500; private int backoffMs = 5000; } diff --git a/backend/src/main/java/gc/mda/kcg/config/CacheConfig.java b/backend/src/main/java/gc/mda/kcg/config/CacheConfig.java new file mode 100644 index 0000000..525a01c --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/config/CacheConfig.java @@ -0,0 +1,34 @@ +package gc.mda.kcg.config; + +import com.github.benmanes.caffeine.cache.Caffeine; +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.cache.caffeine.CaffeineCacheManager; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import java.util.concurrent.TimeUnit; + +@Configuration +@EnableCaching +public class CacheConfig { + + public static final String AIRCRAFT_IRAN = "aircraft-iran"; + public static final String AIRCRAFT_KOREA = "aircraft-korea"; + public static final String OSINT_IRAN = "osint-iran"; + public static final String OSINT_KOREA = "osint-korea"; + public static final String SATELLITES = "satellites"; + + @Bean + public CacheManager cacheManager() { + CaffeineCacheManager manager = new CaffeineCacheManager( + AIRCRAFT_IRAN, AIRCRAFT_KOREA, + OSINT_IRAN, OSINT_KOREA, + SATELLITES + ); + manager.setCaffeine(Caffeine.newBuilder() + .expireAfterWrite(2, TimeUnit.DAYS) + .maximumSize(1)); + return manager; + } +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/osint/OsintController.java b/backend/src/main/java/gc/mda/kcg/domain/osint/OsintController.java new file mode 100644 index 0000000..bed9fb7 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/osint/OsintController.java @@ -0,0 +1,65 @@ +package gc.mda.kcg.domain.osint; + +import gc.mda.kcg.config.CacheConfig; +import lombok.RequiredArgsConstructor; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +@RestController +@RequestMapping("/api/osint") +@RequiredArgsConstructor +public class OsintController { + + private static final Set VALID_REGIONS = Set.of("iran", "korea"); + + private final CacheManager cacheManager; + + private final Map lastUpdated = new ConcurrentHashMap<>(); + + @GetMapping + public ResponseEntity> getOsint( + @RequestParam(defaultValue = "iran") String region) { + + if (!VALID_REGIONS.contains(region)) { + return ResponseEntity.badRequest() + .body(Map.of("error", "유효하지 않은 region: " + region)); + } + + String cacheName = "iran".equals(region) ? CacheConfig.OSINT_IRAN : CacheConfig.OSINT_KOREA; + List items = getCachedItems(cacheName); + long updatedAt = lastUpdated.getOrDefault(region, 0L); + + return ResponseEntity.ok(Map.of( + "region", region, + "count", items.size(), + "lastUpdated", updatedAt, + "items", items + )); + } + + @SuppressWarnings("unchecked") + private List getCachedItems(String cacheName) { + Cache cache = cacheManager.getCache(cacheName); + if (cache != null) { + Cache.ValueWrapper wrapper = cache.get("data"); + if (wrapper != null) { + return (List) wrapper.get(); + } + } + return List.of(); + } + + public void markUpdated(String region) { + lastUpdated.put(region, System.currentTimeMillis()); + } +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/osint/OsintDto.java b/backend/src/main/java/gc/mda/kcg/domain/osint/OsintDto.java new file mode 100644 index 0000000..53a97f8 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/osint/OsintDto.java @@ -0,0 +1,39 @@ +package gc.mda.kcg.domain.osint; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +@JsonInclude(JsonInclude.Include.NON_NULL) +public class OsintDto { + + private String id; + private long timestamp; + private String title; + private String source; + private String url; + private String category; + private String language; + private String imageUrl; + private Double lat; + private Double lng; + + public static OsintDto from(OsintFeed f) { + return OsintDto.builder() + .id(f.getSource() + "-" + f.getId()) + .timestamp(f.getPublishedAt() != null + ? f.getPublishedAt().toEpochMilli() + : f.getCollectedAt().toEpochMilli()) + .title(f.getTitle()) + .source(f.getSource()) + .url(f.getSourceUrl()) + .category(f.getCategory()) + .language(f.getLanguage()) + .imageUrl(f.getImageUrl()) + .lat(f.getPosition() != null ? f.getPosition().getY() : null) + .lng(f.getPosition() != null ? f.getPosition().getX() : null) + .build(); + } +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/osint/OsintFeed.java b/backend/src/main/java/gc/mda/kcg/domain/osint/OsintFeed.java new file mode 100644 index 0000000..10bd1c7 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/osint/OsintFeed.java @@ -0,0 +1,62 @@ +package gc.mda.kcg.domain.osint; + +import jakarta.persistence.*; +import lombok.*; +import org.locationtech.jts.geom.Point; + +import java.time.Instant; + +@Entity +@Table( + name = "osint_feeds", + schema = "kcg", + uniqueConstraints = @UniqueConstraint(columnNames = {"source", "source_url"}) +) +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class OsintFeed { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, columnDefinition = "TEXT") + private String title; + + @Column(nullable = false, length = 64) + private String source; + + @Column(name = "source_url", columnDefinition = "TEXT") + private String sourceUrl; + + @Column(length = 32) + private String category; + + @Column(length = 8) + private String language; + + @Column(name = "focus", length = 16) + private String region; + + @Column(name = "image_url", columnDefinition = "TEXT") + private String imageUrl; + + @Column(columnDefinition = "geometry(Point, 4326)") + private Point position; + + @Column(name = "published_at") + private Instant publishedAt; + + @Column(name = "collected_at", nullable = false) + private Instant collectedAt; + + @PrePersist + protected void onCreate() { + if (collectedAt == null) { + collectedAt = Instant.now(); + } + } +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/osint/OsintFeedRepository.java b/backend/src/main/java/gc/mda/kcg/domain/osint/OsintFeedRepository.java new file mode 100644 index 0000000..95cf034 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/osint/OsintFeedRepository.java @@ -0,0 +1,13 @@ +package gc.mda.kcg.domain.osint; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.time.Instant; +import java.util.List; + +public interface OsintFeedRepository extends JpaRepository { + + boolean existsBySourceAndSourceUrl(String source, String sourceUrl); + + List findByRegionAndCollectedAtAfterOrderByPublishedAtDesc(String region, Instant since); +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/satellite/SatelliteController.java b/backend/src/main/java/gc/mda/kcg/domain/satellite/SatelliteController.java new file mode 100644 index 0000000..179211c --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/satellite/SatelliteController.java @@ -0,0 +1,46 @@ +package gc.mda.kcg.domain.satellite; + +import gc.mda.kcg.collector.satellite.SatelliteCollector; +import gc.mda.kcg.config.CacheConfig; +import lombok.RequiredArgsConstructor; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/satellites") +@RequiredArgsConstructor +public class SatelliteController { + + private final CacheManager cacheManager; + private final SatelliteCollector satelliteCollector; + + @GetMapping + public ResponseEntity> getSatellites( + @RequestParam(defaultValue = "iran") String region) { + + Cache cache = cacheManager.getCache(CacheConfig.SATELLITES); + Cache.ValueWrapper wrapper = cache != null ? cache.get("data") : null; + + @SuppressWarnings("unchecked") + List sats = (wrapper != null && wrapper.get() instanceof List) + ? (List) wrapper.get() + : List.of(); + + long lastUpdated = satelliteCollector.getLastUpdated(); + + return ResponseEntity.ok(Map.of( + "region", region, + "count", sats.size(), + "lastUpdated", lastUpdated, + "satellites", sats + )); + } +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/satellite/SatelliteDto.java b/backend/src/main/java/gc/mda/kcg/domain/satellite/SatelliteDto.java new file mode 100644 index 0000000..8754d9d --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/satellite/SatelliteDto.java @@ -0,0 +1,27 @@ +package gc.mda.kcg.domain.satellite; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +@JsonInclude(JsonInclude.Include.NON_NULL) +public class SatelliteDto { + + private int noradId; + private String name; + private String tle1; + private String tle2; + private String category; + + public static SatelliteDto from(SatelliteTle e) { + return SatelliteDto.builder() + .noradId(e.getNoradId()) + .name(e.getName()) + .tle1(e.getTleLine1()) + .tle2(e.getTleLine2()) + .category(e.getCategory()) + .build(); + } +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/satellite/SatelliteTle.java b/backend/src/main/java/gc/mda/kcg/domain/satellite/SatelliteTle.java new file mode 100644 index 0000000..105f912 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/satellite/SatelliteTle.java @@ -0,0 +1,50 @@ +package gc.mda.kcg.domain.satellite; + +import jakarta.persistence.*; +import lombok.*; + +import java.time.Instant; + +@Entity +@Table(name = "satellite_tle", schema = "kcg") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class SatelliteTle { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "norad_id", nullable = false) + private Integer noradId; + + @Column(nullable = false, length = 128) + private String name; + + @Column(name = "tle_line1", nullable = false, length = 70) + private String tleLine1; + + @Column(name = "tle_line2", nullable = false, length = 70) + private String tleLine2; + + @Column(length = 20) + private String category; + + @Column(name = "tle_group", length = 32) + private String tleGroup; + + private Instant epoch; + + @Column(name = "collected_at", nullable = false) + private Instant collectedAt; + + @PrePersist + protected void onCreate() { + if (collectedAt == null) { + collectedAt = Instant.now(); + } + } +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/satellite/SatelliteTleRepository.java b/backend/src/main/java/gc/mda/kcg/domain/satellite/SatelliteTleRepository.java new file mode 100644 index 0000000..4f485ba --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/satellite/SatelliteTleRepository.java @@ -0,0 +1,11 @@ +package gc.mda.kcg.domain.satellite; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.time.Instant; +import java.util.List; + +public interface SatelliteTleRepository extends JpaRepository { + + List findByCollectedAtAfterOrderByNoradIdAsc(Instant since); +} diff --git a/frontend/src/components/EventLog.tsx b/frontend/src/components/EventLog.tsx index 6b96d85..01e6b70 100644 --- a/frontend/src/components/EventLog.tsx +++ b/frontend/src/components/EventLog.tsx @@ -31,7 +31,6 @@ interface BreakingNews { const T0_NEWS = new Date('2026-03-01T12:01:00Z').getTime(); const HOUR_MS = 3600_000; const DAY_MS = 24 * HOUR_MS; -const _MIN_MS = 60_000; const BREAKING_NEWS: BreakingNews[] = [ // DAY 1 @@ -378,8 +377,8 @@ export function EventLog({ events, currentTime, totalShipCount: _totalShipCount, [events.length, currentTime], ); - // Iran-related ships (military + Iranian flag) - const _iranMilitaryShips = useMemo(() => + // Iran-related ships (military + Iranian flag) — reserved for ship status panel + const iranMilitaryShips = useMemo(() => ships.filter(s => s.flag === 'IR' || s.category === 'carrier' || s.category === 'destroyer' || @@ -390,6 +389,7 @@ export function EventLog({ events, currentTime, totalShipCount: _totalShipCount, }), [ships], ); + void iranMilitaryShips; return (
diff --git a/frontend/src/components/SatelliteMap.tsx b/frontend/src/components/SatelliteMap.tsx index 7905edb..e7414b9 100644 --- a/frontend/src/components/SatelliteMap.tsx +++ b/frontend/src/components/SatelliteMap.tsx @@ -246,7 +246,7 @@ export function SatelliteMap({ events, currentTime, aircraft, satellites, ships, )} {/* Overlay layers */} - {layers.aircraft && } + {layers.aircraft && } {layers.satellites && } {layers.ships && } diff --git a/frontend/src/services/celestrak.ts b/frontend/src/services/celestrak.ts index 0d9b6ce..913f189 100644 --- a/frontend/src/services/celestrak.ts +++ b/frontend/src/services/celestrak.ts @@ -80,16 +80,21 @@ function isNearMiddleEast(sat: Satellite): boolean { let satCache: { sats: Satellite[]; ts: number } | null = null; const SAT_CACHE_TTL = 10 * 60_000; -export async function fetchSatelliteTLE(): Promise { - // Return cache if fresh - if (satCache && Date.now() - satCache.ts < SAT_CACHE_TTL) { - return satCache.sats; +async function fetchSatellitesFromBackend(region: 'iran' | 'korea' = 'iran'): Promise { + try { + const res = await fetch(`/api/kcg/satellites?region=${region}`, { credentials: 'include' }); + if (!res.ok) return []; + const data = await res.json(); + return (data.satellites ?? []) as Satellite[]; + } catch { + return []; } +} +async function fetchSatelliteTLEFromCelesTrak(): Promise { const allSats: Satellite[] = []; const seenIds = new Set(); - // Fetch TLE groups from CelesTrak sequentially (avoid hammering) for (const { group, category } of CELESTRAK_GROUPS) { try { const url = `/api/celestrak/NORAD/elements/gp.php?GROUP=${group}&FORMAT=tle`; @@ -111,12 +116,10 @@ export async function fetchSatelliteTLE(): Promise { } } - if (allSats.length === 0) { - console.warn('CelesTrak: no data fetched, using fallback'); - return FALLBACK_SATELLITES; - } + return allSats; +} - // For GEO/MEO sats keep all, for LEO filter to Middle East region +function filterSatellitesByRegion(allSats: Satellite[], isNearFn: (sat: Satellite) => boolean): Satellite[] { const filtered: Satellite[] = []; for (const sat of allSats) { try { @@ -126,12 +129,10 @@ export async function fetchSatelliteTLE(): Promise { const geo = satellite.eciToGeodetic(pv.position, satellite.gstime(new Date())); const altKm = geo.height; - // GEO (>30000km) and MEO (>5000km): always include (they cover wide areas) if (altKm > 5000) { filtered.push(sat); } else { - // LEO: only keep if passes near Middle East - if (isNearMiddleEast(sat)) { + if (isNearFn(sat)) { filtered.push(sat); } } @@ -139,12 +140,30 @@ export async function fetchSatelliteTLE(): Promise { // skip bad TLE } } + return filtered.slice(0, 100); +} - // Cap at ~100 satellites to keep rendering performant - const capped = filtered.slice(0, 100); +export async function fetchSatelliteTLE(): Promise { + if (satCache && Date.now() - satCache.ts < SAT_CACHE_TTL) { + return satCache.sats; + } + // 백엔드 API 우선 + let allSats = await fetchSatellitesFromBackend('iran'); + + // 백엔드 실패 시 CelesTrak 직접 호출 fallback + if (allSats.length === 0) { + allSats = await fetchSatelliteTLEFromCelesTrak(); + } + + if (allSats.length === 0) { + console.warn('CelesTrak: no data fetched, using fallback'); + return FALLBACK_SATELLITES; + } + + const capped = filterSatellitesByRegion(allSats, isNearMiddleEast); satCache = { sats: capped, ts: Date.now() }; - console.log(`CelesTrak: loaded ${capped.length} satellites (from ${allSats.length} total)`); + console.log(`Satellites: loaded ${capped.length} (from ${allSats.length} total)`); return capped; } @@ -176,46 +195,19 @@ export async function fetchSatelliteTLEKorea(): Promise { return satCacheKorea.sats; } - const allSats: Satellite[] = []; - const seenIds = new Set(); + // 백엔드 API 우선 + let allSats = await fetchSatellitesFromBackend('korea'); - for (const { group, category } of CELESTRAK_GROUPS) { - try { - const url = `/api/celestrak/NORAD/elements/gp.php?GROUP=${group}&FORMAT=tle`; - const res = await fetch(url); - if (!res.ok) continue; - const text = await res.text(); - const parsed = parseTLE(text, category); - for (const sat of parsed) { - if (!seenIds.has(sat.noradId)) { - seenIds.add(sat.noradId); - allSats.push(sat); - } - } - } catch { /* skip */ } + // 백엔드 실패 시 CelesTrak 직접 호출 fallback + if (allSats.length === 0) { + allSats = await fetchSatelliteTLEFromCelesTrak(); } if (allSats.length === 0) return FALLBACK_SATELLITES; - const filtered: Satellite[] = []; - for (const sat of allSats) { - try { - const satrec = satellite.twoline2satrec(sat.tle1, sat.tle2); - const pv = satellite.propagate(satrec, new Date()); - if (!pv || typeof pv.position === 'boolean' || !pv.position) continue; - const geo = satellite.eciToGeodetic(pv.position, satellite.gstime(new Date())); - const altKm = geo.height; - if (altKm > 5000) { - filtered.push(sat); - } else { - if (isNearKorea(sat)) filtered.push(sat); - } - } catch { /* skip */ } - } - - const capped = filtered.slice(0, 100); + const capped = filterSatellitesByRegion(allSats, isNearKorea); satCacheKorea = { sats: capped, ts: Date.now() }; - console.log(`CelesTrak Korea: loaded ${capped.length} satellites`); + console.log(`Satellites Korea: loaded ${capped.length} (from ${allSats.length} total)`); return capped; } diff --git a/frontend/src/services/navWarning.ts b/frontend/src/services/navWarning.ts index 720abaa..5f10d33 100644 --- a/frontend/src/services/navWarning.ts +++ b/frontend/src/services/navWarning.ts @@ -45,12 +45,12 @@ function dms(d: number, m: number, s: number): number { } /** Compute center of polygon */ -// eslint-disable-next-line @typescript-eslint/no-unused-vars function center(pts: [number, number][]): [number, number] { const lat = pts.reduce((s, p) => s + p[0], 0) / pts.length; const lng = pts.reduce((s, p) => s + p[1], 0) / pts.length; return [lat, lng]; } +void center; // ═══════════════════════════════════════════════════════ // 해상사격장 구역 데이터 (WGS-84) diff --git a/frontend/src/services/osint.ts b/frontend/src/services/osint.ts index b5d1456..16d05f2 100644 --- a/frontend/src/services/osint.ts +++ b/frontend/src/services/osint.ts @@ -715,7 +715,25 @@ const PINNED_KOREA: OsintItem[] = [ ]; // ── Main fetch: merge all sources, deduplicate, sort by time ── +async function fetchOsintFromBackend(region: 'iran' | 'korea'): Promise { + try { + const res = await fetch(`/api/kcg/osint?region=${region}`, { credentials: 'include' }); + if (!res.ok) return []; + const data = await res.json(); + return (data.items ?? []) as OsintItem[]; + } catch { + return []; + } +} + export async function fetchOsintFeed(focus: 'iran' | 'korea' = 'iran'): Promise { + // 백엔드 API 우선 시도 + const backendItems = await fetchOsintFromBackend(focus); + if (backendItems.length > 0) { + return backendItems; + } + + // 백엔드 실패 시 직접 호출 fallback const gdeltKw = focus === 'korea' ? GDELT_KEYWORDS_KOREA : GDELT_KEYWORDS_IRAN; const gnKrKw = focus === 'korea' ? GNEWS_KR_KOREA : GNEWS_KR_IRAN; const gnEnKw = focus === 'korea' ? GNEWS_EN_KOREA : GNEWS_EN_IRAN; From dee52c33d5fa523327212e0b069e5dbae889cd1a Mon Sep 17 00:00:00 2001 From: htlee Date: Wed, 18 Mar 2026 04:09:13 +0900 Subject: [PATCH 4/5] =?UTF-8?q?docs:=20=EB=A6=B4=EB=A6=AC=EC=A6=88=20?= =?UTF-8?q?=EB=85=B8=ED=8A=B8=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/RELEASE-NOTES.md | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index da11eae..0640ed5 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -4,6 +4,17 @@ ## [Unreleased] +### 추가 +- 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 + +### 수정 +- 002 마이그레이션 search_path에 public 추가 (PostGIS 타입 참조) + ## [2026-03-17.5] ### 추가 From f39948557a4eaad20752d263f2b219bd4a53204f Mon Sep 17 00:00:00 2001 From: htlee Date: Wed, 18 Mar 2026 04:16:52 +0900 Subject: [PATCH 5/5] =?UTF-8?q?docs:=20=EB=A6=B4=EB=A6=AC=EC=A6=88=20?= =?UTF-8?q?=EB=85=B8=ED=8A=B8=20=EC=A0=95=EB=A6=AC=20(2026-03-18)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/RELEASE-NOTES.md | 75 +++++++++++++++---------------------------- 1 file changed, 25 insertions(+), 50 deletions(-) diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index 0640ed5..821de9b 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -4,6 +4,8 @@ ## [Unreleased] +## [2026-03-18] + ### 추가 - OSINT 수집기: GDELT + Google News RSS 백엔드 수집 (@Scheduled 2분) - Satellite 수집기: CelesTrak TLE 백엔드 수집 (@Scheduled 10분) @@ -15,52 +17,6 @@ ### 수정 - 002 마이그레이션 search_path에 public 추가 (PostGIS 타입 참조) -## [2026-03-17.5] - -### 추가 -- 백엔드 항공기 수집기 (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) - -### 수정 -- 항공기 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/` 디렉토리 기준 실행 - ## [2026-03-17] ### 추가 @@ -71,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 분류 기준으로 동기화