release: 2026-03-17.3 (2건 커밋) #110

병합
htlee develop 에서 main 로 2 commits 를 머지했습니다 2026-03-17 10:02:39 +09:00
6개의 변경된 파일367개의 추가작업 그리고 2개의 파일을 삭제

파일 보기

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