From 0cc6e58f9b6bbfb05e4c4cb56b0a3bb668cbafe2 Mon Sep 17 00:00:00 2001 From: htlee Date: Thu, 19 Feb 2026 22:37:03 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20ChnPrmShip=20=EC=A0=84=EC=9A=A9=20DB=20?= =?UTF-8?q?=EC=9D=B4=EB=A0=A5=20+=20API=20enrichment=20+=20ShipImage=20V2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - t_chnprmship_positions 월별 파티션 테이블 (PartitionManager 관리) - ChnPrmShipPositionSyncStep: 5분 Job 편승 캐시→DB 이중 적재 - ChnPrmShip 캐시 TTL 2→7일, 워밍업 소스 전용 DB + t_ais_position 이중화 - tracks/vessels API: includeChnPrmShip=true 시 ChnPrmShipInfo enrichment - ShipImageControllerV2: /api/v2/shipimg/{imo} 추가 - SwaggerConfig: V2 경로 분리 + shipimg V2 그룹 추가 Co-Authored-By: Claude Opus 4.6 --- .../job/ChnPrmShipPositionSyncStepConfig.java | 119 ++++++++++++++++++ .../job/VesselTrackAggregationJobConfig.java | 6 +- .../batch/reader/ChnPrmShipCacheManager.java | 16 +++ .../batch/reader/ChnPrmShipCacheWarmer.java | 79 +++++++++--- .../gis/controller/GisControllerV2.java | 5 + .../domain/gis/service/GisServiceV2.java | 74 ++++++++++- .../controller/ShipImageControllerV2.java | 60 +++++++++ .../domain/vessel/dto/CompactVesselTrack.java | 58 +++++++++ .../vessel/dto/VesselTracksRequest.java | 7 ++ .../global/config/SwaggerConfig.java | 5 +- .../global/util/PartitionManager.java | 21 +++- src/main/resources/application.yml | 6 +- 12 files changed, 427 insertions(+), 29 deletions(-) create mode 100644 src/main/java/gc/mda/signal_batch/batch/job/ChnPrmShipPositionSyncStepConfig.java create mode 100644 src/main/java/gc/mda/signal_batch/domain/ship/controller/ShipImageControllerV2.java diff --git a/src/main/java/gc/mda/signal_batch/batch/job/ChnPrmShipPositionSyncStepConfig.java b/src/main/java/gc/mda/signal_batch/batch/job/ChnPrmShipPositionSyncStepConfig.java new file mode 100644 index 0000000..58ca2d4 --- /dev/null +++ b/src/main/java/gc/mda/signal_batch/batch/job/ChnPrmShipPositionSyncStepConfig.java @@ -0,0 +1,119 @@ +package gc.mda.signal_batch.batch.job; + +import gc.mda.signal_batch.batch.reader.ChnPrmShipCacheManager; +import gc.mda.signal_batch.domain.vessel.model.AisTargetEntity; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.Step; +import org.springframework.batch.core.repository.JobRepository; +import org.springframework.batch.core.step.builder.StepBuilder; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.transaction.PlatformTransactionManager; + +import javax.sql.DataSource; +import java.sql.Timestamp; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +/** + * 5분 집계 Job 편승: ChnPrmShip 캐시 스냅샷 → t_chnprmship_positions INSERT + * + * - ChnPrmShipCacheManager의 전체 스냅샷을 조회 + * - ON CONFLICT (mmsi, message_timestamp) DO NOTHING: 동일 시점 중복 스킵, 새 시점은 이력 추가 + * - 서비스 재시작 시 ChnPrmShipCacheWarmer가 이 테이블에서 워밍업 + */ +@Slf4j +@Configuration +@Profile("!query") +@ConditionalOnProperty(name = "vessel.batch.scheduler.enabled", havingValue = "true", matchIfMissing = true) +public class ChnPrmShipPositionSyncStepConfig { + + private final JobRepository jobRepository; + private final DataSource queryDataSource; + private final PlatformTransactionManager transactionManager; + private final ChnPrmShipCacheManager chnPrmShipCacheManager; + + public ChnPrmShipPositionSyncStepConfig( + JobRepository jobRepository, + @Qualifier("queryDataSource") DataSource queryDataSource, + @Qualifier("queryTransactionManager") PlatformTransactionManager transactionManager, + ChnPrmShipCacheManager chnPrmShipCacheManager) { + this.jobRepository = jobRepository; + this.queryDataSource = queryDataSource; + this.transactionManager = transactionManager; + this.chnPrmShipCacheManager = chnPrmShipCacheManager; + } + + @Bean + public Step chnPrmShipPositionSyncStep() { + return new StepBuilder("chnPrmShipPositionSyncStep", jobRepository) + .tasklet((contribution, chunkContext) -> { + Collection entities = chnPrmShipCacheManager.getAllValues(); + + if (entities.isEmpty()) { + log.debug("ChnPrmShip 캐시에 데이터 없음 — t_chnprmship_positions 동기화 스킵"); + return org.springframework.batch.repeat.RepeatStatus.FINISHED; + } + + JdbcTemplate jdbcTemplate = new JdbcTemplate(queryDataSource); + + String sql = """ + INSERT INTO signal.t_chnprmship_positions ( + mmsi, imo, name, callsign, vessel_type, extra_info, + lat, lon, geom, + heading, sog, cog, rot, + length, width, draught, + destination, eta, status, + message_timestamp, signal_kind_code, class_type + ) VALUES ( + ?, ?, ?, ?, ?, ?, + ?, ?, public.ST_SetSRID(public.ST_MakePoint(?, ?), 4326), + ?, ?, ?, ?, + ?, ?, ?, + ?, ?, ?, + ?, ?, ? + ) + ON CONFLICT (mmsi, message_timestamp) DO NOTHING + """; + + List batchArgs = new ArrayList<>(); + + for (AisTargetEntity e : entities) { + if (e.getMmsi() == null || e.getLat() == null || e.getLon() == null + || e.getMessageTimestamp() == null) { + continue; + } + + Timestamp msgTs = Timestamp.from(e.getMessageTimestamp().toInstant()); + Timestamp etaTs = e.getEta() != null + ? Timestamp.from(e.getEta().toInstant()) + : null; + + batchArgs.add(new Object[] { + e.getMmsi(), e.getImo(), e.getName(), e.getCallsign(), + e.getVesselType(), e.getExtraInfo(), + e.getLat(), e.getLon(), + e.getLon(), e.getLat(), // ST_MakePoint(lon, lat) + e.getHeading(), e.getSog(), e.getCog(), e.getRot(), + e.getLength(), e.getWidth(), e.getDraught(), + e.getDestination(), etaTs, e.getStatus(), + msgTs, e.getSignalKindCode(), e.getClassType() + }); + } + + if (!batchArgs.isEmpty()) { + int[] results = jdbcTemplate.batchUpdate(sql, batchArgs); + log.info("t_chnprmship_positions 동기화 완료: {} 건 INSERT (DO NOTHING on conflict)", + results.length); + } + + return org.springframework.batch.repeat.RepeatStatus.FINISHED; + }, transactionManager) + .build(); + } +} diff --git a/src/main/java/gc/mda/signal_batch/batch/job/VesselTrackAggregationJobConfig.java b/src/main/java/gc/mda/signal_batch/batch/job/VesselTrackAggregationJobConfig.java index b9ad0de..02e8c79 100644 --- a/src/main/java/gc/mda/signal_batch/batch/job/VesselTrackAggregationJobConfig.java +++ b/src/main/java/gc/mda/signal_batch/batch/job/VesselTrackAggregationJobConfig.java @@ -26,10 +26,11 @@ public class VesselTrackAggregationJobConfig { private final JobRepository jobRepository; private final VesselTrackStepConfig vesselTrackStepConfig; private final AisPositionSyncStepConfig aisPositionSyncStepConfig; + private final ChnPrmShipPositionSyncStepConfig chnPrmShipPositionSyncStepConfig; private final JobCompletionListener jobCompletionListener; private final CacheBasedTrackJobListener cacheBasedTrackJobListener; private final PerformanceOptimizationListener performanceOptimizationListener; - + @Bean public Job vesselTrackAggregationJob() { return new JobBuilder("vesselTrackAggregationJob", jobRepository) @@ -37,11 +38,12 @@ public class VesselTrackAggregationJobConfig { .validator(trackJobParametersValidator()) .listener(jobCompletionListener) .listener(cacheBasedTrackJobListener) - .listener(performanceOptimizationListener) // 성능 최적화 리스너 추가 + .listener(performanceOptimizationListener) .start(vesselTrackStepConfig.vesselTrackStep()) .next(vesselTrackStepConfig.gridTrackSummaryStep()) .next(vesselTrackStepConfig.areaTrackSummaryStep()) .next(aisPositionSyncStepConfig.aisPositionSyncStep()) + .next(chnPrmShipPositionSyncStepConfig.chnPrmShipPositionSyncStep()) .build(); } diff --git a/src/main/java/gc/mda/signal_batch/batch/reader/ChnPrmShipCacheManager.java b/src/main/java/gc/mda/signal_batch/batch/reader/ChnPrmShipCacheManager.java index 78619e1..e78741f 100644 --- a/src/main/java/gc/mda/signal_batch/batch/reader/ChnPrmShipCacheManager.java +++ b/src/main/java/gc/mda/signal_batch/batch/reader/ChnPrmShipCacheManager.java @@ -10,6 +10,7 @@ import org.springframework.stereotype.Component; import java.time.OffsetDateTime; import java.time.ZoneOffset; +import java.util.Collection; import java.util.List; import java.util.Map; import java.util.concurrent.TimeUnit; @@ -70,6 +71,21 @@ public class ChnPrmShipCacheManager { return updated; } + /** + * MMSI로 단건 조회 (enrichment용) + */ + public AisTargetEntity getByMmsi(String mmsi) { + if (mmsi == null) return null; + return cache.getIfPresent(mmsi); + } + + /** + * 전체 캐시 스냅샷 반환 (DB 동기화용) + */ + public Collection getAllValues() { + return cache.asMap().values(); + } + /** * 시간 범위 내 캐시 데이터 조회 */ diff --git a/src/main/java/gc/mda/signal_batch/batch/reader/ChnPrmShipCacheWarmer.java b/src/main/java/gc/mda/signal_batch/batch/reader/ChnPrmShipCacheWarmer.java index 7533055..50acbb7 100644 --- a/src/main/java/gc/mda/signal_batch/batch/reader/ChnPrmShipCacheWarmer.java +++ b/src/main/java/gc/mda/signal_batch/batch/reader/ChnPrmShipCacheWarmer.java @@ -20,7 +20,9 @@ import java.util.List; /** * 기동 시 ChnPrmShip 캐시 워밍업 * - * t_ais_position 테이블에서 대상 MMSI의 데이터를 조회하여 캐시를 채운다. + * 1차: t_chnprmship_positions (전용 이력 테이블, 최근 7일) + * 2차: t_ais_position (최신 위치, fallback) + * 각 MMSI별로 message_timestamp가 더 최신인 것을 캐시에 보관한다. * 이후 매 분 배치 수집에서 실시간 데이터가 캐시를 갱신한다. */ @Slf4j @@ -57,34 +59,77 @@ public class ChnPrmShipCacheWarmer implements ApplicationRunner { List mmsiList = new ArrayList<>(properties.getMmsiSet()); int totalLoaded = 0; + // 1차: t_chnprmship_positions (전용 이력 테이블) + int loadedFromDedicated = 0; for (int i = 0; i < mmsiList.size(); i += DB_QUERY_CHUNK_SIZE) { List chunk = mmsiList.subList(i, Math.min(i + DB_QUERY_CHUNK_SIZE, mmsiList.size())); - try { - List fromDb = queryLatestByMmsiSince(chunk, since); - - fromDb.forEach(entity -> { - if (entity.getSignalKindCode() == null) { - SignalKindCode kindCode = SignalKindCode.resolve( - entity.getVesselType(), entity.getExtraInfo()); - entity.setSignalKindCode(kindCode.getCode()); - } - }); - - cacheManager.putAll(fromDb); - totalLoaded += fromDb.size(); + List fromDedicated = queryLatestFromChnPrmShipPositions(chunk, since); + resolveSignalKindCode(fromDedicated); + cacheManager.putAll(fromDedicated); + loadedFromDedicated += fromDedicated.size(); } catch (Exception e) { - log.warn("ChnPrmShip 워밍업 DB 조회 실패 (chunk {}/{}): {}", + log.warn("ChnPrmShip 워밍업 전용 테이블 조회 실패 (chunk {}/{}): {}", i / DB_QUERY_CHUNK_SIZE + 1, (mmsiList.size() + DB_QUERY_CHUNK_SIZE - 1) / DB_QUERY_CHUNK_SIZE, e.getMessage()); } } + totalLoaded += loadedFromDedicated; + + // 2차: t_ais_position (더 최신 데이터가 있으면 덮어씀) + int loadedFromAisPosition = 0; + for (int i = 0; i < mmsiList.size(); i += DB_QUERY_CHUNK_SIZE) { + List chunk = mmsiList.subList(i, + Math.min(i + DB_QUERY_CHUNK_SIZE, mmsiList.size())); + try { + List fromAis = queryLatestByMmsiSince(chunk, since); + resolveSignalKindCode(fromAis); + cacheManager.putAll(fromAis); + loadedFromAisPosition += fromAis.size(); + } catch (Exception e) { + log.warn("ChnPrmShip 워밍업 t_ais_position 조회 실패 (chunk {}/{}): {}", + i / DB_QUERY_CHUNK_SIZE + 1, + (mmsiList.size() + DB_QUERY_CHUNK_SIZE - 1) / DB_QUERY_CHUNK_SIZE, + e.getMessage()); + } + } + totalLoaded = Math.max(totalLoaded, loadedFromAisPosition); long elapsed = System.currentTimeMillis() - startTime; - log.info("ChnPrmShip 캐시 워밍업 완료 - 대상: {}, 로딩: {}건, 소요: {}ms", - properties.getMmsiSet().size(), totalLoaded, elapsed); + log.info("ChnPrmShip 캐시 워밍업 완료 - 대상: {}, 전용DB: {}건, AIS: {}건, 캐시 크기: {}, 소요: {}ms", + properties.getMmsiSet().size(), loadedFromDedicated, loadedFromAisPosition, + cacheManager.size(), elapsed); + } + + private void resolveSignalKindCode(List entities) { + entities.forEach(entity -> { + if (entity.getSignalKindCode() == null) { + SignalKindCode kindCode = SignalKindCode.resolve( + entity.getVesselType(), entity.getExtraInfo()); + entity.setSignalKindCode(kindCode.getCode()); + } + }); + } + + private List queryLatestFromChnPrmShipPositions(List mmsiList, OffsetDateTime since) { + String placeholders = String.join(",", mmsiList.stream().map(m -> "?").toList()); + String sql = "SELECT DISTINCT ON (mmsi) mmsi, imo, name, callsign, vessel_type, extra_info, " + + "lat, lon, heading, sog, cog, rot, length, width, draught, " + + "destination, eta, status, message_timestamp, signal_kind_code, class_type " + + "FROM signal.t_chnprmship_positions " + + "WHERE mmsi IN (" + placeholders + ") " + + "AND message_timestamp >= ? " + + "ORDER BY mmsi, message_timestamp DESC"; + + Object[] params = new Object[mmsiList.size() + 1]; + for (int j = 0; j < mmsiList.size(); j++) { + params[j] = mmsiList.get(j); + } + params[mmsiList.size()] = since; + + return queryJdbcTemplate.query(sql, params, (rs, rowNum) -> mapRow(rs)); } private List queryLatestByMmsiSince(List mmsiList, OffsetDateTime since) { diff --git a/src/main/java/gc/mda/signal_batch/domain/gis/controller/GisControllerV2.java b/src/main/java/gc/mda/signal_batch/domain/gis/controller/GisControllerV2.java index 58960ca..dcf181a 100644 --- a/src/main/java/gc/mda/signal_batch/domain/gis/controller/GisControllerV2.java +++ b/src/main/java/gc/mda/signal_batch/domain/gis/controller/GisControllerV2.java @@ -168,6 +168,11 @@ public class GisControllerV2 { - 선박별 병합된 단일 객체 - geometry/timestamps/speeds 배열 포함 - 선박 메타정보 포함 + + **중국허가선박 정보 (includeChnPrmShip)** + - `includeChnPrmShip: true` 설정 시, 대상 MMSI에 대해 `chnPrmShipInfo` 필드가 추가됩니다 + - 비대상 선박은 기존 응답과 동일 (chnPrmShipInfo: null → JSON 자동 제외) + - 전용 캐시(TTL 7일)에서 조회하여 최신 위치/선박정보를 포함합니다 """ ) @ApiResponses(value = { diff --git a/src/main/java/gc/mda/signal_batch/domain/gis/service/GisServiceV2.java b/src/main/java/gc/mda/signal_batch/domain/gis/service/GisServiceV2.java index 055eeed..1bc335d 100644 --- a/src/main/java/gc/mda/signal_batch/domain/gis/service/GisServiceV2.java +++ b/src/main/java/gc/mda/signal_batch/domain/gis/service/GisServiceV2.java @@ -1,8 +1,11 @@ package gc.mda.signal_batch.domain.gis.service; +import gc.mda.signal_batch.batch.reader.ChnPrmShipCacheManager; +import gc.mda.signal_batch.batch.reader.ChnPrmShipProperties; import gc.mda.signal_batch.batch.reader.FiveMinTrackCache; import gc.mda.signal_batch.batch.reader.HourlyTrackCache; import gc.mda.signal_batch.domain.vessel.dto.CompactVesselTrack; +import gc.mda.signal_batch.domain.vessel.model.AisTargetEntity; 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.model.VesselTrack; @@ -46,6 +49,8 @@ public class GisServiceV2 { private final HourlyTrackCache hourlyTrackCache; private final FiveMinTrackCache fiveMinTrackCache; private final VesselTrackToCompactConverter vesselTrackToCompactConverter; + private final ChnPrmShipCacheManager chnPrmShipCacheManager; + private final ChnPrmShipProperties chnPrmShipProperties; @Value("${rest.v2.query.timeout-seconds:30}") private int restQueryTimeout; @@ -64,7 +69,9 @@ public class GisServiceV2 { GisService gisService, HourlyTrackCache hourlyTrackCache, FiveMinTrackCache fiveMinTrackCache, - VesselTrackToCompactConverter vesselTrackToCompactConverter) { + VesselTrackToCompactConverter vesselTrackToCompactConverter, + ChnPrmShipCacheManager chnPrmShipCacheManager, + ChnPrmShipProperties chnPrmShipProperties) { this.queryDataSource = queryDataSource; this.activeQueryManager = activeQueryManager; this.dailyTrackCacheManager = dailyTrackCacheManager; @@ -73,6 +80,8 @@ public class GisServiceV2 { this.hourlyTrackCache = hourlyTrackCache; this.fiveMinTrackCache = fiveMinTrackCache; this.vesselTrackToCompactConverter = vesselTrackToCompactConverter; + this.chnPrmShipCacheManager = chnPrmShipCacheManager; + this.chnPrmShipProperties = chnPrmShipProperties; } /** @@ -262,7 +271,7 @@ public class GisServiceV2 { } /** - * 선박별 항적 조회 V2 (캐시 + Semaphore + 간소화) + * 선박별 항적 조회 V2 (캐시 + Semaphore + 간소화 + ChnPrmShip enrichment) */ public List getVesselTracksV2(VesselTracksRequest request) { String queryId = "rest-vessels-" + UUID.randomUUID().toString().substring(0, 8); @@ -284,8 +293,14 @@ public class GisServiceV2 { result = applySimplificationPipeline(result); - log.debug("V2 API: Returned {} tracks for {} vessels (cache={})", - result.size(), request.getVessels().size(), dailyTrackCacheManager.isEnabled()); + // ChnPrmShip enrichment + if (request.isIncludeChnPrmShip()) { + result = enrichWithChnPrmShipInfo(result); + } + + log.debug("V2 API: Returned {} tracks for {} vessels (cache={}, chnPrmShip={})", + result.size(), request.getVessels().size(), + dailyTrackCacheManager.isEnabled(), request.isIncludeChnPrmShip()); return result; @@ -497,6 +512,57 @@ public class GisServiceV2 { return merged; } + // ── ChnPrmShip Enrichment ── + + private List enrichWithChnPrmShipInfo(List tracks) { + if (tracks == null || tracks.isEmpty()) { + return tracks; + } + + int enriched = 0; + List result = new ArrayList<>(tracks.size()); + + for (CompactVesselTrack track : tracks) { + if (!chnPrmShipProperties.isTarget(track.getVesselId())) { + result.add(track); + continue; + } + + AisTargetEntity entity = chnPrmShipCacheManager.getByMmsi(track.getVesselId()); + if (entity == null) { + result.add(track); + continue; + } + + CompactVesselTrack.ChnPrmShipInfo info = CompactVesselTrack.ChnPrmShipInfo.builder() + .imo(entity.getImo()) + .name(entity.getName()) + .callsign(entity.getCallsign()) + .vesselType(entity.getVesselType()) + .lat(entity.getLat()) + .lon(entity.getLon()) + .sog(entity.getSog()) + .cog(entity.getCog()) + .heading(entity.getHeading()) + .length(entity.getLength()) + .width(entity.getWidth()) + .draught(entity.getDraught()) + .destination(entity.getDestination()) + .status(entity.getStatus()) + .messageTimestamp(entity.getMessageTimestamp()) + .build(); + + result.add(track.toBuilder().chnPrmShipInfo(info).build()); + enriched++; + } + + if (enriched > 0) { + log.info("[ChnPrmShip] enrichment 완료: {} / {} tracks", enriched, tracks.size()); + } + + return result; + } + // ── MMSI 필터링 ── private Map> filterByMmsi( diff --git a/src/main/java/gc/mda/signal_batch/domain/ship/controller/ShipImageControllerV2.java b/src/main/java/gc/mda/signal_batch/domain/ship/controller/ShipImageControllerV2.java new file mode 100644 index 0000000..831b899 --- /dev/null +++ b/src/main/java/gc/mda/signal_batch/domain/ship/controller/ShipImageControllerV2.java @@ -0,0 +1,60 @@ +package gc.mda.signal_batch.domain.ship.controller; + +import gc.mda.signal_batch.domain.ship.dto.ShipImageDto; +import gc.mda.signal_batch.domain.ship.service.ShipImageService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Content; +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 lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.List; + +/** + * 선박 이미지 API V2 + * V1과 동일한 로직이며, V2 프록시 환경에서 별도 V1 프록시 설정 없이 사용하기 위해 분리 + */ +@Slf4j +@RestController +@RequestMapping("/api/v2/shipimg") +@RequiredArgsConstructor +@Tag(name = "Ship Image V2", description = "선박 이미지 API (V2)") +public class ShipImageControllerV2 { + + private final ShipImageService shipImageService; + + @GetMapping("/{imo}") + @Operation( + summary = "선박 이미지 경로 조회", + description = "IMO 번호로 선박 이미지 경로 목록을 조회합니다. 프론트엔드에서 썸네일은 path + '_1.jpg', 원본은 path + '_2.jpg'를 사용합니다." + ) + @ApiResponses(value = { + @ApiResponse( + responseCode = "200", + description = "조회 성공 (데이터 없으면 빈 배열 반환)", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = ShipImageDto.class))) + ) + }) + public ResponseEntity> getShipImages( + @Parameter(description = "IMO 번호", example = "9141833") + @PathVariable Integer imo) { + + log.debug("V2 Requesting ship images for IMO: {}", imo); + + List images = shipImageService.getImagesByImo(imo); + + log.debug("V2 Found {} images for IMO: {}", images.size(), imo); + + return ResponseEntity.ok(images); + } +} diff --git a/src/main/java/gc/mda/signal_batch/domain/vessel/dto/CompactVesselTrack.java b/src/main/java/gc/mda/signal_batch/domain/vessel/dto/CompactVesselTrack.java index 2604e93..2bcdb87 100644 --- a/src/main/java/gc/mda/signal_batch/domain/vessel/dto/CompactVesselTrack.java +++ b/src/main/java/gc/mda/signal_batch/domain/vessel/dto/CompactVesselTrack.java @@ -9,6 +9,7 @@ import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; +import java.time.OffsetDateTime; import java.util.List; /** @@ -84,4 +85,61 @@ public class CompactVesselTrack { example = "000023" ) private String shipKindCode; + + @Schema(description = "중국허가선박 정보 (대상 선박이고 includeChnPrmShip=true인 경우에만 포함)") + private ChnPrmShipInfo chnPrmShipInfo; + + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + @JsonInclude(JsonInclude.Include.NON_NULL) + @Schema(description = "중국허가선박 상세 정보 (AisTargetEntity 기반)") + public static class ChnPrmShipInfo { + + @Schema(description = "IMO 번호", example = "9123456") + private Long imo; + + @Schema(description = "선박명", example = "LIAN HE 15") + private String name; + + @Schema(description = "호출부호", example = "BVZQ") + private String callsign; + + @Schema(description = "선종 (AIS vessel type)", example = "Fishing") + private String vesselType; + + @Schema(description = "위도", example = "34.227527") + private Double lat; + + @Schema(description = "경도", example = "127.0638") + private Double lon; + + @Schema(description = "대지속력 (knots)", example = "8.5") + private Double sog; + + @Schema(description = "대지침로 (degrees)", example = "245.3") + private Double cog; + + @Schema(description = "선수방향 (degrees)", example = "244.0") + private Double heading; + + @Schema(description = "선박 길이 (m)", example = "45") + private Integer length; + + @Schema(description = "선박 폭 (m)", example = "12") + private Integer width; + + @Schema(description = "흘수 (m)", example = "3.5") + private Double draught; + + @Schema(description = "목적지", example = "BUSAN") + private String destination; + + @Schema(description = "항해 상태", example = "Under way using engine") + private String status; + + @Schema(description = "위치 메시지 시각 (UTC)", example = "2026-02-19T12:00:00Z") + private OffsetDateTime messageTimestamp; + } } diff --git a/src/main/java/gc/mda/signal_batch/domain/vessel/dto/VesselTracksRequest.java b/src/main/java/gc/mda/signal_batch/domain/vessel/dto/VesselTracksRequest.java index 2cb8c48..3c3017d 100644 --- a/src/main/java/gc/mda/signal_batch/domain/vessel/dto/VesselTracksRequest.java +++ b/src/main/java/gc/mda/signal_batch/domain/vessel/dto/VesselTracksRequest.java @@ -44,4 +44,11 @@ public class VesselTracksRequest { @Schema(description = "조회할 선박 MMSI 목록", required = true) private List vessels; + + @Schema( + description = "중국허가선박 정보 포함 여부. true이면 대상 MMSI에 대해 chnPrmShipInfo를 응답에 추가", + example = "false" + ) + @Builder.Default + private boolean includeChnPrmShip = false; } diff --git a/src/main/java/gc/mda/signal_batch/global/config/SwaggerConfig.java b/src/main/java/gc/mda/signal_batch/global/config/SwaggerConfig.java index b17412e..3d99cf2 100644 --- a/src/main/java/gc/mda/signal_batch/global/config/SwaggerConfig.java +++ b/src/main/java/gc/mda/signal_batch/global/config/SwaggerConfig.java @@ -80,7 +80,8 @@ public class SwaggerConfig { .pathsToMatch( "/api/v1/tracks/**", "/api/v1/haegu/**", "/api/v1/areas/**", "/api/v1/passages/**", "/api/v1/vessels/**", - "/api/v2/**" + "/api/v2/tracks/**", "/api/v2/haegu/**", "/api/v2/areas/**", + "/api/v2/vessels/**" ) .build(); } @@ -90,7 +91,7 @@ public class SwaggerConfig { return GroupedOpenApi.builder() .group("2-ship-image-api") .displayName("선박 이미지 API") - .pathsToMatch("/api/v1/shipimg/**") + .pathsToMatch("/api/v1/shipimg/**", "/api/v2/shipimg/**") .build(); } diff --git a/src/main/java/gc/mda/signal_batch/global/util/PartitionManager.java b/src/main/java/gc/mda/signal_batch/global/util/PartitionManager.java index 8898dbf..b7a58a9 100644 --- a/src/main/java/gc/mda/signal_batch/global/util/PartitionManager.java +++ b/src/main/java/gc/mda/signal_batch/global/util/PartitionManager.java @@ -55,7 +55,8 @@ public class PartitionManager { "t_grid_tracks_summary_daily", "t_area_tracks_summary_hourly", "t_area_tracks_summary_daily", - "t_abnormal_tracks" + "t_abnormal_tracks", + "t_chnprmship_positions" ); /** @@ -409,6 +410,21 @@ public class PartitionManager { partitionName, schema, partitionName )); } + // 중국허가선박 위치 이력 테이블 + else if (baseTable.equals("t_chnprmship_positions")) { + indexSqls.add(String.format( + "CREATE INDEX CONCURRENTLY IF NOT EXISTS %s_mmsi_time_idx ON %s.%s (mmsi, message_timestamp DESC)", + partitionName, schema, partitionName + )); + indexSqls.add(String.format( + "CREATE INDEX CONCURRENTLY IF NOT EXISTS %s_time_idx ON %s.%s (message_timestamp DESC)", + partitionName, schema, partitionName + )); + indexSqls.add(String.format( + "CREATE INDEX CONCURRENTLY IF NOT EXISTS %s_geom_idx ON %s.%s USING GIST (geom)", + partitionName, schema, partitionName + )); + } // 인덱스 생성 실행 for (String indexSql : indexSqls) { @@ -662,7 +678,8 @@ public class PartitionManager { OR tablename LIKE 't_grid%' OR tablename LIKE 't_area%' OR tablename LIKE 't_tile%' - OR tablename LIKE 't_abnormal%') + OR tablename LIKE 't_abnormal%' + OR tablename LIKE 't_chnprmship%') ORDER BY tablename """; diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 809b469..d21cc31 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -208,6 +208,8 @@ vessel: retention-months: 3 # 3개월 보관 t_abnormal_tracks: retention-months: 0 # 무한 보관 + t_chnprmship_positions: + retention-months: 3 # 3개월 보관 # Bulk Insert 설정 bulk-insert: @@ -290,10 +292,10 @@ app: chnprmship: mmsi-resource-path: classpath:chnprmship-mmsi.txt - ttl-days: 2 + ttl-days: 7 max-size: 2000 warmup-enabled: true - warmup-days: 2 + warmup-days: 7 # Swagger/OpenAPI 설정 springdoc: -- 2.45.2