feat: 다중 폴리곤 영역 탐색 REST API + STRtree 공간 인덱스 (Phase 8)
캐시 기반 인메모리 다중 폴리곤 영역 내 선박 탐색 API 구현. JTS STRtree + PreparedGeometry로 고속 공간 검색, ANY/ALL/SEQUENTIAL 3가지 모드 지원. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
부모
e729316edf
커밋
b4221c36fd
@ -193,6 +193,37 @@
|
||||
|
||||
---
|
||||
|
||||
## Phase 8 — 다중 폴리곤 영역 탐색 REST API + 공간 인덱스
|
||||
|
||||
- [x] **8.1** DailyTrackCacheManager에 STRtree 공간 인덱스 추가
|
||||
- DailyTrackData에 STRtree spatialIndex 필드 추가 (하위 호환 유지)
|
||||
- loadDay() 완료 후 자동 빌드 → build() 호출로 불변화 (동시성 안전)
|
||||
- getDailyTrackData(date), getCachedDateList() public 메서드 추가
|
||||
- 추가 메모리: 선박당 ~100B × 50K/일 = ~5MB/일, 7일 ~35MB
|
||||
- 상태: **완료** (2026-02-07)
|
||||
|
||||
- [x] **8.2** AreaSearchRequest / AreaSearchResponse DTO 생성
|
||||
- AreaSearchRequest: startTime/endTime + SearchMode(ANY/ALL/SEQUENTIAL) + List<SearchPolygon>
|
||||
- AreaSearchResponse: tracks(CompactVesselTrack[]) + hitDetails + summary
|
||||
- Swagger @Schema 상세 기입, 요청/응답 예시 포함
|
||||
- 상태: **완료** (2026-02-07)
|
||||
|
||||
- [x] **8.3** AreaSearchService 핵심 비즈니스 로직 구현
|
||||
- JTS PreparedGeometry + STRtree 기반 고속 영역 탐색
|
||||
- 다일 데이터 병합 → 단일 STRtree → 폴리곤별 후보 추출 → 정밀 PIP
|
||||
- ANY(합집합)/ALL(교집합)/SEQUENTIAL(순차 통과) 3가지 모드
|
||||
- 캐시 미준비 시 CacheNotReadyException → 503 반환
|
||||
- 상태: **완료** (2026-02-07)
|
||||
|
||||
- [x] **8.4** AreaSearchController REST 엔드포인트
|
||||
- POST /api/v2/tracks/area-search
|
||||
- @Tag("항적 조회 API V2") → 기존 GisControllerV2와 동일 Swagger 그룹
|
||||
- 에러 핸들러: 400 (잘못된 폴리곤), 503 (캐시 미준비)
|
||||
- Swagger: @Operation, @ExampleObject 상세 문서화
|
||||
- 상태: **완료** (2026-02-07)
|
||||
|
||||
---
|
||||
|
||||
## 커밋 이력
|
||||
|
||||
| 날짜 | Phase | 커밋 메시지 | 해시 |
|
||||
|
||||
@ -0,0 +1,123 @@
|
||||
package gc.mda.signal_batch.domain.gis.controller;
|
||||
|
||||
import gc.mda.signal_batch.domain.gis.dto.AreaSearchRequest;
|
||||
import gc.mda.signal_batch.domain.gis.dto.AreaSearchResponse;
|
||||
import gc.mda.signal_batch.domain.gis.service.AreaSearchService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.media.Content;
|
||||
import io.swagger.v3.oas.annotations.media.ExampleObject;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponses;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import java.util.Map;
|
||||
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping("/api/v2/tracks")
|
||||
@RequiredArgsConstructor
|
||||
@Tag(name = "항적 조회 API V2", description = "해구/영역별 선박 항적 조회 API (WebSocket 호환 CompactVesselTrack 응답)")
|
||||
public class AreaSearchController {
|
||||
|
||||
private final AreaSearchService areaSearchService;
|
||||
|
||||
@PostMapping("/area-search")
|
||||
@Operation(
|
||||
summary = "다중 폴리곤 영역 내 선박 탐색",
|
||||
description = """
|
||||
인메모리 캐시(D-1~D-7)를 활용하여 사용자 지정 다중 폴리곤 영역 내 선박을 탐색합니다.
|
||||
|
||||
**검색 모드 (폴리곤 2개 이상 시):**
|
||||
- **ANY**: 합집합 - 어느 한 영역이라도 통과한 선박
|
||||
- **ALL**: 교집합 - 모든 영역을 통과한 선박
|
||||
- **SEQUENTIAL**: 순차 통과 - 모든 영역을 지정된 순서대로 통과한 선박
|
||||
|
||||
**폴리곤 1개일 때:** mode는 무시되며, 해당 영역 히트 선박 + 전체 트랙 반환
|
||||
|
||||
**응답 구조:**
|
||||
- `tracks`: 기존 V2 API와 동일한 CompactVesselTrack 배열 (프론트엔드 렌더링 호환)
|
||||
- `hitDetails`: 선박별 영역 히트 메타데이터 (진입/진출 시간, 히트 포인트 수)
|
||||
- `summary`: 검색 요약 (선박 수, 포인트 수, 처리 시간 등)
|
||||
|
||||
**제약사항:**
|
||||
- 캐시된 날짜 범위만 조회 가능 (D-1 ~ D-7, 오늘 제외)
|
||||
- 폴리곤 최대 10개
|
||||
- 폴리곤 좌표는 닫힌 형태 (첫점 == 끝점)
|
||||
- 캐시 미준비 시 503 반환
|
||||
"""
|
||||
)
|
||||
@ApiResponses(value = {
|
||||
@ApiResponse(responseCode = "200", description = "탐색 성공",
|
||||
content = @Content(
|
||||
mediaType = "application/json",
|
||||
schema = @Schema(implementation = AreaSearchResponse.class)
|
||||
)),
|
||||
@ApiResponse(responseCode = "400", description = "잘못된 요청 (날짜 범위 초과, 잘못된 폴리곤 등)",
|
||||
content = @Content(
|
||||
mediaType = "application/json",
|
||||
examples = @ExampleObject(value = "{\"error\": \"폴리곤 'zone_A'이 유효하지 않습니다 (자기 교차 등)\"}")
|
||||
)),
|
||||
@ApiResponse(responseCode = "503", description = "캐시 미준비 (LOADING 상태)",
|
||||
content = @Content(
|
||||
mediaType = "application/json",
|
||||
examples = @ExampleObject(value = "{\"error\": \"캐시가 아직 준비되지 않았습니다 (상태: LOADING)\"}")
|
||||
))
|
||||
})
|
||||
public ResponseEntity<AreaSearchResponse> searchArea(
|
||||
@io.swagger.v3.oas.annotations.parameters.RequestBody(
|
||||
description = "다중 폴리곤 영역 탐색 요청",
|
||||
required = true,
|
||||
content = @Content(
|
||||
schema = @Schema(implementation = AreaSearchRequest.class),
|
||||
examples = @ExampleObject(
|
||||
name = "순차 통과 예시",
|
||||
value = """
|
||||
{
|
||||
"startTime": "2026-02-01T00:00:00",
|
||||
"endTime": "2026-02-07T23:59:59",
|
||||
"mode": "SEQUENTIAL",
|
||||
"polygons": [
|
||||
{
|
||||
"id": "zone_A", "name": "대한해협 서수도",
|
||||
"coordinates": [[128.5,34.0],[129.5,34.0],[129.5,35.0],[128.5,35.0],[128.5,34.0]]
|
||||
},
|
||||
{
|
||||
"id": "zone_B", "name": "제주해협",
|
||||
"coordinates": [[126.0,33.0],[127.0,33.0],[127.0,34.0],[126.0,34.0],[126.0,33.0]]
|
||||
}
|
||||
]
|
||||
}
|
||||
"""
|
||||
)
|
||||
)
|
||||
)
|
||||
@Valid @RequestBody AreaSearchRequest request) {
|
||||
|
||||
log.info("Area search request: mode={}, polygons={}, timeRange={} ~ {}",
|
||||
request.getMode(), request.getPolygons().size(),
|
||||
request.getStartTime(), request.getEndTime());
|
||||
|
||||
return ResponseEntity.ok(areaSearchService.search(request));
|
||||
}
|
||||
|
||||
@ExceptionHandler(IllegalArgumentException.class)
|
||||
public ResponseEntity<Map<String, String>> handleBadRequest(IllegalArgumentException e) {
|
||||
log.warn("Area search bad request: {}", e.getMessage());
|
||||
return ResponseEntity.badRequest()
|
||||
.body(Map.of("error", e.getMessage()));
|
||||
}
|
||||
|
||||
@ExceptionHandler(AreaSearchService.CacheNotReadyException.class)
|
||||
public ResponseEntity<Map<String, String>> handleCacheNotReady(AreaSearchService.CacheNotReadyException e) {
|
||||
log.warn("Area search cache not ready: {}", e.getMessage());
|
||||
return ResponseEntity.status(HttpStatus.SERVICE_UNAVAILABLE)
|
||||
.body(Map.of("error", e.getMessage()));
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,75 @@
|
||||
package gc.mda.signal_batch.domain.gis.dto;
|
||||
|
||||
import com.fasterxml.jackson.databind.annotation.JsonDeserialize;
|
||||
import gc.mda.signal_batch.global.config.FlexibleLocalDateTimeDeserializer;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import jakarta.validation.Valid;
|
||||
import jakarta.validation.constraints.NotNull;
|
||||
import jakarta.validation.constraints.Size;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Schema(description = "다중 폴리곤 영역 탐색 요청")
|
||||
public class AreaSearchRequest {
|
||||
|
||||
@NotNull(message = "시작 시간은 필수입니다")
|
||||
@JsonDeserialize(using = FlexibleLocalDateTimeDeserializer.class)
|
||||
@Schema(description = "조회 시작 시간", example = "2026-02-01T00:00:00", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private LocalDateTime startTime;
|
||||
|
||||
@NotNull(message = "종료 시간은 필수입니다")
|
||||
@JsonDeserialize(using = FlexibleLocalDateTimeDeserializer.class)
|
||||
@Schema(description = "조회 종료 시간", example = "2026-02-07T23:59:59", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private LocalDateTime endTime;
|
||||
|
||||
@Schema(description = "검색 모드: ANY(합집합), ALL(교집합), SEQUENTIAL(순차 통과). 폴리곤 1개일 때는 무시됨",
|
||||
example = "ANY", defaultValue = "ANY")
|
||||
@Builder.Default
|
||||
private SearchMode mode = SearchMode.ANY;
|
||||
|
||||
@NotNull(message = "폴리곤 목록은 필수입니다")
|
||||
@Size(min = 1, max = 10, message = "폴리곤은 1~10개까지 지정 가능합니다")
|
||||
@Valid
|
||||
@Schema(description = "탐색 대상 폴리곤 영역 목록 (1~10개)", requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private List<SearchPolygon> polygons;
|
||||
|
||||
@Schema(description = "검색 모드 (폴리곤이 2개 이상일 때 적용)")
|
||||
public enum SearchMode {
|
||||
@Schema(description = "합집합: 어느 한 영역이라도 통과한 선박")
|
||||
ANY,
|
||||
@Schema(description = "교집합: 모든 영역을 통과한 선박")
|
||||
ALL,
|
||||
@Schema(description = "순차 통과: 모든 영역을 지정된 순서대로 통과한 선박")
|
||||
SEQUENTIAL
|
||||
}
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Schema(description = "탐색 대상 폴리곤 영역")
|
||||
public static class SearchPolygon {
|
||||
|
||||
@Schema(description = "클라이언트 지정 영역 식별자", example = "zone_A")
|
||||
private String id;
|
||||
|
||||
@Schema(description = "영역 표시명", example = "대한해협 서수도")
|
||||
private String name;
|
||||
|
||||
@NotNull(message = "폴리곤 좌표는 필수입니다")
|
||||
@Size(min = 4, message = "폴리곤은 최소 4개 좌표 필요 (첫점==끝점)")
|
||||
@Schema(description = "폴리곤 좌표 배열 [[lon,lat],...] 첫점과 끝점이 동일해야 함",
|
||||
example = "[[128.5,34.0],[129.5,34.0],[129.5,35.0],[128.5,35.0],[128.5,34.0]]",
|
||||
requiredMode = Schema.RequiredMode.REQUIRED)
|
||||
private List<double[]> coordinates;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,80 @@
|
||||
package gc.mda.signal_batch.domain.gis.dto;
|
||||
|
||||
import gc.mda.signal_batch.domain.vessel.dto.CompactVesselTrack;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import lombok.AllArgsConstructor;
|
||||
import lombok.Builder;
|
||||
import lombok.Data;
|
||||
import lombok.NoArgsConstructor;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Schema(description = "다중 폴리곤 영역 탐색 응답")
|
||||
public class AreaSearchResponse {
|
||||
|
||||
@Schema(description = "히트된 선박의 전체 기간 항적 (기존 V2 API와 동일한 CompactVesselTrack)")
|
||||
private List<CompactVesselTrack> tracks;
|
||||
|
||||
@Schema(description = "선박별 영역 히트 메타데이터 (key: vesselId)")
|
||||
private Map<String, List<PolygonHitDetail>> hitDetails;
|
||||
|
||||
@Schema(description = "검색 요약 정보")
|
||||
private AreaSearchSummary summary;
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Schema(description = "폴리곤별 히트 상세 정보")
|
||||
public static class PolygonHitDetail {
|
||||
|
||||
@Schema(description = "폴리곤 식별자", example = "zone_A")
|
||||
private String polygonId;
|
||||
|
||||
@Schema(description = "폴리곤 표시명", example = "대한해협 서수도")
|
||||
private String polygonName;
|
||||
|
||||
@Schema(description = "영역 첫 진입 Unix timestamp (초)", example = "1738368000")
|
||||
private Long entryTimestamp;
|
||||
|
||||
@Schema(description = "영역 마지막 진출 Unix timestamp (초)", example = "1738382400")
|
||||
private Long exitTimestamp;
|
||||
|
||||
@Schema(description = "영역 내 포인트 수", example = "45")
|
||||
private Integer hitPointCount;
|
||||
}
|
||||
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Schema(description = "영역 탐색 요약 정보")
|
||||
public static class AreaSearchSummary {
|
||||
|
||||
@Schema(description = "히트된 선박 수", example = "12")
|
||||
private Integer totalVessels;
|
||||
|
||||
@Schema(description = "히트된 전체 포인트 수", example = "4560")
|
||||
private Long totalPoints;
|
||||
|
||||
@Schema(description = "적용된 검색 모드", example = "SEQUENTIAL")
|
||||
private AreaSearchRequest.SearchMode mode;
|
||||
|
||||
@Schema(description = "검색 대상 폴리곤 ID 목록", example = "[\"zone_A\", \"zone_B\"]")
|
||||
private List<String> polygonIds;
|
||||
|
||||
@Schema(description = "처리 소요 시간 (ms)", example = "1250")
|
||||
private Long processingTimeMs;
|
||||
|
||||
@Schema(description = "조회에 사용된 캐시 날짜 목록", example = "[\"2026-02-01\", \"2026-02-02\"]")
|
||||
private List<String> cachedDates;
|
||||
|
||||
@Schema(description = "캐시된 전체 선박 수", example = "285000")
|
||||
private Integer totalCachedVessels;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,481 @@
|
||||
package gc.mda.signal_batch.domain.gis.service;
|
||||
|
||||
import gc.mda.signal_batch.domain.gis.dto.AreaSearchRequest;
|
||||
import gc.mda.signal_batch.domain.gis.dto.AreaSearchRequest.SearchMode;
|
||||
import gc.mda.signal_batch.domain.gis.dto.AreaSearchRequest.SearchPolygon;
|
||||
import gc.mda.signal_batch.domain.gis.dto.AreaSearchResponse;
|
||||
import gc.mda.signal_batch.domain.gis.dto.AreaSearchResponse.AreaSearchSummary;
|
||||
import gc.mda.signal_batch.domain.gis.dto.AreaSearchResponse.PolygonHitDetail;
|
||||
import gc.mda.signal_batch.domain.vessel.dto.CompactVesselTrack;
|
||||
import gc.mda.signal_batch.global.websocket.service.DailyTrackCacheManager;
|
||||
import gc.mda.signal_batch.global.websocket.service.DailyTrackCacheManager.DailyTrackData;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.locationtech.jts.geom.*;
|
||||
import org.locationtech.jts.geom.prep.PreparedGeometry;
|
||||
import org.locationtech.jts.geom.prep.PreparedGeometryFactory;
|
||||
import org.locationtech.jts.index.strtree.STRtree;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.*;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class AreaSearchService {
|
||||
|
||||
private final DailyTrackCacheManager cacheManager;
|
||||
private static final GeometryFactory GEOMETRY_FACTORY = new GeometryFactory();
|
||||
|
||||
/**
|
||||
* 다중 폴리곤 영역 탐색 메인 엔트리
|
||||
*/
|
||||
public AreaSearchResponse search(AreaSearchRequest request) {
|
||||
long startMs = System.currentTimeMillis();
|
||||
|
||||
// 1. 입력 검증
|
||||
validateRequest(request);
|
||||
|
||||
// 2. 캐시 데이터 수집 (날짜 범위)
|
||||
List<LocalDate> targetDates = collectTargetDates(request.getStartTime(), request.getEndTime());
|
||||
if (targetDates.isEmpty()) {
|
||||
return buildEmptyResponse(request, startMs);
|
||||
}
|
||||
|
||||
// 3. 다일 데이터 → 선박별 단일 트랙 병합
|
||||
Map<String, CompactVesselTrack> mergedTracks = mergeMultipleDays(targetDates);
|
||||
if (mergedTracks.isEmpty()) {
|
||||
return buildEmptyResponse(request, startMs);
|
||||
}
|
||||
|
||||
// 4. 좌표 → JTS Polygon 변환
|
||||
List<Polygon> jtsPolygons = convertToJtsPolygons(request.getPolygons());
|
||||
|
||||
// 5. 병합된 트랙으로 STRtree 빌드
|
||||
STRtree spatialIndex = buildSpatialIndex(mergedTracks);
|
||||
|
||||
// 6. 각 폴리곤별 히트 선박 + 타임스탬프 수집
|
||||
List<Map<String, PolygonHitDetail>> perPolygonHits = new ArrayList<>();
|
||||
for (int i = 0; i < jtsPolygons.size(); i++) {
|
||||
Polygon polygon = jtsPolygons.get(i);
|
||||
SearchPolygon searchPolygon = request.getPolygons().get(i);
|
||||
Map<String, PolygonHitDetail> hits = findHitsForPolygon(
|
||||
polygon, searchPolygon, mergedTracks, spatialIndex);
|
||||
perPolygonHits.add(hits);
|
||||
}
|
||||
|
||||
// 7. 모드별 결과 합산
|
||||
SearchMode mode = request.getPolygons().size() == 1 ? SearchMode.ANY : request.getMode();
|
||||
Map<String, List<PolygonHitDetail>> resultHits;
|
||||
switch (mode) {
|
||||
case ALL:
|
||||
resultHits = processAllMode(perPolygonHits);
|
||||
break;
|
||||
case SEQUENTIAL:
|
||||
resultHits = processSequentialMode(perPolygonHits);
|
||||
break;
|
||||
default:
|
||||
resultHits = processAnyMode(perPolygonHits);
|
||||
break;
|
||||
}
|
||||
|
||||
// 8. 결과 선박의 전체 기간 트랙 + 히트 메타 반환
|
||||
List<CompactVesselTrack> resultTracks = resultHits.keySet().stream()
|
||||
.map(mergedTracks::get)
|
||||
.filter(Objects::nonNull)
|
||||
.collect(Collectors.toList());
|
||||
|
||||
long totalPoints = resultHits.values().stream()
|
||||
.flatMap(Collection::stream)
|
||||
.mapToLong(h -> h.getHitPointCount() != null ? h.getHitPointCount() : 0)
|
||||
.sum();
|
||||
|
||||
int totalCachedVessels = targetDates.stream()
|
||||
.mapToInt(d -> {
|
||||
DailyTrackData data = cacheManager.getDailyTrackData(d);
|
||||
return data != null ? data.getVesselCount() : 0;
|
||||
})
|
||||
.sum();
|
||||
|
||||
long elapsedMs = System.currentTimeMillis() - startMs;
|
||||
log.info("Area search completed: mode={}, polygons={}, hitVessels={}, totalPoints={}, elapsed={}ms",
|
||||
mode, request.getPolygons().size(), resultHits.size(), totalPoints, elapsedMs);
|
||||
|
||||
return AreaSearchResponse.builder()
|
||||
.tracks(resultTracks)
|
||||
.hitDetails(resultHits)
|
||||
.summary(AreaSearchSummary.builder()
|
||||
.totalVessels(resultHits.size())
|
||||
.totalPoints(totalPoints)
|
||||
.mode(mode)
|
||||
.polygonIds(request.getPolygons().stream()
|
||||
.map(SearchPolygon::getId)
|
||||
.collect(Collectors.toList()))
|
||||
.processingTimeMs(elapsedMs)
|
||||
.cachedDates(targetDates.stream()
|
||||
.map(LocalDate::toString)
|
||||
.collect(Collectors.toList()))
|
||||
.totalCachedVessels(totalCachedVessels)
|
||||
.build())
|
||||
.build();
|
||||
}
|
||||
|
||||
// ── 입력 검증 ──
|
||||
|
||||
private void validateRequest(AreaSearchRequest request) {
|
||||
if (request.getStartTime().isAfter(request.getEndTime())) {
|
||||
throw new IllegalArgumentException("시작 시간이 종료 시간보다 나중입니다");
|
||||
}
|
||||
|
||||
DailyTrackCacheManager.CacheStatus cacheStatus = cacheManager.getStatus();
|
||||
if (cacheStatus == DailyTrackCacheManager.CacheStatus.LOADING
|
||||
|| cacheStatus == DailyTrackCacheManager.CacheStatus.NOT_STARTED) {
|
||||
throw new CacheNotReadyException("캐시가 아직 준비되지 않았습니다 (상태: " + cacheStatus + ")");
|
||||
}
|
||||
|
||||
for (SearchPolygon polygon : request.getPolygons()) {
|
||||
validatePolygon(polygon);
|
||||
}
|
||||
}
|
||||
|
||||
private void validatePolygon(SearchPolygon polygon) {
|
||||
List<double[]> coords = polygon.getCoordinates();
|
||||
if (coords == null || coords.size() < 4) {
|
||||
throw new IllegalArgumentException(
|
||||
"폴리곤 '" + polygon.getId() + "'은 최소 4개 좌표가 필요합니다 (첫점==끝점)");
|
||||
}
|
||||
|
||||
double[] first = coords.get(0);
|
||||
double[] last = coords.get(coords.size() - 1);
|
||||
if (first[0] != last[0] || first[1] != last[1]) {
|
||||
throw new IllegalArgumentException(
|
||||
"폴리곤 '" + polygon.getId() + "'의 첫점과 끝점이 동일해야 합니다");
|
||||
}
|
||||
|
||||
// JTS로 유효성 검사
|
||||
try {
|
||||
Polygon jtsPolygon = toJtsPolygon(coords);
|
||||
if (!jtsPolygon.isValid()) {
|
||||
throw new IllegalArgumentException(
|
||||
"폴리곤 '" + polygon.getId() + "'이 유효하지 않습니다 (자기 교차 등)");
|
||||
}
|
||||
} catch (IllegalArgumentException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
throw new IllegalArgumentException(
|
||||
"폴리곤 '" + polygon.getId() + "' 변환 실패: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// ── 날짜 수집 ──
|
||||
|
||||
private List<LocalDate> collectTargetDates(LocalDateTime startTime, LocalDateTime endTime) {
|
||||
LocalDate today = LocalDate.now();
|
||||
LocalDate startDate = startTime.toLocalDate();
|
||||
LocalDate endDate = endTime.toLocalDate();
|
||||
List<LocalDate> dates = new ArrayList<>();
|
||||
|
||||
for (LocalDate d = startDate; !d.isAfter(endDate); d = d.plusDays(1)) {
|
||||
if (d.isAfter(today) || d.equals(today)) {
|
||||
// 오늘/미래 데이터는 캐시에 없음 (경고 로그)
|
||||
if (d.equals(today)) {
|
||||
log.warn("Area search: 오늘({}) 데이터는 캐시에 없어 제외됩니다", today);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (cacheManager.isCached(d)) {
|
||||
dates.add(d);
|
||||
} else {
|
||||
log.debug("Area search: 날짜 {}가 캐시에 없어 제외됩니다", d);
|
||||
}
|
||||
}
|
||||
|
||||
return dates;
|
||||
}
|
||||
|
||||
// ── JTS 변환 ──
|
||||
|
||||
List<Polygon> convertToJtsPolygons(List<SearchPolygon> searchPolygons) {
|
||||
return searchPolygons.stream()
|
||||
.map(sp -> toJtsPolygon(sp.getCoordinates()))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
|
||||
private Polygon toJtsPolygon(List<double[]> coordinates) {
|
||||
Coordinate[] coords = new Coordinate[coordinates.size()];
|
||||
for (int i = 0; i < coordinates.size(); i++) {
|
||||
double[] c = coordinates.get(i);
|
||||
coords[i] = new Coordinate(c[0], c[1]);
|
||||
}
|
||||
return GEOMETRY_FACTORY.createPolygon(coords);
|
||||
}
|
||||
|
||||
// ── 다일 데이터 병합 ──
|
||||
|
||||
Map<String, CompactVesselTrack> mergeMultipleDays(List<LocalDate> dates) {
|
||||
Map<String, List<CompactVesselTrack>> byVessel = new HashMap<>();
|
||||
|
||||
for (LocalDate date : dates) {
|
||||
DailyTrackData data = cacheManager.getDailyTrackData(date);
|
||||
if (data == null) continue;
|
||||
|
||||
for (Map.Entry<String, CompactVesselTrack> entry : data.getTracks().entrySet()) {
|
||||
byVessel.computeIfAbsent(entry.getKey(), k -> new ArrayList<>())
|
||||
.add(entry.getValue());
|
||||
}
|
||||
}
|
||||
|
||||
Map<String, CompactVesselTrack> merged = new HashMap<>(byVessel.size());
|
||||
for (Map.Entry<String, List<CompactVesselTrack>> entry : byVessel.entrySet()) {
|
||||
List<CompactVesselTrack> trackList = entry.getValue();
|
||||
if (trackList.size() == 1) {
|
||||
merged.put(entry.getKey(), trackList.get(0));
|
||||
continue;
|
||||
}
|
||||
|
||||
// 여러 날짜 병합
|
||||
CompactVesselTrack first = trackList.get(0);
|
||||
List<double[]> geo = new ArrayList<>();
|
||||
List<String> ts = new ArrayList<>();
|
||||
List<Double> sp = new ArrayList<>();
|
||||
double totalDist = 0;
|
||||
double maxSpeed = 0;
|
||||
int pointCount = 0;
|
||||
|
||||
for (CompactVesselTrack t : trackList) {
|
||||
if (t.getGeometry() != null) geo.addAll(t.getGeometry());
|
||||
if (t.getTimestamps() != null) ts.addAll(t.getTimestamps());
|
||||
if (t.getSpeeds() != null) sp.addAll(t.getSpeeds());
|
||||
if (t.getTotalDistance() != null) totalDist += t.getTotalDistance();
|
||||
if (t.getMaxSpeed() != null) maxSpeed = Math.max(maxSpeed, t.getMaxSpeed());
|
||||
if (t.getPointCount() != null) pointCount += t.getPointCount();
|
||||
}
|
||||
|
||||
merged.put(entry.getKey(), CompactVesselTrack.builder()
|
||||
.vesselId(first.getVesselId())
|
||||
.sigSrcCd(first.getSigSrcCd())
|
||||
.targetId(first.getTargetId())
|
||||
.nationalCode(first.getNationalCode())
|
||||
.shipName(first.getShipName())
|
||||
.shipType(first.getShipType())
|
||||
.shipKindCode(first.getShipKindCode())
|
||||
.integrationTargetId(first.getIntegrationTargetId())
|
||||
.geometry(geo)
|
||||
.timestamps(ts)
|
||||
.speeds(sp)
|
||||
.totalDistance(totalDist)
|
||||
.avgSpeed(pointCount > 0 ? totalDist / Math.max(1, pointCount) * 60 : 0)
|
||||
.maxSpeed(maxSpeed)
|
||||
.pointCount(pointCount)
|
||||
.build());
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
// ── STRtree 빌드 ──
|
||||
|
||||
private STRtree buildSpatialIndex(Map<String, CompactVesselTrack> tracks) {
|
||||
STRtree tree = new STRtree();
|
||||
for (Map.Entry<String, CompactVesselTrack> entry : tracks.entrySet()) {
|
||||
CompactVesselTrack track = entry.getValue();
|
||||
if (track.getGeometry() == null || track.getGeometry().isEmpty()) continue;
|
||||
|
||||
double minLon = Double.MAX_VALUE, maxLon = -Double.MAX_VALUE;
|
||||
double minLat = Double.MAX_VALUE, maxLat = -Double.MAX_VALUE;
|
||||
for (double[] coord : track.getGeometry()) {
|
||||
if (coord[0] < minLon) minLon = coord[0];
|
||||
if (coord[0] > maxLon) maxLon = coord[0];
|
||||
if (coord[1] < minLat) minLat = coord[1];
|
||||
if (coord[1] > maxLat) maxLat = coord[1];
|
||||
}
|
||||
tree.insert(new Envelope(minLon, maxLon, minLat, maxLat), entry.getKey());
|
||||
}
|
||||
tree.build();
|
||||
return tree;
|
||||
}
|
||||
|
||||
// ── 폴리곤별 히트 검색 ──
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
Map<String, PolygonHitDetail> findHitsForPolygon(
|
||||
Polygon polygon, SearchPolygon searchPolygon,
|
||||
Map<String, CompactVesselTrack> tracks, STRtree spatialIndex) {
|
||||
|
||||
PreparedGeometry prepared = PreparedGeometryFactory.prepare(polygon);
|
||||
Envelope mbr = polygon.getEnvelopeInternal();
|
||||
|
||||
// STRtree 후보 추출
|
||||
List<String> candidates = spatialIndex.query(mbr);
|
||||
|
||||
Map<String, PolygonHitDetail> hits = new HashMap<>();
|
||||
for (String vesselId : candidates) {
|
||||
CompactVesselTrack track = tracks.get(vesselId);
|
||||
if (track == null) continue;
|
||||
|
||||
PolygonHitDetail hit = checkTrackAgainstPolygon(track, prepared, searchPolygon);
|
||||
if (hit != null) {
|
||||
hits.put(vesselId, hit);
|
||||
}
|
||||
}
|
||||
|
||||
return hits;
|
||||
}
|
||||
|
||||
/**
|
||||
* 정밀 point-in-polygon 검사: 트랙의 각 좌표를 폴리곤과 비교
|
||||
*/
|
||||
private PolygonHitDetail checkTrackAgainstPolygon(
|
||||
CompactVesselTrack track, PreparedGeometry prepared, SearchPolygon searchPolygon) {
|
||||
|
||||
List<double[]> geometry = track.getGeometry();
|
||||
List<String> timestamps = track.getTimestamps();
|
||||
if (geometry == null || geometry.isEmpty()) return null;
|
||||
|
||||
Long entryTimestamp = null;
|
||||
Long exitTimestamp = null;
|
||||
int hitCount = 0;
|
||||
|
||||
for (int i = 0; i < geometry.size(); i++) {
|
||||
double[] coord = geometry.get(i);
|
||||
Point point = GEOMETRY_FACTORY.createPoint(new Coordinate(coord[0], coord[1]));
|
||||
|
||||
if (prepared.contains(point)) {
|
||||
hitCount++;
|
||||
long ts = parseTimestamp(timestamps, i);
|
||||
if (entryTimestamp == null || ts < entryTimestamp) {
|
||||
entryTimestamp = ts;
|
||||
}
|
||||
if (exitTimestamp == null || ts > exitTimestamp) {
|
||||
exitTimestamp = ts;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hitCount == 0) return null;
|
||||
|
||||
return PolygonHitDetail.builder()
|
||||
.polygonId(searchPolygon.getId())
|
||||
.polygonName(searchPolygon.getName())
|
||||
.entryTimestamp(entryTimestamp)
|
||||
.exitTimestamp(exitTimestamp)
|
||||
.hitPointCount(hitCount)
|
||||
.build();
|
||||
}
|
||||
|
||||
private long parseTimestamp(List<String> timestamps, int index) {
|
||||
if (timestamps == null || index >= timestamps.size()) return 0L;
|
||||
try {
|
||||
return Long.parseLong(timestamps.get(index));
|
||||
} catch (NumberFormatException e) {
|
||||
return 0L;
|
||||
}
|
||||
}
|
||||
|
||||
// ── 모드별 결과 처리 ──
|
||||
|
||||
/**
|
||||
* ANY 모드: 합집합 (어느 영역이든 통과한 선박)
|
||||
*/
|
||||
Map<String, List<PolygonHitDetail>> processAnyMode(
|
||||
List<Map<String, PolygonHitDetail>> perPolygonHits) {
|
||||
|
||||
Map<String, List<PolygonHitDetail>> result = new HashMap<>();
|
||||
for (Map<String, PolygonHitDetail> polygonHits : perPolygonHits) {
|
||||
for (Map.Entry<String, PolygonHitDetail> entry : polygonHits.entrySet()) {
|
||||
result.computeIfAbsent(entry.getKey(), k -> new ArrayList<>())
|
||||
.add(entry.getValue());
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* ALL 모드: 교집합 (모든 영역을 통과한 선박)
|
||||
*/
|
||||
Map<String, List<PolygonHitDetail>> processAllMode(
|
||||
List<Map<String, PolygonHitDetail>> perPolygonHits) {
|
||||
|
||||
if (perPolygonHits.isEmpty()) return Collections.emptyMap();
|
||||
|
||||
// 모든 폴리곤에 공통으로 존재하는 vesselId 찾기
|
||||
Set<String> commonVessels = new HashSet<>(perPolygonHits.get(0).keySet());
|
||||
for (int i = 1; i < perPolygonHits.size(); i++) {
|
||||
commonVessels.retainAll(perPolygonHits.get(i).keySet());
|
||||
}
|
||||
|
||||
Map<String, List<PolygonHitDetail>> result = new HashMap<>();
|
||||
for (String vesselId : commonVessels) {
|
||||
List<PolygonHitDetail> hits = new ArrayList<>();
|
||||
for (Map<String, PolygonHitDetail> polygonHits : perPolygonHits) {
|
||||
hits.add(polygonHits.get(vesselId));
|
||||
}
|
||||
result.put(vesselId, hits);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* SEQUENTIAL 모드: 교집합 + entryTimestamp 순서 검증
|
||||
*/
|
||||
Map<String, List<PolygonHitDetail>> processSequentialMode(
|
||||
List<Map<String, PolygonHitDetail>> perPolygonHits) {
|
||||
|
||||
// 먼저 ALL 모드로 교집합 구함
|
||||
Map<String, List<PolygonHitDetail>> allHits = processAllMode(perPolygonHits);
|
||||
|
||||
// 순서 검증: 각 선박에 대해 polygon 순서대로 entryTimestamp 증가 확인
|
||||
Map<String, List<PolygonHitDetail>> result = new HashMap<>();
|
||||
for (Map.Entry<String, List<PolygonHitDetail>> entry : allHits.entrySet()) {
|
||||
List<PolygonHitDetail> hits = entry.getValue();
|
||||
if (isSequentialOrder(hits)) {
|
||||
result.put(entry.getKey(), hits);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private boolean isSequentialOrder(List<PolygonHitDetail> hits) {
|
||||
for (int i = 1; i < hits.size(); i++) {
|
||||
Long prevEntry = hits.get(i - 1).getEntryTimestamp();
|
||||
Long currEntry = hits.get(i).getEntryTimestamp();
|
||||
if (prevEntry == null || currEntry == null) return false;
|
||||
if (currEntry <= prevEntry) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
// ── 빈 응답 ──
|
||||
|
||||
private AreaSearchResponse buildEmptyResponse(AreaSearchRequest request, long startMs) {
|
||||
long elapsedMs = System.currentTimeMillis() - startMs;
|
||||
SearchMode mode = request.getPolygons().size() == 1 ? SearchMode.ANY : request.getMode();
|
||||
|
||||
return AreaSearchResponse.builder()
|
||||
.tracks(Collections.emptyList())
|
||||
.hitDetails(Collections.emptyMap())
|
||||
.summary(AreaSearchSummary.builder()
|
||||
.totalVessels(0)
|
||||
.totalPoints(0L)
|
||||
.mode(mode)
|
||||
.polygonIds(request.getPolygons().stream()
|
||||
.map(SearchPolygon::getId)
|
||||
.collect(Collectors.toList()))
|
||||
.processingTimeMs(elapsedMs)
|
||||
.cachedDates(Collections.emptyList())
|
||||
.totalCachedVessels(0)
|
||||
.build())
|
||||
.build();
|
||||
}
|
||||
|
||||
// ── 예외 클래스 ──
|
||||
|
||||
public static class CacheNotReadyException extends RuntimeException {
|
||||
public CacheNotReadyException(String message) {
|
||||
super(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -8,7 +8,9 @@ import gc.mda.signal_batch.domain.vessel.dto.IntegrationVessel;
|
||||
import gc.mda.signal_batch.global.util.IntegrationSignalConstants;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.locationtech.jts.geom.Coordinate;
|
||||
import org.locationtech.jts.geom.Envelope;
|
||||
import org.locationtech.jts.geom.LineString;
|
||||
import org.locationtech.jts.index.strtree.STRtree;
|
||||
import org.locationtech.jts.io.ParseException;
|
||||
import org.locationtech.jts.io.WKTReader;
|
||||
import org.springframework.beans.factory.annotation.Qualifier;
|
||||
@ -75,13 +77,19 @@ public class DailyTrackCacheManager {
|
||||
private final long loadedAtMillis;
|
||||
private final int vesselCount;
|
||||
private final long memorySizeBytes;
|
||||
private final STRtree spatialIndex;
|
||||
|
||||
public DailyTrackData(LocalDate date, Map<String, CompactVesselTrack> tracks, long memorySizeBytes) {
|
||||
this(date, tracks, memorySizeBytes, null);
|
||||
}
|
||||
|
||||
public DailyTrackData(LocalDate date, Map<String, CompactVesselTrack> tracks, long memorySizeBytes, STRtree spatialIndex) {
|
||||
this.date = date;
|
||||
this.tracks = tracks;
|
||||
this.loadedAtMillis = System.currentTimeMillis();
|
||||
this.vesselCount = tracks.size();
|
||||
this.memorySizeBytes = memorySizeBytes;
|
||||
this.spatialIndex = spatialIndex;
|
||||
}
|
||||
|
||||
public LocalDate getDate() { return date; }
|
||||
@ -89,6 +97,7 @@ public class DailyTrackCacheManager {
|
||||
public long getLoadedAtMillis() { return loadedAtMillis; }
|
||||
public int getVesselCount() { return vesselCount; }
|
||||
public long getMemorySizeBytes() { return memorySizeBytes; }
|
||||
public STRtree getSpatialIndex() { return spatialIndex; }
|
||||
}
|
||||
|
||||
/**
|
||||
@ -341,7 +350,12 @@ public class DailyTrackCacheManager {
|
||||
}
|
||||
|
||||
estimatedMemory += tracks.size() * 200L; // 객체 오버헤드
|
||||
return new DailyTrackData(date, tracks, estimatedMemory);
|
||||
|
||||
// STRtree 공간 인덱스 빌드
|
||||
STRtree spatialIndex = buildSpatialIndex(tracks);
|
||||
estimatedMemory += tracks.size() * 100L; // 인덱스 오버헤드
|
||||
|
||||
return new DailyTrackData(date, tracks, estimatedMemory, spatialIndex);
|
||||
}
|
||||
|
||||
// ── 캐시 조회 API ──
|
||||
@ -590,6 +604,22 @@ public class DailyTrackCacheManager {
|
||||
return cacheProperties.isEnabled();
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 날짜의 DailyTrackData 직접 접근 (STRtree 포함)
|
||||
*/
|
||||
public DailyTrackData getDailyTrackData(LocalDate date) {
|
||||
return cache.get(date);
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 캐시된 날짜 목록 반환
|
||||
*/
|
||||
public List<LocalDate> getCachedDateList() {
|
||||
List<LocalDate> dates = new ArrayList<>(cache.keySet());
|
||||
Collections.sort(dates);
|
||||
return dates;
|
||||
}
|
||||
|
||||
// ── 내부 유틸 ──
|
||||
|
||||
private boolean isInViewport(CompactVesselTrack track, double minLon, double minLat, double maxLon, double maxLat) {
|
||||
@ -602,6 +632,29 @@ public class DailyTrackCacheManager {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 항적 맵에서 STRtree 공간 인덱스 빌드
|
||||
*/
|
||||
private STRtree buildSpatialIndex(Map<String, CompactVesselTrack> tracks) {
|
||||
STRtree tree = new STRtree();
|
||||
for (Map.Entry<String, CompactVesselTrack> entry : tracks.entrySet()) {
|
||||
CompactVesselTrack track = entry.getValue();
|
||||
if (track.getGeometry() == null || track.getGeometry().isEmpty()) continue;
|
||||
|
||||
double minLon = Double.MAX_VALUE, maxLon = -Double.MAX_VALUE;
|
||||
double minLat = Double.MAX_VALUE, maxLat = -Double.MAX_VALUE;
|
||||
for (double[] coord : track.getGeometry()) {
|
||||
if (coord[0] < minLon) minLon = coord[0];
|
||||
if (coord[0] > maxLon) maxLon = coord[0];
|
||||
if (coord[1] < minLat) minLat = coord[1];
|
||||
if (coord[1] > maxLat) maxLat = coord[1];
|
||||
}
|
||||
tree.insert(new Envelope(minLon, maxLon, minLat, maxLat), entry.getKey());
|
||||
}
|
||||
tree.build();
|
||||
return tree;
|
||||
}
|
||||
|
||||
/**
|
||||
* 선박 데이터 누적용 내부 클래스
|
||||
*/
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user