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:
htlee 2026-03-24 07:52:06 +09:00
부모 81bced4367
커밋 9e1b3730ff
12개의 변경된 파일295개의 추가작업 그리고 3개의 파일을 삭제

파일 보기

@ -26,6 +26,7 @@ public class AuthFilter extends OncePerRequestFilter {
private static final String VESSEL_ANALYSIS_PATH_PREFIX = "/api/vessel-analysis"; private static final String VESSEL_ANALYSIS_PATH_PREFIX = "/api/vessel-analysis";
private static final String PREDICTION_PATH_PREFIX = "/api/prediction/"; private static final String PREDICTION_PATH_PREFIX = "/api/prediction/";
private static final String FLEET_PATH_PREFIX = "/api/fleet-"; private static final String FLEET_PATH_PREFIX = "/api/fleet-";
private static final String EVENTS_PATH_PREFIX = "/api/events";
private final JwtProvider jwtProvider; private final JwtProvider jwtProvider;
@ -37,7 +38,8 @@ public class AuthFilter extends OncePerRequestFilter {
|| path.startsWith(CCTV_PATH_PREFIX) || path.startsWith(CCTV_PATH_PREFIX)
|| path.startsWith(VESSEL_ANALYSIS_PATH_PREFIX) || path.startsWith(VESSEL_ANALYSIS_PATH_PREFIX)
|| path.startsWith(PREDICTION_PATH_PREFIX) || path.startsWith(PREDICTION_PATH_PREFIX)
|| path.startsWith(FLEET_PATH_PREFIX); || path.startsWith(FLEET_PATH_PREFIX)
|| path.startsWith(EVENTS_PATH_PREFIX);
} }
@Override @Override

파일 보기

@ -2,12 +2,14 @@ package gc.mda.kcg.domain.aircraft;
import gc.mda.kcg.collector.aircraft.AircraftCacheStore; import gc.mda.kcg.collector.aircraft.AircraftCacheStore;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import java.time.Instant;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set; import java.util.Set;
@ -20,16 +22,24 @@ public class AircraftController {
private static final Set<String> VALID_REGIONS = Set.of("iran", "korea"); private static final Set<String> VALID_REGIONS = Set.of("iran", "korea");
private final AircraftCacheStore cacheStore; private final AircraftCacheStore cacheStore;
private final AircraftService aircraftService;
@GetMapping @GetMapping
public ResponseEntity<Map<String, Object>> getAircraft( 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)) { if (!VALID_REGIONS.contains(region)) {
return ResponseEntity.badRequest() return ResponseEntity.badRequest()
.body(Map.of("error", "유효하지 않은 region: " + region)); .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); List<AircraftDto> aircraft = cacheStore.get(region);
long lastUpdated = cacheStore.getLastUpdated(region); long lastUpdated = cacheStore.getLastUpdated(region);

파일 보기

@ -1,6 +1,17 @@
package gc.mda.kcg.domain.aircraft; package gc.mda.kcg.domain.aircraft;
import org.springframework.data.jpa.repository.JpaRepository; 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> { 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();
}
}

파일 보기

@ -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()));
}
}

파일 보기

@ -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 lombok.RequiredArgsConstructor;
import org.springframework.cache.Cache; import org.springframework.cache.Cache;
import org.springframework.cache.CacheManager; import org.springframework.cache.CacheManager;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController; import org.springframework.web.bind.annotation.RestController;
import java.time.Instant;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Set; import java.util.Set;
@ -23,18 +25,26 @@ public class OsintController {
private static final Set<String> VALID_REGIONS = Set.of("iran", "korea"); private static final Set<String> VALID_REGIONS = Set.of("iran", "korea");
private final CacheManager cacheManager; private final CacheManager cacheManager;
private final OsintService osintService;
private final Map<String, Long> lastUpdated = new ConcurrentHashMap<>(); private final Map<String, Long> lastUpdated = new ConcurrentHashMap<>();
@GetMapping @GetMapping
public ResponseEntity<Map<String, Object>> getOsint( 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)) { if (!VALID_REGIONS.contains(region)) {
return ResponseEntity.badRequest() return ResponseEntity.badRequest()
.body(Map.of("error", "유효하지 않은 region: " + region)); .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; String cacheName = "iran".equals(region) ? CacheConfig.OSINT_IRAN : CacheConfig.OSINT_KOREA;
List<OsintDto> items = getCachedItems(cacheName); List<OsintDto> items = getCachedItems(cacheName);
long updatedAt = lastUpdated.getOrDefault(region, 0L); long updatedAt = lastUpdated.getOrDefault(region, 0L);

파일 보기

@ -12,4 +12,6 @@ public interface OsintFeedRepository extends JpaRepository<OsintFeed, Long> {
boolean existsByTitle(String title); boolean existsByTitle(String title);
List<OsintFeed> findByRegionAndCollectedAtAfterOrderByPublishedAtDesc(String region, Instant since); 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();
}
}