feat: ChnPrmShip 전용 DB 이력 + API enrichment + ShipImage V2 #46
@ -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<AisTargetEntity> 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<Object[]> 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();
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
|
||||
|
||||
@ -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<AisTargetEntity> getAllValues() {
|
||||
return cache.asMap().values();
|
||||
}
|
||||
|
||||
/**
|
||||
* 시간 범위 내 캐시 데이터 조회
|
||||
*/
|
||||
|
||||
@ -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<String> 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<String> chunk = mmsiList.subList(i,
|
||||
Math.min(i + DB_QUERY_CHUNK_SIZE, mmsiList.size()));
|
||||
|
||||
try {
|
||||
List<AisTargetEntity> 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<AisTargetEntity> 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<String> chunk = mmsiList.subList(i,
|
||||
Math.min(i + DB_QUERY_CHUNK_SIZE, mmsiList.size()));
|
||||
try {
|
||||
List<AisTargetEntity> 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<AisTargetEntity> entities) {
|
||||
entities.forEach(entity -> {
|
||||
if (entity.getSignalKindCode() == null) {
|
||||
SignalKindCode kindCode = SignalKindCode.resolve(
|
||||
entity.getVesselType(), entity.getExtraInfo());
|
||||
entity.setSignalKindCode(kindCode.getCode());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
private List<AisTargetEntity> queryLatestFromChnPrmShipPositions(List<String> 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<AisTargetEntity> queryLatestByMmsiSince(List<String> mmsiList, OffsetDateTime since) {
|
||||
|
||||
@ -168,6 +168,11 @@ public class GisControllerV2 {
|
||||
- 선박별 병합된 단일 객체
|
||||
- geometry/timestamps/speeds 배열 포함
|
||||
- 선박 메타정보 포함
|
||||
|
||||
**중국허가선박 정보 (includeChnPrmShip)**
|
||||
- `includeChnPrmShip: true` 설정 시, 대상 MMSI에 대해 `chnPrmShipInfo` 필드가 추가됩니다
|
||||
- 비대상 선박은 기존 응답과 동일 (chnPrmShipInfo: null → JSON 자동 제외)
|
||||
- 전용 캐시(TTL 7일)에서 조회하여 최신 위치/선박정보를 포함합니다
|
||||
"""
|
||||
)
|
||||
@ApiResponses(value = {
|
||||
|
||||
@ -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<CompactVesselTrack> 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<CompactVesselTrack> enrichWithChnPrmShipInfo(List<CompactVesselTrack> tracks) {
|
||||
if (tracks == null || tracks.isEmpty()) {
|
||||
return tracks;
|
||||
}
|
||||
|
||||
int enriched = 0;
|
||||
List<CompactVesselTrack> 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<String, List<VesselTrack>> filterByMmsi(
|
||||
|
||||
@ -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<List<ShipImageDto>> getShipImages(
|
||||
@Parameter(description = "IMO 번호", example = "9141833")
|
||||
@PathVariable Integer imo) {
|
||||
|
||||
log.debug("V2 Requesting ship images for IMO: {}", imo);
|
||||
|
||||
List<ShipImageDto> images = shipImageService.getImagesByImo(imo);
|
||||
|
||||
log.debug("V2 Found {} images for IMO: {}", images.size(), imo);
|
||||
|
||||
return ResponseEntity.ok(images);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -44,4 +44,11 @@ public class VesselTracksRequest {
|
||||
|
||||
@Schema(description = "조회할 선박 MMSI 목록", required = true)
|
||||
private List<String> vessels;
|
||||
|
||||
@Schema(
|
||||
description = "중국허가선박 정보 포함 여부. true이면 대상 MMSI에 대해 chnPrmShipInfo를 응답에 추가",
|
||||
example = "false"
|
||||
)
|
||||
@Builder.Default
|
||||
private boolean includeChnPrmShip = false;
|
||||
}
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
""";
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user