feat(backend): 이란 리플레이 시점 조회 API + Events CRUD
- Aircraft/OSINT Controller: from/to Instant 파라미터 추가 (기존 캐시 조회와 공존) - AircraftService.getByDateRange(): DB에서 icao24별 최신 위치 조회 - OsintService.getByDateRange(): 날짜 범위 OSINT 조회 - Event 패키지 신규: Entity, Dto, Repository, Service, Controller - GET /api/events?from=&to= (인증 예외) - POST /api/events/import (벌크 import) - AuthFilter: /api/events 인증 예외 추가
This commit is contained in:
부모
81bced4367
커밋
9e1b3730ff
@ -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
|
||||
|
||||
@ -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<String> VALID_REGIONS = Set.of("iran", "korea");
|
||||
|
||||
private final AircraftCacheStore cacheStore;
|
||||
private final AircraftService aircraftService;
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<Map<String, Object>> 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<AircraftDto> results = aircraftService.getByDateRange(region, from, to);
|
||||
return ResponseEntity.ok(Map.of("region", region, "count", results.size(), "items", results));
|
||||
}
|
||||
|
||||
List<AircraftDto> aircraft = cacheStore.get(region);
|
||||
long lastUpdated = cacheStore.getLastUpdated(region);
|
||||
|
||||
|
||||
@ -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<AircraftPosition, Long> {
|
||||
|
||||
@Query("SELECT a FROM AircraftPosition a WHERE a.region = :region AND a.collectedAt BETWEEN :from AND :to ORDER BY a.collectedAt DESC")
|
||||
List<AircraftPosition> findByRegionAndDateRange(
|
||||
@Param("region") String region,
|
||||
@Param("from") Instant from,
|
||||
@Param("to") Instant to);
|
||||
}
|
||||
|
||||
@ -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<AircraftDto> getByDateRange(String region, Instant from, Instant to) {
|
||||
List<AircraftPosition> positions = repository.findByRegionAndDateRange(region, from, to);
|
||||
|
||||
Map<String, AircraftDto> 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();
|
||||
}
|
||||
}
|
||||
42
backend/src/main/java/gc/mda/kcg/domain/event/Event.java
Normal file
42
backend/src/main/java/gc/mda/kcg/domain/event/Event.java
Normal file
@ -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<String, Object> rawData;
|
||||
}
|
||||
@ -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<Map<String, Object>> 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<EventDto> results = eventService.getByDateRange(f, t);
|
||||
return ResponseEntity.ok(Map.of("count", results.size(), "items", results));
|
||||
}
|
||||
|
||||
@PostMapping("/import")
|
||||
public ResponseEntity<Map<String, Object>> importEvents(@RequestBody List<EventDto> events) {
|
||||
int imported = eventService.importEvents(events);
|
||||
return ResponseEntity.ok(Map.of("imported", imported, "total", events.size()));
|
||||
}
|
||||
}
|
||||
49
backend/src/main/java/gc/mda/kcg/domain/event/EventDto.java
Normal file
49
backend/src/main/java/gc/mda/kcg/domain/event/EventDto.java
Normal file
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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<Event, Long> {
|
||||
|
||||
List<Event> findByTimestampBetweenOrderByTimestampAsc(Instant from, Instant to);
|
||||
|
||||
boolean existsByEventId(String eventId);
|
||||
}
|
||||
@ -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<EventDto> getByDateRange(Instant from, Instant to) {
|
||||
return repository.findByTimestampBetweenOrderByTimestampAsc(from, to)
|
||||
.stream().map(EventDto::from).toList();
|
||||
}
|
||||
|
||||
public int importEvents(List<EventDto> 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;
|
||||
}
|
||||
}
|
||||
@ -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<String> VALID_REGIONS = Set.of("iran", "korea");
|
||||
|
||||
private final CacheManager cacheManager;
|
||||
private final OsintService osintService;
|
||||
|
||||
private final Map<String, Long> lastUpdated = new ConcurrentHashMap<>();
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<Map<String, Object>> 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<OsintDto> 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<OsintDto> items = getCachedItems(cacheName);
|
||||
long updatedAt = lastUpdated.getOrDefault(region, 0L);
|
||||
|
||||
@ -12,4 +12,6 @@ public interface OsintFeedRepository extends JpaRepository<OsintFeed, Long> {
|
||||
boolean existsByTitle(String title);
|
||||
|
||||
List<OsintFeed> findByRegionAndCollectedAtAfterOrderByPublishedAtDesc(String region, Instant since);
|
||||
|
||||
List<OsintFeed> findByRegionAndPublishedAtBetweenOrderByPublishedAtDesc(String region, Instant from, Instant to);
|
||||
}
|
||||
|
||||
@ -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<OsintDto> getByDateRange(String region, Instant from, Instant to) {
|
||||
return repository.findByRegionAndPublishedAtBetweenOrderByPublishedAtDesc(region, from, to)
|
||||
.stream()
|
||||
.map(OsintDto::from)
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
불러오는 중...
Reference in New Issue
Block a user