Merge pull request 'release: 2026-03-17.3 (2건 커밋)' (#110) from develop into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 9m44s
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 9m44s
This commit is contained in:
커밋
796bd09f29
@ -4,6 +4,14 @@
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
## [2026-03-17.3]
|
||||
|
||||
### 추가
|
||||
- 최근 선박 위치 상세 조회 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]
|
||||
|
||||
### 수정
|
||||
|
||||
@ -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<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
|
||||
* 인증: 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();
|
||||
}
|
||||
}
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user