feat: recent-positions-detail API + AIS WebClient 버퍼 확장 (#109)
This commit is contained in:
부모
6751c84a0b
커밋
0f14991345
@ -4,6 +4,12 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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]
|
## [2026-03-17.2]
|
||||||
|
|
||||||
### 수정
|
### 수정
|
||||||
|
|||||||
@ -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.VesselTracksRequest;
|
||||||
import gc.mda.signal_batch.domain.vessel.dto.CompactVesselTrack;
|
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.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.gis.service.GisService;
|
||||||
import gc.mda.signal_batch.domain.vessel.service.VesselPositionService;
|
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.Operation;
|
||||||
import io.swagger.v3.oas.annotations.Parameter;
|
import io.swagger.v3.oas.annotations.Parameter;
|
||||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
@ -28,6 +31,7 @@ public class GisController {
|
|||||||
|
|
||||||
private final GisService gisService;
|
private final GisService gisService;
|
||||||
private final VesselPositionService vesselPositionService;
|
private final VesselPositionService vesselPositionService;
|
||||||
|
private final VesselPositionDetailService vesselPositionDetailService;
|
||||||
|
|
||||||
@GetMapping("/haegu/boundaries")
|
@GetMapping("/haegu/boundaries")
|
||||||
@Operation(summary = "해구 경계 조회", description = "모든 해구의 경계 정보를 GeoJSON 형식으로 반환")
|
@Operation(summary = "해구 경계 조회", description = "모든 해구의 경계 정보를 GeoJSON 형식으로 반환")
|
||||||
@ -97,4 +101,20 @@ public class GisController {
|
|||||||
|
|
||||||
return vesselPositionService.getRecentVesselPositions(minutes);
|
return vesselPositionService.getRecentVesselPositions(minutes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping("/vessels/recent-positions-detail")
|
||||||
|
@Operation(
|
||||||
|
summary = "최근 위치 상세 조회 (공간 필터 지원)",
|
||||||
|
description = "AIS 캐시에서 지정 시간 내 선박의 상세 정보를 공간 필터(폴리곤/원)와 함께 조회합니다. "
|
||||||
|
+ "coordinates(폴리곤)와 center+radiusNm(원) 중 하나를 지정하거나, 둘 다 생략하면 전체 조회합니다."
|
||||||
|
)
|
||||||
|
public List<RecentPositionDetailResponse> 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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -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<double[]> 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
) {}
|
||||||
@ -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<RecentPositionDetailResponse> getRecentPositionsDetail(RecentPositionDetailRequest request) {
|
||||||
|
long startMs = System.currentTimeMillis();
|
||||||
|
|
||||||
|
Collection<AisTargetEntity> allEntities = aisTargetCacheManager.getAllValues();
|
||||||
|
OffsetDateTime threshold = OffsetDateTime.now().minusMinutes(request.getMinutes());
|
||||||
|
|
||||||
|
// 공간 필터 준비 (null이면 전체)
|
||||||
|
PreparedGeometry spatialFilter = buildSpatialFilter(request);
|
||||||
|
|
||||||
|
// 단일 루프: 시간 필터 + 공간 필터 + 변환
|
||||||
|
List<RecentPositionDetailResponse> 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<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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 원 → 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -12,7 +12,7 @@ import org.springframework.web.reactive.function.client.WebClient;
|
|||||||
*
|
*
|
||||||
* API: POST /AisSvc.svc/AIS/GetTargetsEnhanced
|
* API: POST /AisSvc.svc/AIS/GetTargetsEnhanced
|
||||||
* 인증: Basic Authentication
|
* 인증: Basic Authentication
|
||||||
* 버퍼: 50MB (AIS GetTargets 응답 ~20MB+)
|
* 버퍼: 100MB (AIS GetTargets 응답 ~20MB+, 피크 시 50MB 초과 대응)
|
||||||
*/
|
*/
|
||||||
@Slf4j
|
@Slf4j
|
||||||
@Configuration
|
@Configuration
|
||||||
@ -37,7 +37,7 @@ public class AisApiWebClientConfig {
|
|||||||
.defaultHeaders(headers -> headers.setBasicAuth(aisApiUsername, aisApiPassword))
|
.defaultHeaders(headers -> headers.setBasicAuth(aisApiUsername, aisApiPassword))
|
||||||
.codecs(configurer -> configurer
|
.codecs(configurer -> configurer
|
||||||
.defaultCodecs()
|
.defaultCodecs()
|
||||||
.maxInMemorySize(50 * 1024 * 1024))
|
.maxInMemorySize(100 * 1024 * 1024))
|
||||||
.build();
|
.build();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user