From e6680f6e03dc5c9aad959d64056b91f4d6b207f6 Mon Sep 17 00:00:00 2001 From: htlee Date: Wed, 18 Mar 2026 08:05:11 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20=EC=A7=80=EC=A7=84=ED=8C=8C(USGS)?= =?UTF-8?q?=20+=20=EA=B8=B0=EC=95=95(Open-Meteo)=20=EC=88=98=EC=A7=91?= =?UTF-8?q?=EA=B8=B0=20+=20DB=20=ED=85=8C=EC=9D=B4=EB=B8=94=20=EC=84=A4?= =?UTF-8?q?=EA=B3=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - SeismicCollector: USGS FDSN API, 이란 bbox, 5분 주기, M2+ - PressureCollector: Open-Meteo API, 이란 5개 관측점, 10분 주기 - Entity: seismic_events, pressure_readings (DB 마이그레이션 004) - REST: GET /api/sensor/seismic, GET /api/sensor/pressure - Caffeine 캐시: seismic, pressure (TTL 2일) Frontend: - SensorChart 그래프 순서: 지진파 → 기압 → 소음 → 방사선 - 소음/방사선 차트에 (DEMO) 라벨 표시 Co-Authored-By: Claude Opus 4.6 (1M context) --- .../collector/sensor/PressureCollector.java | 113 ++++++++++++++++++ .../collector/sensor/SeismicCollector.java | 90 ++++++++++++++ .../java/gc/mda/kcg/config/CacheConfig.java | 5 +- .../kcg/domain/sensor/PressureReading.java | 49 ++++++++ .../sensor/PressureReadingRepository.java | 11 ++ .../mda/kcg/domain/sensor/SeismicEvent.java | 50 ++++++++ .../domain/sensor/SeismicEventRepository.java | 11 ++ .../kcg/domain/sensor/SensorController.java | 44 +++++++ .../gc/mda/kcg/domain/sensor/SensorDto.java | 51 ++++++++ database/migration/004_sensor_data.sql | 30 +++++ frontend/src/App.css | 6 + .../src/components/common/SensorChart.tsx | 34 +++--- 12 files changed, 479 insertions(+), 15 deletions(-) create mode 100644 backend/src/main/java/gc/mda/kcg/collector/sensor/PressureCollector.java create mode 100644 backend/src/main/java/gc/mda/kcg/collector/sensor/SeismicCollector.java create mode 100644 backend/src/main/java/gc/mda/kcg/domain/sensor/PressureReading.java create mode 100644 backend/src/main/java/gc/mda/kcg/domain/sensor/PressureReadingRepository.java create mode 100644 backend/src/main/java/gc/mda/kcg/domain/sensor/SeismicEvent.java create mode 100644 backend/src/main/java/gc/mda/kcg/domain/sensor/SeismicEventRepository.java create mode 100644 backend/src/main/java/gc/mda/kcg/domain/sensor/SensorController.java create mode 100644 backend/src/main/java/gc/mda/kcg/domain/sensor/SensorDto.java create mode 100644 database/migration/004_sensor_data.sql diff --git a/backend/src/main/java/gc/mda/kcg/collector/sensor/PressureCollector.java b/backend/src/main/java/gc/mda/kcg/collector/sensor/PressureCollector.java new file mode 100644 index 0000000..cb27cff --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/collector/sensor/PressureCollector.java @@ -0,0 +1,113 @@ +package gc.mda.kcg.collector.sensor; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import gc.mda.kcg.collector.CollectorStatusTracker; +import gc.mda.kcg.domain.sensor.PressureReading; +import gc.mda.kcg.domain.sensor.PressureReadingRepository; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +import java.time.Instant; +import java.time.OffsetDateTime; +import java.time.format.DateTimeParseException; + +@Slf4j +@Component +@RequiredArgsConstructor +public class PressureCollector { + + private static final String[] STATION_NAMES = {"tehran", "isfahan", "bandar-abbas", "shiraz", "tabriz"}; + private static final double[] LATS = {35.69, 32.65, 27.19, 29.59, 38.08}; + private static final double[] LNGS = {51.39, 51.68, 56.27, 52.58, 46.29}; + + private static final String OPEN_METEO_URL = + "https://api.open-meteo.com/v1/forecast?" + + "latitude=35.69,32.65,27.19,29.59,38.08" + + "&longitude=51.39,51.68,56.27,52.58,46.29" + + "&hourly=surface_pressure&past_hours=24&forecast_hours=0&timezone=UTC"; + + private final RestTemplate restTemplate; + private final ObjectMapper objectMapper; + private final PressureReadingRepository repository; + private final CollectorStatusTracker tracker; + + @PostConstruct + public void init() { + Thread.ofVirtual().name("pressure-init").start(() -> { + log.info("Open-Meteo 기압 데이터 초기 로드"); + collect(); + }); + } + + @Scheduled(initialDelay = 45_000, fixedDelay = 600_000) + public void collectScheduled() { + collect(); + } + + private void collect() { + try { + String body = restTemplate.getForObject(OPEN_METEO_URL, String.class); + if (body == null || body.isBlank()) return; + + JsonNode root = objectMapper.readTree(body); + int saved = 0; + Instant now = Instant.now(); + + // Open-Meteo 멀티 위치: 배열 형태 응답 + if (root.isArray()) { + for (int i = 0; i < root.size() && i < STATION_NAMES.length; i++) { + saved += parseStation(root.get(i), STATION_NAMES[i], LATS[i], LNGS[i], now); + } + } else { + // 단일 위치 응답 (파라미터 1개일 때) + saved += parseStation(root, STATION_NAMES[0], LATS[0], LNGS[0], now); + } + + tracker.recordSuccess("pressure", "iran", saved); + log.debug("Open-Meteo 기압 수집 완료: {}건 저장", saved); + } catch (Exception e) { + tracker.recordFailure("pressure", "iran", e.getMessage()); + log.warn("Open-Meteo 기압 수집 실패: {}", e.getMessage()); + } + } + + private int parseStation(JsonNode stationData, String station, double lat, double lng, Instant now) { + JsonNode hourly = stationData.path("hourly"); + JsonNode times = hourly.path("time"); + JsonNode pressures = hourly.path("surface_pressure"); + if (!times.isArray() || !pressures.isArray()) return 0; + + int saved = 0; + for (int j = 0; j < times.size() && j < pressures.size(); j++) { + String timeStr = times.get(j).asText(); + double pressure = pressures.get(j).asDouble(Double.NaN); + if (Double.isNaN(pressure)) continue; + + try { + // Open-Meteo 시간 형식: "2026-03-18T07:00" (Z 없음) → Z 추가 + Instant readingTime = OffsetDateTime.parse(timeStr + "Z").toInstant(); + try { + repository.save(PressureReading.builder() + .station(station) + .lat(lat) + .lng(lng) + .pressureHpa(pressure) + .readingTime(readingTime) + .collectedAt(now) + .build()); + saved++; + } catch (Exception ignored) { + // unique constraint violation = 이미 존재, 무시 + } + } catch (DateTimeParseException e) { + log.debug("기압 시간 파싱 실패: {}", timeStr); + } + } + return saved; + } +} diff --git a/backend/src/main/java/gc/mda/kcg/collector/sensor/SeismicCollector.java b/backend/src/main/java/gc/mda/kcg/collector/sensor/SeismicCollector.java new file mode 100644 index 0000000..f734cf0 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/collector/sensor/SeismicCollector.java @@ -0,0 +1,90 @@ +package gc.mda.kcg.collector.sensor; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import gc.mda.kcg.collector.CollectorStatusTracker; +import gc.mda.kcg.domain.sensor.SeismicEvent; +import gc.mda.kcg.domain.sensor.SeismicEventRepository; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; +import org.springframework.web.client.RestTemplate; + +import java.time.Instant; + +@Slf4j +@Component +@RequiredArgsConstructor +public class SeismicCollector { + + private static final String USGS_URL = + "https://earthquake.usgs.gov/fdsnws/event/1/query?format=geojson" + + "&minlatitude=25&maxlatitude=40&minlongitude=44&maxlongitude=63" + + "&orderby=time&limit=50&minmagnitude=2"; + + private final RestTemplate restTemplate; + private final ObjectMapper objectMapper; + private final SeismicEventRepository repository; + private final CollectorStatusTracker tracker; + + @PostConstruct + public void init() { + Thread.ofVirtual().name("seismic-init").start(() -> { + log.info("USGS 지진 데이터 초기 로드"); + collect(); + }); + } + + @Scheduled(initialDelay = 60_000, fixedDelay = 300_000) + public void collectScheduled() { + collect(); + } + + private void collect() { + try { + String body = restTemplate.getForObject(USGS_URL, String.class); + if (body == null || body.isBlank()) return; + + JsonNode root = objectMapper.readTree(body); + JsonNode features = root.path("features"); + if (!features.isArray()) return; + + int saved = 0; + Instant now = Instant.now(); + for (JsonNode f : features) { + JsonNode props = f.path("properties"); + JsonNode coords = f.path("geometry").path("coordinates"); + + String usgsId = f.path("id").asText(null); + if (usgsId == null) continue; + if (repository.existsByUsgsId(usgsId)) continue; + + double mag = props.path("mag").asDouble(0); + String place = props.path("place").asText(null); + long timeMs = props.path("time").asLong(0); + double lng = coords.get(0).asDouble(); + double lat = coords.get(1).asDouble(); + Double depth = coords.has(2) ? coords.get(2).asDouble() : null; + + repository.save(SeismicEvent.builder() + .usgsId(usgsId) + .magnitude(mag) + .depth(depth) + .lat(lat) + .lng(lng) + .place(place) + .eventTime(Instant.ofEpochMilli(timeMs)) + .collectedAt(now) + .build()); + saved++; + } + tracker.recordSuccess("seismic", "iran", saved); + log.debug("USGS 지진 수집 완료: {}건 저장", saved); + } catch (Exception e) { + tracker.recordFailure("seismic", "iran", e.getMessage()); + log.warn("USGS 지진 수집 실패: {}", e.getMessage()); + } + } +} diff --git a/backend/src/main/java/gc/mda/kcg/config/CacheConfig.java b/backend/src/main/java/gc/mda/kcg/config/CacheConfig.java index 525a01c..1d216f7 100644 --- a/backend/src/main/java/gc/mda/kcg/config/CacheConfig.java +++ b/backend/src/main/java/gc/mda/kcg/config/CacheConfig.java @@ -18,13 +18,16 @@ public class CacheConfig { public static final String OSINT_IRAN = "osint-iran"; public static final String OSINT_KOREA = "osint-korea"; public static final String SATELLITES = "satellites"; + public static final String SEISMIC = "seismic"; + public static final String PRESSURE = "pressure"; @Bean public CacheManager cacheManager() { CaffeineCacheManager manager = new CaffeineCacheManager( AIRCRAFT_IRAN, AIRCRAFT_KOREA, OSINT_IRAN, OSINT_KOREA, - SATELLITES + SATELLITES, + SEISMIC, PRESSURE ); manager.setCaffeine(Caffeine.newBuilder() .expireAfterWrite(2, TimeUnit.DAYS) diff --git a/backend/src/main/java/gc/mda/kcg/domain/sensor/PressureReading.java b/backend/src/main/java/gc/mda/kcg/domain/sensor/PressureReading.java new file mode 100644 index 0000000..f405295 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/sensor/PressureReading.java @@ -0,0 +1,49 @@ +package gc.mda.kcg.domain.sensor; + +import jakarta.persistence.*; +import lombok.*; + +import java.time.Instant; + +@Entity +@Table( + name = "pressure_readings", + schema = "kcg", + uniqueConstraints = @UniqueConstraint(columnNames = {"station", "reading_time"}) +) +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class PressureReading { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, length = 50) + private String station; + + @Column(nullable = false) + private double lat; + + @Column(nullable = false) + private double lng; + + @Column(nullable = false) + private double pressureHpa; + + @Column(nullable = false) + private Instant readingTime; + + @Column(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/sensor/PressureReadingRepository.java b/backend/src/main/java/gc/mda/kcg/domain/sensor/PressureReadingRepository.java new file mode 100644 index 0000000..3c418c0 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/sensor/PressureReadingRepository.java @@ -0,0 +1,11 @@ +package gc.mda.kcg.domain.sensor; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.time.Instant; +import java.util.List; + +public interface PressureReadingRepository extends JpaRepository { + List findByStationAndReadingTimeAfterOrderByReadingTimeAsc(String station, Instant since); + List findByReadingTimeAfterOrderByReadingTimeAsc(Instant since); +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/sensor/SeismicEvent.java b/backend/src/main/java/gc/mda/kcg/domain/sensor/SeismicEvent.java new file mode 100644 index 0000000..f877e3e --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/sensor/SeismicEvent.java @@ -0,0 +1,50 @@ +package gc.mda.kcg.domain.sensor; + +import jakarta.persistence.*; +import lombok.*; + +import java.time.Instant; + +@Entity +@Table(name = "seismic_events", schema = "kcg") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class SeismicEvent { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(unique = true, nullable = false, length = 30) + private String usgsId; + + @Column(nullable = false) + private double magnitude; + + private Double depth; + + @Column(nullable = false) + private double lat; + + @Column(nullable = false) + private double lng; + + @Column(length = 255) + private String place; + + @Column(nullable = false) + private Instant eventTime; + + @Column(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/sensor/SeismicEventRepository.java b/backend/src/main/java/gc/mda/kcg/domain/sensor/SeismicEventRepository.java new file mode 100644 index 0000000..5340f7e --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/sensor/SeismicEventRepository.java @@ -0,0 +1,11 @@ +package gc.mda.kcg.domain.sensor; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.time.Instant; +import java.util.List; + +public interface SeismicEventRepository extends JpaRepository { + boolean existsByUsgsId(String usgsId); + List findByEventTimeAfterOrderByEventTimeDesc(Instant since); +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/sensor/SensorController.java b/backend/src/main/java/gc/mda/kcg/domain/sensor/SensorController.java new file mode 100644 index 0000000..3efa827 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/sensor/SensorController.java @@ -0,0 +1,44 @@ +package gc.mda.kcg.domain.sensor; + +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.*; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/sensor") +@RequiredArgsConstructor +public class SensorController { + + private final SeismicEventRepository seismicRepo; + private final PressureReadingRepository pressureRepo; + + /** + * 지진 이벤트 조회 (USGS 수집 데이터) + */ + @GetMapping("/seismic") + public Map getSeismic( + @RequestParam(defaultValue = "24") int hours) { + Instant since = Instant.now().minus(hours, ChronoUnit.HOURS); + List data = seismicRepo + .findByEventTimeAfterOrderByEventTimeDesc(since) + .stream().map(SensorDto.SeismicDto::from).toList(); + return Map.of("count", data.size(), "data", data); + } + + /** + * 기압 데이터 조회 (Open-Meteo 수집 데이터) + */ + @GetMapping("/pressure") + public Map getPressure( + @RequestParam(defaultValue = "24") int hours) { + Instant since = Instant.now().minus(hours, ChronoUnit.HOURS); + List data = pressureRepo + .findByReadingTimeAfterOrderByReadingTimeAsc(since) + .stream().map(SensorDto.PressureDto::from).toList(); + return Map.of("count", data.size(), "data", data); + } +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/sensor/SensorDto.java b/backend/src/main/java/gc/mda/kcg/domain/sensor/SensorDto.java new file mode 100644 index 0000000..8e06b56 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/sensor/SensorDto.java @@ -0,0 +1,51 @@ +package gc.mda.kcg.domain.sensor; + +import lombok.Builder; +import lombok.Getter; + +public class SensorDto { + + @Getter + @Builder + public static class SeismicDto { + private String usgsId; + private double magnitude; + private Double depth; + private double lat; + private double lng; + private String place; + private long timestamp; // epoch ms + + public static SeismicDto from(SeismicEvent e) { + return SeismicDto.builder() + .usgsId(e.getUsgsId()) + .magnitude(e.getMagnitude()) + .depth(e.getDepth()) + .lat(e.getLat()) + .lng(e.getLng()) + .place(e.getPlace()) + .timestamp(e.getEventTime().toEpochMilli()) + .build(); + } + } + + @Getter + @Builder + public static class PressureDto { + private String station; + private double lat; + private double lng; + private double pressureHpa; + private long timestamp; // epoch ms + + public static PressureDto from(PressureReading r) { + return PressureDto.builder() + .station(r.getStation()) + .lat(r.getLat()) + .lng(r.getLng()) + .pressureHpa(r.getPressureHpa()) + .timestamp(r.getReadingTime().toEpochMilli()) + .build(); + } + } +} diff --git a/database/migration/004_sensor_data.sql b/database/migration/004_sensor_data.sql new file mode 100644 index 0000000..f937f0a --- /dev/null +++ b/database/migration/004_sensor_data.sql @@ -0,0 +1,30 @@ +SET search_path TO kcg, public; + +-- 지진 이벤트 (USGS) +CREATE TABLE IF NOT EXISTS seismic_events ( + id BIGSERIAL PRIMARY KEY, + usgs_id VARCHAR(30) UNIQUE NOT NULL, + magnitude DOUBLE PRECISION NOT NULL, + depth DOUBLE PRECISION, + lat DOUBLE PRECISION NOT NULL, + lng DOUBLE PRECISION NOT NULL, + place VARCHAR(255), + event_time TIMESTAMPTZ NOT NULL, + collected_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS idx_seismic_events_time ON seismic_events(event_time DESC); + +-- 기압 데이터 (Open-Meteo) +CREATE TABLE IF NOT EXISTS pressure_readings ( + id BIGSERIAL PRIMARY KEY, + station VARCHAR(50) NOT NULL, + lat DOUBLE PRECISION NOT NULL, + lng DOUBLE PRECISION NOT NULL, + pressure_hpa DOUBLE PRECISION NOT NULL, + reading_time TIMESTAMPTZ NOT NULL, + collected_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE(station, reading_time) +); + +CREATE INDEX IF NOT EXISTS idx_pressure_readings_time ON pressure_readings(reading_time DESC); diff --git a/frontend/src/App.css b/frontend/src/App.css index a466923..1c27ecb 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1131,6 +1131,12 @@ font-family: 'Courier New', monospace; } +.chart-demo-label { + font-size: 9px; + color: #ef4444; + opacity: 0.7; +} + /* Footer / Controls */ .app-footer { background: var(--bg-card); diff --git a/frontend/src/components/common/SensorChart.tsx b/frontend/src/components/common/SensorChart.tsx index 6f4077f..288a22d 100644 --- a/frontend/src/components/common/SensorChart.tsx +++ b/frontend/src/components/common/SensorChart.tsx @@ -54,19 +54,6 @@ export function SensorChart({ data, currentTime, startTime }: Props) { -
-

{t('sensor.noiseLevelDb')}

- - - - - - - - - -
-

{t('sensor.airPressureHpa')}

@@ -81,7 +68,26 @@ export function SensorChart({ data, currentTime, startTime }: Props) {
-

{t('sensor.radiationUsv')}

+

+ {t('sensor.noiseLevelDb')}{' '} + (DEMO) +

+ + + + + + + + + +
+ +
+

+ {t('sensor.radiationUsv')}{' '} + (DEMO) +

-- 2.45.2 From c84380a3a7e2fdfb124bc3515782df2b68e0345b Mon Sep 17 00:00:00 2001 From: htlee Date: Wed, 18 Mar 2026 08:14:57 +0900 Subject: [PATCH 2/2] =?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 | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index c2c3886..7163403 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -4,6 +4,14 @@ ## [Unreleased] +### 추가 +- 지진파 수집기: USGS FDSN API, 이란 bbox(M2+), 5분 주기 +- 기압 수집기: Open-Meteo API, 이란 5개 관측점(테헤란/이스파한/반다르아바스/시라즈/타브리즈), 10분 주기 +- DB 테이블: seismic_events, pressure_readings (마이그레이션 004) +- REST API: GET /api/sensor/seismic, GET /api/sensor/pressure +- SensorChart 그래프 순서 변경: 지진파 → 기압 → 소음 → 방사선 +- 소음/방사선 차트에 (DEMO) 라벨 표시 + ### 변경 - 프론트엔드 패키지 구조 리팩토링: components/ → common/layers/iran/korea/ 분리 - App.tsx God Component 분해: 1,179줄 → 588줄 (데이터 훅 3개 추출) -- 2.45.2