From a2efe1a751c907df5f92665f31d630388def67e2 Mon Sep 17 00:00:00 2001 From: htlee Date: Tue, 17 Mar 2026 10:00:59 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20recent-positions-detail=20API=20+?= =?UTF-8?q?=20AIS=20WebClient=20=EB=B2=84=ED=8D=BC=20100MB=20=ED=99=95?= =?UTF-8?q?=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/gis/controller/GisController.java | 20 ++ .../dto/RecentPositionDetailRequest.java | 61 ++++++ .../dto/RecentPositionDetailResponse.java | 87 ++++++++ .../service/VesselPositionDetailService.java | 189 ++++++++++++++++++ .../global/config/AisApiWebClientConfig.java | 4 +- 5 files changed, 359 insertions(+), 2 deletions(-) create mode 100644 src/main/java/gc/mda/signal_batch/domain/vessel/dto/RecentPositionDetailRequest.java create mode 100644 src/main/java/gc/mda/signal_batch/domain/vessel/dto/RecentPositionDetailResponse.java create mode 100644 src/main/java/gc/mda/signal_batch/domain/vessel/service/VesselPositionDetailService.java diff --git a/src/main/java/gc/mda/signal_batch/domain/gis/controller/GisController.java b/src/main/java/gc/mda/signal_batch/domain/gis/controller/GisController.java index bb00c86..2932b91 100644 --- a/src/main/java/gc/mda/signal_batch/domain/gis/controller/GisController.java +++ b/src/main/java/gc/mda/signal_batch/domain/gis/controller/GisController.java @@ -6,8 +6,11 @@ import gc.mda.signal_batch.domain.vessel.dto.TrackResponse; import gc.mda.signal_batch.domain.vessel.dto.VesselTracksRequest; import gc.mda.signal_batch.domain.vessel.dto.CompactVesselTrack; import gc.mda.signal_batch.domain.vessel.dto.RecentVesselPositionDto; +import gc.mda.signal_batch.domain.vessel.dto.RecentPositionDetailRequest; +import gc.mda.signal_batch.domain.vessel.dto.RecentPositionDetailResponse; import gc.mda.signal_batch.domain.gis.service.GisService; import gc.mda.signal_batch.domain.vessel.service.VesselPositionService; +import gc.mda.signal_batch.domain.vessel.service.VesselPositionDetailService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; @@ -28,6 +31,7 @@ public class GisController { private final GisService gisService; private final VesselPositionService vesselPositionService; + private final VesselPositionDetailService vesselPositionDetailService; @GetMapping("/haegu/boundaries") @Operation(summary = "해구 경계 조회", description = "모든 해구의 경계 정보를 GeoJSON 형식으로 반환") @@ -97,4 +101,20 @@ public class GisController { return vesselPositionService.getRecentVesselPositions(minutes); } + + @PostMapping("/vessels/recent-positions-detail") + @Operation( + summary = "최근 위치 상세 조회 (공간 필터 지원)", + description = "AIS 캐시에서 지정 시간 내 선박의 상세 정보를 공간 필터(폴리곤/원)와 함께 조회합니다. " + + "coordinates(폴리곤)와 center+radiusNm(원) 중 하나를 지정하거나, 둘 다 생략하면 전체 조회합니다." + ) + public List getRecentPositionsDetail( + @RequestBody RecentPositionDetailRequest request) { + + if (request.getMinutes() <= 0 || request.getMinutes() > 1440) { + throw new IllegalArgumentException("Minutes must be between 1 and 1440"); + } + + return vesselPositionDetailService.getRecentPositionsDetail(request); + } } \ No newline at end of file diff --git a/src/main/java/gc/mda/signal_batch/domain/vessel/dto/RecentPositionDetailRequest.java b/src/main/java/gc/mda/signal_batch/domain/vessel/dto/RecentPositionDetailRequest.java new file mode 100644 index 0000000..29536dc --- /dev/null +++ b/src/main/java/gc/mda/signal_batch/domain/vessel/dto/RecentPositionDetailRequest.java @@ -0,0 +1,61 @@ +package gc.mda.signal_batch.domain.vessel.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * 최근 선박 위치 상세 조회 요청 + * + * 공간 필터 사용법: + * - 폴리곤/사각형: coordinates에 닫힌 좌표 배열 전달 + * - 원: center + radiusNm 전달 (서버에서 64점 폴리곤으로 변환) + * - 전체 조회: coordinates와 center 모두 null + */ +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@Schema(description = "최근 선박 위치 상세 조회 요청 (공간 필터 지원)") +public class RecentPositionDetailRequest { + + @Schema(description = "조회 시간 범위 (분 단위, 1~1440)", example = "5") + @Builder.Default + private int minutes = 5; + + @Schema(description = "폴리곤/사각형 좌표 배열 [[lon,lat],...] — 첫점과 끝점 동일", + example = "[[125,33],[130,33],[130,37],[125,37],[125,33]]") + private List coordinates; + + @Schema(description = "원 중심 좌표 [lon, lat]", example = "[129, 35]") + private double[] center; + + @Schema(description = "원 반경 (해리, NM)", example = "50") + private Double radiusNm; + + /** + * 공간 필터가 지정되었는지 확인 + */ + public boolean hasSpatialFilter() { + return (coordinates != null && !coordinates.isEmpty()) + || (center != null && radiusNm != null); + } + + /** + * 원형 필터인지 확인 + */ + public boolean isCircleFilter() { + return center != null && center.length == 2 && radiusNm != null; + } + + /** + * 폴리곤/사각형 필터인지 확인 + */ + public boolean isPolygonFilter() { + return coordinates != null && coordinates.size() >= 4; + } +} diff --git a/src/main/java/gc/mda/signal_batch/domain/vessel/dto/RecentPositionDetailResponse.java b/src/main/java/gc/mda/signal_batch/domain/vessel/dto/RecentPositionDetailResponse.java new file mode 100644 index 0000000..e76bc36 --- /dev/null +++ b/src/main/java/gc/mda/signal_batch/domain/vessel/dto/RecentPositionDetailResponse.java @@ -0,0 +1,87 @@ +package gc.mda.signal_batch.domain.vessel.dto; + +import com.fasterxml.jackson.annotation.JsonFormat; +import com.fasterxml.jackson.annotation.JsonInclude; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.math.BigDecimal; +import java.time.LocalDateTime; + +/** + * 최근 선박 위치 상세 응답 + * + * 기존 RecentVesselPositionDto 전체 필드 + AIS 상세 정보 확장 + */ +@JsonInclude(JsonInclude.Include.NON_NULL) +@Schema(description = "최근 선박 위치 상세 정보 (AIS 확장 필드 포함)") +public record RecentPositionDetailResponse( + + // ── 기존 필드 (RecentVesselPositionDto 호환) ── + + @Schema(description = "MMSI", example = "440113620") + String mmsi, + + @Schema(description = "IMO 번호", example = "9141833") + Long imo, + + @Schema(description = "경도 (WGS84)", example = "127.0638") + Double lon, + + @Schema(description = "위도 (WGS84)", example = "34.227527") + Double lat, + + @Schema(description = "대지속도 (knots)", example = "10.4") + BigDecimal sog, + + @Schema(description = "대지침로 (도)", example = "215.3") + BigDecimal cog, + + @Schema(description = "선박명", example = "SAM SUNG 2HO") + String shipNm, + + @Schema(description = "선박 유형 (AIS ship type)", example = "74") + String shipTy, + + @Schema(description = "선박 종류 코드", example = "000023") + String shipKindCode, + + @Schema(description = "국가 코드 (MID 기반)", example = "KR") + String nationalCode, + + @Schema(description = "최종 업데이트 시간", example = "2026-03-17 12:05:00") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + LocalDateTime lastUpdate, + + @Schema(description = "선박 사진 썸네일 경로") + String shipImagePath, + + @Schema(description = "선박 사진 수") + Integer shipImageCount, + + // ── 확장 필드 (AIS 상세) ── + + @Schema(description = "침로 (0~360도)", example = "215.0") + Double heading, + + @Schema(description = "호출 부호", example = "HLBQ") + String callSign, + + @Schema(description = "항해 상태", example = "Under way using engine") + String status, + + @Schema(description = "목적지", example = "BUSAN") + String destination, + + @Schema(description = "도착 예정시간", example = "2026-03-18 08:00:00") + @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") + LocalDateTime eta, + + @Schema(description = "흘수 (m)", example = "6.5") + Double draught, + + @Schema(description = "선박 길이 (m)", example = "180") + Integer length, + + @Schema(description = "선박 폭 (m)", example = "28") + Integer width +) {} diff --git a/src/main/java/gc/mda/signal_batch/domain/vessel/service/VesselPositionDetailService.java b/src/main/java/gc/mda/signal_batch/domain/vessel/service/VesselPositionDetailService.java new file mode 100644 index 0000000..cd7c2dc --- /dev/null +++ b/src/main/java/gc/mda/signal_batch/domain/vessel/service/VesselPositionDetailService.java @@ -0,0 +1,189 @@ +package gc.mda.signal_batch.domain.vessel.service; + +import gc.mda.signal_batch.batch.reader.AisTargetCacheManager; +import gc.mda.signal_batch.domain.ship.service.ShipImageService; +import gc.mda.signal_batch.domain.ship.service.ShipImageService.ShipImageSummary; +import gc.mda.signal_batch.domain.vessel.dto.RecentPositionDetailRequest; +import gc.mda.signal_batch.domain.vessel.dto.RecentPositionDetailResponse; +import gc.mda.signal_batch.domain.vessel.model.AisTargetEntity; +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.springframework.stereotype.Service; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.time.LocalDateTime; +import java.time.OffsetDateTime; +import java.time.ZoneId; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +/** + * 최근 선박 위치 상세 조회 서비스 + * + * AisTargetCacheManager(~33K, 1분 갱신)에서 직접 조회하여 + * 시간 필터 + 공간 필터(폴리곤/원) 적용 후 상세 정보 반환 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class VesselPositionDetailService { + + private final AisTargetCacheManager aisTargetCacheManager; + private final ShipImageService shipImageService; + + private static final GeometryFactory GEOMETRY_FACTORY = new GeometryFactory(); + private static final int CIRCLE_POINTS = 64; + private static final double EARTH_RADIUS_NM = 3440.065; + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); + + /** + * 최근 선박 위치 상세 조회 + */ + public List getRecentPositionsDetail(RecentPositionDetailRequest request) { + long startMs = System.currentTimeMillis(); + + Collection allEntities = aisTargetCacheManager.getAllValues(); + OffsetDateTime threshold = OffsetDateTime.now().minusMinutes(request.getMinutes()); + + // 공간 필터 준비 (null이면 전체) + PreparedGeometry spatialFilter = buildSpatialFilter(request); + + // 단일 루프: 시간 필터 + 공간 필터 + 변환 + List results = new ArrayList<>(1000); + Coordinate reusable = new Coordinate(); + + for (AisTargetEntity entity : allEntities) { + // 시간 필터 + if (entity.getMessageTimestamp() == null || entity.getMessageTimestamp().isBefore(threshold)) { + continue; + } + // 위치 필수 + if (entity.getLat() == null || entity.getLon() == null) { + continue; + } + // 공간 필터 + if (spatialFilter != null) { + reusable.x = entity.getLon(); + reusable.y = entity.getLat(); + Point point = GEOMETRY_FACTORY.createPoint(reusable); + if (!spatialFilter.contains(point)) { + continue; + } + } + results.add(toResponse(entity)); + } + + log.debug("recent-positions-detail: {}건 / {}ms (전체: {}, minutes: {})", + results.size(), System.currentTimeMillis() - startMs, + allEntities.size(), request.getMinutes()); + + return results; + } + + /** + * 요청에서 공간 필터(PreparedGeometry) 생성 + */ + private PreparedGeometry buildSpatialFilter(RecentPositionDetailRequest request) { + if (!request.hasSpatialFilter()) { + return null; + } + + Polygon polygon; + if (request.isCircleFilter()) { + polygon = createCirclePolygon( + request.getCenter()[0], request.getCenter()[1], + request.getRadiusNm()); + } else if (request.isPolygonFilter()) { + polygon = createPolygonFromCoordinates(request.getCoordinates()); + } else { + return null; + } + + return PreparedGeometryFactory.prepare(polygon); + } + + /** + * 좌표 배열 → JTS Polygon + */ + private Polygon createPolygonFromCoordinates(List 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); + } + + /** + * 원 → 64점 폴리곤 변환 (equirectangular 근사) + */ + private Polygon createCirclePolygon(double centerLon, double centerLat, double radiusNm) { + double radiusRad = radiusNm / EARTH_RADIUS_NM; + double cosLat = Math.cos(Math.toRadians(centerLat)); + + Coordinate[] coords = new Coordinate[CIRCLE_POINTS + 1]; + for (int i = 0; i < CIRCLE_POINTS; i++) { + double angle = 2.0 * Math.PI * i / CIRCLE_POINTS; + double dLat = Math.toDegrees(radiusRad * Math.cos(angle)); + double dLon = Math.toDegrees(radiusRad * Math.sin(angle) / cosLat); + coords[i] = new Coordinate(centerLon + dLon, centerLat + dLat); + } + coords[CIRCLE_POINTS] = coords[0]; // 닫기 + return GEOMETRY_FACTORY.createPolygon(coords); + } + + /** + * AisTargetEntity → RecentPositionDetailResponse 변환 + */ + private RecentPositionDetailResponse toResponse(AisTargetEntity e) { + String mmsi = e.getMmsi(); + String nationalCode = mmsi != null && mmsi.length() >= 3 ? mmsi.substring(0, 3) : "000"; + String shipKindCode = e.getSignalKindCode() != null ? e.getSignalKindCode() : "000027"; + Long imo = e.getImo() != null && e.getImo() > 0 ? e.getImo() : null; + + // ShipImage enrichment + ShipImageSummary img = shipImageService.getImageSummary(imo); + + return new RecentPositionDetailResponse( + mmsi, + imo, + round6(e.getLon()), + round6(e.getLat()), + scaleDecimal(e.getSog(), 1), + scaleDecimal(e.getCog(), 1), + e.getName(), + e.getVesselType(), + shipKindCode, + nationalCode, + toLocalDateTime(e.getMessageTimestamp()), + img != null ? img.thumbnailPath() : null, + img != null ? img.imageCount() : null, + // 확장 필드 + e.getHeading(), + e.getCallsign(), + e.getStatus(), + e.getDestination(), + toLocalDateTime(e.getEta()), + e.getDraught(), + e.getLength(), + e.getWidth() + ); + } + + private static Double round6(Double value) { + return value != null ? Math.round(value * 1_000_000) / 1_000_000.0 : null; + } + + private static BigDecimal scaleDecimal(Double value, int scale) { + return value != null ? BigDecimal.valueOf(value).setScale(scale, RoundingMode.HALF_UP) : null; + } + + private static LocalDateTime toLocalDateTime(OffsetDateTime odt) { + return odt != null ? odt.atZoneSameInstant(KST).toLocalDateTime() : null; + } +} diff --git a/src/main/java/gc/mda/signal_batch/global/config/AisApiWebClientConfig.java b/src/main/java/gc/mda/signal_batch/global/config/AisApiWebClientConfig.java index da47f31..f0e2c76 100644 --- a/src/main/java/gc/mda/signal_batch/global/config/AisApiWebClientConfig.java +++ b/src/main/java/gc/mda/signal_batch/global/config/AisApiWebClientConfig.java @@ -12,7 +12,7 @@ import org.springframework.web.reactive.function.client.WebClient; * * API: POST /AisSvc.svc/AIS/GetTargetsEnhanced * 인증: Basic Authentication - * 버퍼: 50MB (AIS GetTargets 응답 ~20MB+) + * 버퍼: 100MB (AIS GetTargets 응답 ~20MB+, 피크 시 50MB 초과 대응) */ @Slf4j @Configuration @@ -37,7 +37,7 @@ public class AisApiWebClientConfig { .defaultHeaders(headers -> headers.setBasicAuth(aisApiUsername, aisApiPassword)) .codecs(configurer -> configurer .defaultCodecs() - .maxInMemorySize(50 * 1024 * 1024)) + .maxInMemorySize(100 * 1024 * 1024)) .build(); } } -- 2.45.2 From 01ea6a2ec2a4bebb964d49203ba25ca6bd4da168 Mon Sep 17 00:00:00 2001 From: htlee Date: Tue, 17 Mar 2026 10:01:24 +0900 Subject: [PATCH 2/2] =?UTF-8?q?docs:=20=EB=A6=B4=EB=A6=AC=EC=A6=88=20?= =?UTF-8?q?=EB=85=B8=ED=8A=B8=20=EC=97=85=EB=8D=B0=EC=9D=B4=ED=8A=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/RELEASE-NOTES.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index d731ac4..f24f8e4 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -4,6 +4,12 @@ ## [Unreleased] +### 추가 +- 최근 선박 위치 상세 조회 API (`POST /api/v1/vessels/recent-positions-detail`) — 공간 필터(폴리곤/원) + AIS 상세 필드(callSign, status, destination, eta, draught, length, width) + +### 변경 +- AIS API WebClient 버퍼 50MB→100MB 확장 — 피크 시 DataBufferLimitException 대응 + ## [2026-03-17.2] ### 수정 -- 2.45.2