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) +