diff --git a/backend/src/main/java/gc/mda/kcg/auth/AuthFilter.java b/backend/src/main/java/gc/mda/kcg/auth/AuthFilter.java index 18c4370..78ae2ae 100644 --- a/backend/src/main/java/gc/mda/kcg/auth/AuthFilter.java +++ b/backend/src/main/java/gc/mda/kcg/auth/AuthFilter.java @@ -26,6 +26,7 @@ public class AuthFilter extends OncePerRequestFilter { private static final String VESSEL_ANALYSIS_PATH_PREFIX = "/api/vessel-analysis"; private static final String PREDICTION_PATH_PREFIX = "/api/prediction/"; private static final String FLEET_PATH_PREFIX = "/api/fleet-"; + private static final String EVENTS_PATH_PREFIX = "/api/events"; private final JwtProvider jwtProvider; @@ -37,7 +38,8 @@ public class AuthFilter extends OncePerRequestFilter { || path.startsWith(CCTV_PATH_PREFIX) || path.startsWith(VESSEL_ANALYSIS_PATH_PREFIX) || path.startsWith(PREDICTION_PATH_PREFIX) - || path.startsWith(FLEET_PATH_PREFIX); + || path.startsWith(FLEET_PATH_PREFIX) + || path.startsWith(EVENTS_PATH_PREFIX); } @Override diff --git a/backend/src/main/java/gc/mda/kcg/domain/aircraft/AircraftController.java b/backend/src/main/java/gc/mda/kcg/domain/aircraft/AircraftController.java index 1846d1e..dc08546 100644 --- a/backend/src/main/java/gc/mda/kcg/domain/aircraft/AircraftController.java +++ b/backend/src/main/java/gc/mda/kcg/domain/aircraft/AircraftController.java @@ -2,12 +2,14 @@ package gc.mda.kcg.domain.aircraft; import gc.mda.kcg.collector.aircraft.AircraftCacheStore; import lombok.RequiredArgsConstructor; +import org.springframework.format.annotation.DateTimeFormat; 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.time.Instant; import java.util.List; import java.util.Map; import java.util.Set; @@ -20,16 +22,24 @@ public class AircraftController { private static final Set VALID_REGIONS = Set.of("iran", "korea"); private final AircraftCacheStore cacheStore; + private final AircraftService aircraftService; @GetMapping public ResponseEntity> getAircraft( - @RequestParam(defaultValue = "iran") String region) { + @RequestParam(defaultValue = "iran") String region, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant from, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant to) { if (!VALID_REGIONS.contains(region)) { return ResponseEntity.badRequest() .body(Map.of("error", "유효하지 않은 region: " + region)); } + if (from != null && to != null) { + List results = aircraftService.getByDateRange(region, from, to); + return ResponseEntity.ok(Map.of("region", region, "count", results.size(), "items", results)); + } + List aircraft = cacheStore.get(region); long lastUpdated = cacheStore.getLastUpdated(region); diff --git a/backend/src/main/java/gc/mda/kcg/domain/aircraft/AircraftPositionRepository.java b/backend/src/main/java/gc/mda/kcg/domain/aircraft/AircraftPositionRepository.java index 2442340..9e6a100 100644 --- a/backend/src/main/java/gc/mda/kcg/domain/aircraft/AircraftPositionRepository.java +++ b/backend/src/main/java/gc/mda/kcg/domain/aircraft/AircraftPositionRepository.java @@ -1,6 +1,17 @@ package gc.mda.kcg.domain.aircraft; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.Instant; +import java.util.List; public interface AircraftPositionRepository extends JpaRepository { + + @Query("SELECT a FROM AircraftPosition a WHERE a.region = :region AND a.collectedAt BETWEEN :from AND :to ORDER BY a.collectedAt DESC") + List findByRegionAndDateRange( + @Param("region") String region, + @Param("from") Instant from, + @Param("to") Instant to); } diff --git a/backend/src/main/java/gc/mda/kcg/domain/aircraft/AircraftService.java b/backend/src/main/java/gc/mda/kcg/domain/aircraft/AircraftService.java new file mode 100644 index 0000000..f6f0824 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/aircraft/AircraftService.java @@ -0,0 +1,51 @@ +package gc.mda.kcg.domain.aircraft; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +@Service +@RequiredArgsConstructor +public class AircraftService { + + private final AircraftPositionRepository repository; + + /** + * 시간 범위 내 항공기 위치를 조회하고 icao24 기준 최신 위치로 중복 제거하여 반환. + */ + public List getByDateRange(String region, Instant from, Instant to) { + List positions = repository.findByRegionAndDateRange(region, from, to); + + Map deduplicated = new LinkedHashMap<>(); + for (AircraftPosition p : positions) { + deduplicated.putIfAbsent(p.getIcao24(), toDto(p)); + } + return List.copyOf(deduplicated.values()); + } + + private AircraftDto toDto(AircraftPosition p) { + return AircraftDto.builder() + .icao24(p.getIcao24()) + .callsign(p.getCallsign()) + .lat(p.getPosition() != null ? p.getPosition().getY() : 0.0) + .lng(p.getPosition() != null ? p.getPosition().getX() : 0.0) + .altitude(p.getAltitude() != null ? p.getAltitude() : 0.0) + .velocity(p.getVelocity() != null ? p.getVelocity() : 0.0) + .heading(p.getHeading() != null ? p.getHeading() : 0.0) + .verticalRate(p.getVerticalRate() != null ? p.getVerticalRate() : 0.0) + .onGround(p.getOnGround() != null && p.getOnGround()) + .category(p.getCategory()) + .typecode(p.getTypecode()) + .typeDesc(p.getTypeDesc()) + .registration(p.getRegistration()) + .operator(p.getOperator()) + .squawk(p.getSquawk()) + .lastSeen(p.getLastSeen() != null ? p.getLastSeen().toEpochMilli() + : p.getCollectedAt().toEpochMilli()) + .build(); + } +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/event/Event.java b/backend/src/main/java/gc/mda/kcg/domain/event/Event.java new file mode 100644 index 0000000..385d22e --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/event/Event.java @@ -0,0 +1,42 @@ +package gc.mda.kcg.domain.event; + +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +import java.time.Instant; +import java.util.Map; + +@Entity +@Table(name = "events", schema = "kcg") +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class Event { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "event_id", unique = true) + private String eventId; + + private String title; + private String description; + private String source; + + @Column(name = "latitude") + private Double latitude; + + @Column(name = "longitude") + private Double longitude; + + private Instant timestamp; + + @JdbcTypeCode(SqlTypes.JSON) + @Column(columnDefinition = "jsonb") + private Map rawData; +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/event/EventController.java b/backend/src/main/java/gc/mda/kcg/domain/event/EventController.java new file mode 100644 index 0000000..b72bfad --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/event/EventController.java @@ -0,0 +1,34 @@ +package gc.mda.kcg.domain.event; + +import lombok.RequiredArgsConstructor; +import org.springframework.format.annotation.DateTimeFormat; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.time.Instant; +import java.util.List; +import java.util.Map; + +@RestController +@RequestMapping("/api/events") +@RequiredArgsConstructor +public class EventController { + + private final EventService eventService; + + @GetMapping + public ResponseEntity> getEvents( + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant from, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant to) { + Instant f = from != null ? from : Instant.parse("2026-03-01T00:00:00Z"); + Instant t = to != null ? to : Instant.now(); + List results = eventService.getByDateRange(f, t); + return ResponseEntity.ok(Map.of("count", results.size(), "items", results)); + } + + @PostMapping("/import") + public ResponseEntity> importEvents(@RequestBody List events) { + int imported = eventService.importEvents(events); + return ResponseEntity.ok(Map.of("imported", imported, "total", events.size())); + } +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/event/EventDto.java b/backend/src/main/java/gc/mda/kcg/domain/event/EventDto.java new file mode 100644 index 0000000..61105ea --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/event/EventDto.java @@ -0,0 +1,49 @@ +package gc.mda.kcg.domain.event; + +import com.fasterxml.jackson.annotation.JsonInclude; +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +@JsonInclude(JsonInclude.Include.NON_NULL) +public class EventDto { + + private String id; + private long timestamp; + private Double lat; + private Double lng; + private String type; + private String source; + private String label; + private String description; + private Integer intensity; + + public static EventDto from(Event e) { + return EventDto.builder() + .id(e.getEventId()) + .timestamp(e.getTimestamp() != null ? e.getTimestamp().toEpochMilli() : 0) + .lat(e.getLatitude()) + .lng(e.getLongitude()) + .type(extractType(e)) + .source(e.getSource()) + .label(e.getTitle()) + .description(e.getDescription()) + .intensity(extractIntensity(e)) + .build(); + } + + private static String extractType(Event e) { + if (e.getRawData() != null && e.getRawData().containsKey("type")) { + return String.valueOf(e.getRawData().get("type")); + } + return "alert"; + } + + private static Integer extractIntensity(Event e) { + if (e.getRawData() != null && e.getRawData().containsKey("intensity")) { + return ((Number) e.getRawData().get("intensity")).intValue(); + } + return 50; + } +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/event/EventRepository.java b/backend/src/main/java/gc/mda/kcg/domain/event/EventRepository.java new file mode 100644 index 0000000..1c21e14 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/event/EventRepository.java @@ -0,0 +1,13 @@ +package gc.mda.kcg.domain.event; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.time.Instant; +import java.util.List; + +public interface EventRepository extends JpaRepository { + + List findByTimestampBetweenOrderByTimestampAsc(Instant from, Instant to); + + boolean existsByEventId(String eventId); +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/event/EventService.java b/backend/src/main/java/gc/mda/kcg/domain/event/EventService.java new file mode 100644 index 0000000..050bc54 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/event/EventService.java @@ -0,0 +1,43 @@ +package gc.mda.kcg.domain.event; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.util.List; +import java.util.Map; + +@Service +@RequiredArgsConstructor +public class EventService { + + private final EventRepository repository; + + public List getByDateRange(Instant from, Instant to) { + return repository.findByTimestampBetweenOrderByTimestampAsc(from, to) + .stream().map(EventDto::from).toList(); + } + + public int importEvents(List dtos) { + int count = 0; + for (EventDto dto : dtos) { + if (dto.getId() != null && repository.existsByEventId(dto.getId())) continue; + Event e = Event.builder() + .eventId(dto.getId()) + .title(dto.getLabel()) + .description(dto.getDescription()) + .source(dto.getSource()) + .latitude(dto.getLat()) + .longitude(dto.getLng()) + .timestamp(Instant.ofEpochMilli(dto.getTimestamp())) + .rawData(Map.of( + "type", dto.getType() != null ? dto.getType() : "alert", + "intensity", dto.getIntensity() != null ? dto.getIntensity() : 50 + )) + .build(); + repository.save(e); + count++; + } + return count; + } +} 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 index bed9fb7..2419520 100644 --- a/backend/src/main/java/gc/mda/kcg/domain/osint/OsintController.java +++ b/backend/src/main/java/gc/mda/kcg/domain/osint/OsintController.java @@ -4,12 +4,14 @@ import gc.mda.kcg.config.CacheConfig; import lombok.RequiredArgsConstructor; import org.springframework.cache.Cache; import org.springframework.cache.CacheManager; +import org.springframework.format.annotation.DateTimeFormat; 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.time.Instant; import java.util.List; import java.util.Map; import java.util.Set; @@ -23,18 +25,26 @@ public class OsintController { private static final Set VALID_REGIONS = Set.of("iran", "korea"); private final CacheManager cacheManager; + private final OsintService osintService; private final Map lastUpdated = new ConcurrentHashMap<>(); @GetMapping public ResponseEntity> getOsint( - @RequestParam(defaultValue = "iran") String region) { + @RequestParam(defaultValue = "iran") String region, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant from, + @RequestParam(required = false) @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) Instant to) { if (!VALID_REGIONS.contains(region)) { return ResponseEntity.badRequest() .body(Map.of("error", "유효하지 않은 region: " + region)); } + if (from != null && to != null) { + List results = osintService.getByDateRange(region, from, to); + return ResponseEntity.ok(Map.of("region", region, "count", results.size(), "items", results)); + } + String cacheName = "iran".equals(region) ? CacheConfig.OSINT_IRAN : CacheConfig.OSINT_KOREA; List items = getCachedItems(cacheName); long updatedAt = lastUpdated.getOrDefault(region, 0L); 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 index d3355aa..a548686 100644 --- a/backend/src/main/java/gc/mda/kcg/domain/osint/OsintFeedRepository.java +++ b/backend/src/main/java/gc/mda/kcg/domain/osint/OsintFeedRepository.java @@ -12,4 +12,6 @@ public interface OsintFeedRepository extends JpaRepository { boolean existsByTitle(String title); List findByRegionAndCollectedAtAfterOrderByPublishedAtDesc(String region, Instant since); + + List findByRegionAndPublishedAtBetweenOrderByPublishedAtDesc(String region, Instant from, Instant to); } diff --git a/backend/src/main/java/gc/mda/kcg/domain/osint/OsintService.java b/backend/src/main/java/gc/mda/kcg/domain/osint/OsintService.java new file mode 100644 index 0000000..1a7d544 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/osint/OsintService.java @@ -0,0 +1,25 @@ +package gc.mda.kcg.domain.osint; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class OsintService { + + private final OsintFeedRepository repository; + + /** + * 시간 범위 내 OSINT 피드를 조회하여 반환. + * focus(region) 필드 기준 필터링, publishedAt 기준 정렬. + */ + public List getByDateRange(String region, Instant from, Instant to) { + return repository.findByRegionAndPublishedAtBetweenOrderByPublishedAtDesc(region, from, to) + .stream() + .map(OsintDto::from) + .toList(); + } +}