From 89482d854f72dfeb10f3164f844de38ba887fef7 Mon Sep 17 00:00:00 2001 From: HeungTak Lee Date: Tue, 20 Jan 2026 13:38:31 +0900 Subject: [PATCH] feat: Add V2 REST API with WebSocket-compatible responses - Add GisControllerV2/GisServiceV2 for CompactVesselTrack responses - Add nationalCode and shipKindCode fields to REST API responses - Add flexible DateTime parsing support (multiple formats) - Add TrackConverter utility for track data conversion - Update SwaggerConfig with V2 API endpoints and unified tags - Update ProdDataSourceConfig for prod-mpr profile support - Enhance Swagger documentation for all DTOs --- .../VesselPositionCacheRefreshScheduler.java | 10 +- .../batch/job/VesselTrackStepConfig.java | 125 +- .../batch/reader/VesselDataReader.java | 30 +- .../batch/writer/VesselTrackBulkWriter.java | 100 +- .../domain/debug/DebugTimeController.java | 13 +- .../domain/gis/controller/GisController.java | 2 +- .../gis/controller/GisControllerV2.java | 207 ++ .../domain/gis/dto/GisBoundaryResponse.java | 24 +- .../gis/dto/TileAggregationRequest.java | 26 + .../domain/gis/service/GisService.java | 21 +- .../domain/gis/service/GisServiceV2.java | 370 ++++ .../passage/dto/SequentialPassageRequest.java | 28 +- .../SequentialAreaTrackingService.java | 8 +- .../ship/controller/ShipImageController.java | 56 + .../domain/ship/dto/ShipImageDto.java | 18 + .../ship/repository/ShipImageRepository.java | 58 + .../domain/ship/service/ShipImageService.java | 58 + .../controller/AbnormalTrackController.java | 64 +- .../domain/vessel/dto/CompactVesselTrack.java | 103 +- .../domain/vessel/dto/IntegrationVessel.java | 2 +- .../vessel/dto/RecentVesselPositionDto.java | 49 +- .../domain/vessel/dto/TrackResponse.java | 33 +- .../vessel/dto/VesselBucketPositionDto.java | 56 + .../vessel/dto/VesselStatsResponse.java | 13 +- .../vessel/dto/VesselTracksRequest.java | 53 +- .../service/IntegrationVesselService.java | 2 +- .../service/VesselPreviousBucketCache.java | 303 +++ .../global/config/BatchConfig.java | 50 +- .../global/config/CacheConfig.java | 53 +- .../config/DataSourceConfigProperties.java | 2 +- .../FlexibleLocalDateTimeDeserializer.java | 47 + .../global/config/ProdDataSourceConfig.java | 2 +- .../global/config/SwaggerConfig.java | 51 +- .../global/util/DateTimeParseUtil.java | 176 ++ .../global/util/PartitionManager.java | 152 +- .../global/util/ShipKindCodeConverter.java | 79 +- .../global/util/TrackConverter.java | 231 +++ .../util/VesselTrackDataJobListener.java | 16 +- .../service/ChunkedTrackStreamingService.java | 24 +- .../controller/BatchAdminController.java | 40 +- .../controller/DataSourceDebugController.java | 5 + .../controller/MetricsController.java | 11 +- .../controller/MigrationController.java | 24 +- .../controller/MonitoringController.java | 7 + .../WebSocketMonitoringController.java | 10 +- .../service/BatchMetadataCleanupService.java | 2 + src/main/resources/application-dev.yml | 9 + src/main/resources/application-local.yml | 70 +- src/main/resources/application-prod-mpr.yml | 278 +++ src/main/resources/application-prod.yml | 31 +- src/main/resources/application.yml | 25 +- .../resources/static/admin/batch-admin.html | 198 +- src/main/resources/static/gis-monitoring.html | 1594 ++------------ .../resources/static/js/gis-monitoring.js | 1831 +++++++++++++++++ .../static/v2/pages/chunked-streaming.html | 6 +- .../static/v2/pages/gis-monitoring.html | 10 +- .../chunked-streaming-gis-animated.html | 6 +- .../websocket/WebSocketLoadTest.java | 14 +- 58 files changed, 5018 insertions(+), 1868 deletions(-) create mode 100644 src/main/java/gc/mda/signal_batch/domain/gis/controller/GisControllerV2.java create mode 100644 src/main/java/gc/mda/signal_batch/domain/gis/service/GisServiceV2.java create mode 100644 src/main/java/gc/mda/signal_batch/domain/ship/controller/ShipImageController.java create mode 100644 src/main/java/gc/mda/signal_batch/domain/ship/dto/ShipImageDto.java create mode 100644 src/main/java/gc/mda/signal_batch/domain/ship/repository/ShipImageRepository.java create mode 100644 src/main/java/gc/mda/signal_batch/domain/ship/service/ShipImageService.java create mode 100644 src/main/java/gc/mda/signal_batch/domain/vessel/dto/VesselBucketPositionDto.java create mode 100644 src/main/java/gc/mda/signal_batch/domain/vessel/service/VesselPreviousBucketCache.java create mode 100644 src/main/java/gc/mda/signal_batch/global/config/FlexibleLocalDateTimeDeserializer.java create mode 100644 src/main/java/gc/mda/signal_batch/global/util/DateTimeParseUtil.java create mode 100644 src/main/java/gc/mda/signal_batch/global/util/TrackConverter.java create mode 100644 src/main/resources/application-prod-mpr.yml create mode 100644 src/main/resources/static/js/gis-monitoring.js diff --git a/src/main/java/gc/mda/signal_batch/batch/job/VesselPositionCacheRefreshScheduler.java b/src/main/java/gc/mda/signal_batch/batch/job/VesselPositionCacheRefreshScheduler.java index 3edac93..19f08f6 100644 --- a/src/main/java/gc/mda/signal_batch/batch/job/VesselPositionCacheRefreshScheduler.java +++ b/src/main/java/gc/mda/signal_batch/batch/job/VesselPositionCacheRefreshScheduler.java @@ -8,6 +8,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Profile; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.RowMapper; import org.springframework.scheduling.annotation.Scheduled; @@ -40,6 +41,7 @@ import java.util.List; */ @Slf4j @Component +@Profile("!query") // query 프로파일에서는 캐시 갱신 스케줄러 비활성화 @RequiredArgsConstructor @ConditionalOnProperty(name = "vessel.batch.cache.latest-position.enabled", havingValue = "true", matchIfMissing = false) public class VesselPositionCacheRefreshScheduler { @@ -117,7 +119,7 @@ public class VesselPositionCacheRefreshScheduler { message_time as last_update FROM signal.sig_test WHERE message_time >= ? AND message_time < ? - AND sig_src_cd NOT IN ('000004', '000005') + AND sig_src_cd != '000005' AND length(target_id) > 5 AND lat BETWEEN -90 AND 90 AND lon BETWEEN -180 AND 180 @@ -141,10 +143,10 @@ public class VesselPositionCacheRefreshScheduler { private void logCacheStats() { try { VesselLatestPositionCache.CacheStats stats = cache.getStats(); - log.info("Cache Stats - Size: {}, HitRate: {:.2f}%, MissRate: {:.2f}%, Hits: {}, Misses: {}", + log.info("Cache Stats - Size: {}, HitRate: {}%, MissRate: {}%, Hits: {}, Misses: {}", stats.currentSize(), - stats.hitRate(), - stats.missRate(), + String.format("%.2f", stats.hitRate()), + String.format("%.2f", stats.missRate()), stats.hitCount(), stats.missCount()); } catch (Exception e) { diff --git a/src/main/java/gc/mda/signal_batch/batch/job/VesselTrackStepConfig.java b/src/main/java/gc/mda/signal_batch/batch/job/VesselTrackStepConfig.java index fc59235..a315588 100644 --- a/src/main/java/gc/mda/signal_batch/batch/job/VesselTrackStepConfig.java +++ b/src/main/java/gc/mda/signal_batch/batch/job/VesselTrackStepConfig.java @@ -2,6 +2,8 @@ package gc.mda.signal_batch.batch.job; import gc.mda.signal_batch.domain.vessel.model.VesselData; import gc.mda.signal_batch.domain.vessel.model.VesselTrack; +import gc.mda.signal_batch.domain.vessel.dto.VesselBucketPositionDto; +import gc.mda.signal_batch.domain.vessel.service.VesselPreviousBucketCache; import gc.mda.signal_batch.batch.processor.VesselTrackProcessor; import gc.mda.signal_batch.batch.processor.AbnormalTrackDetector; import gc.mda.signal_batch.batch.processor.AbnormalTrackDetector.AbnormalDetectionResult; @@ -56,6 +58,10 @@ public class VesselTrackStepConfig { private final TrackClippingUtils trackClippingUtils; private final AbnormalTrackDetector abnormalTrackDetector; private final AbnormalTrackWriter abnormalTrackWriter; + private final VesselPreviousBucketCache previousBucketCache; + + // 현재 처리 중인 버킷의 종료 위치 저장 (캐시 업데이트용) + private final Map currentBucketEndPositions = new HashMap<>(); public VesselTrackStepConfig( JobRepository jobRepository, @@ -66,7 +72,8 @@ public class VesselTrackStepConfig { VesselTrackBulkWriter vesselTrackBulkWriter, TrackClippingUtils trackClippingUtils, AbnormalTrackDetector abnormalTrackDetector, - AbnormalTrackWriter abnormalTrackWriter) { + AbnormalTrackWriter abnormalTrackWriter, + VesselPreviousBucketCache previousBucketCache) { this.jobRepository = jobRepository; this.transactionManager = transactionManager; this.queryDataSource = queryDataSource; @@ -76,6 +83,7 @@ public class VesselTrackStepConfig { this.trackClippingUtils = trackClippingUtils; this.abnormalTrackDetector = abnormalTrackDetector; this.abnormalTrackWriter = abnormalTrackWriter; + this.previousBucketCache = previousBucketCache; } @Value("${vessel.batch.chunk-size:1000}") @@ -113,41 +121,112 @@ public class VesselTrackStepConfig { if (tracks == null || tracks.isEmpty()) { return null; } - - // 2. 강화된 비정상 궤적 필터링 + + // 2. 이전 버킷 위치 조회 (캐시 + DB Fallback) + List vesselKeys = tracks.stream() + .map(track -> track.getSigSrcCd() + ":" + track.getTargetId()) + .distinct() + .collect(Collectors.toList()); + + Map previousPositions = + previousBucketCache.getBatch(vesselKeys); + + // 3. 강화된 비정상 궤적 필터링 (버킷 내 + 버킷 간 점프 검출) List filteredTracks = new ArrayList<>(); + for (VesselTrack track : tracks) { boolean isAbnormal = false; - + String abnormalReason = ""; + // 선박/항공기 구분 boolean isAircraft = "000019".equals(track.getSigSrcCd()); double speedLimit = isAircraft ? 300.0 : 100.0; // 항공기 300, 선박 100 double distanceLimit = isAircraft ? 30.0 : 10.0; // 항공기 30nm, 선박 10nm - - // 평균속도 체크 + + // 버킷 내 평균속도 체크 if (track.getAvgSpeed() != null && track.getAvgSpeed().doubleValue() >= speedLimit) { isAbnormal = true; + abnormalReason = "within_bucket_speed"; } - - // 5분간 이동거리 체크 + + // 버킷 내 이동거리 체크 if (track.getDistanceNm() != null && track.getDistanceNm().doubleValue() >= distanceLimit) { isAbnormal = true; + abnormalReason = "within_bucket_distance"; } - + + // 버킷 간 점프 검출 (NEW!) + if (!isAbnormal && track.getStartPosition() != null) { + String vesselKey = track.getSigSrcCd() + ":" + track.getTargetId(); + VesselBucketPositionDto prevPosition = previousPositions.get(vesselKey); + + if (prevPosition != null) { + double jumpDistance = calculateDistance( + prevPosition.getEndLat(), prevPosition.getEndLon(), + track.getStartPosition().getLat(), track.getStartPosition().getLon() + ); + + // 위성 AIS는 2시간, 일반 신호는 15분 범위 체크 + boolean isSatellite = "000016".equals(track.getSigSrcCd()); + double maxGapMinutes = isSatellite ? 120.0 : 15.0; + double expectedMaxDistance = isAircraft ? (maxGapMinutes / 60.0 * 300.0) : (maxGapMinutes / 60.0 * 50.0); + + if (jumpDistance > expectedMaxDistance) { + isAbnormal = true; + abnormalReason = "bucket_to_bucket_jump"; + log.warn("버킷 간 점프 검출: vessel={}, jump_distance={}nm, threshold={}nm, " + + "prev_pos=[{},{}], curr_pos=[{},{}]", + vesselKey, + String.format("%.1f", jumpDistance), + String.format("%.1f", expectedMaxDistance), + String.format("%.4f", prevPosition.getEndLat()), + String.format("%.4f", prevPosition.getEndLon()), + String.format("%.4f", track.getStartPosition().getLat()), + String.format("%.4f", track.getStartPosition().getLon())); + } + } + } + if (isAbnormal) { - log.warn("5분 비정상 궤적 감지: vessel={}, avg_speed={}, distance={}", - track.getVesselKey(), track.getAvgSpeed(), track.getDistanceNm()); - saveAbnormalTrack(track); + log.warn("비정상 궤적 감지 [{}]: vessel={}, avg_speed={}, distance={}", + abnormalReason, track.getVesselKey(), track.getAvgSpeed(), track.getDistanceNm()); + saveAbnormalTrack(track, abnormalReason); } else { filteredTracks.add(track); + + // 정상 궤적의 종료 위치 저장 (캐시 업데이트용) + if (track.getEndPosition() != null) { + String vesselKey = track.getSigSrcCd() + ":" + track.getTargetId(); + currentBucketEndPositions.put(vesselKey, VesselBucketPositionDto.builder() + .sigSrcCd(track.getSigSrcCd()) + .targetId(track.getTargetId()) + .endLon(track.getEndPosition().getLon()) + .endLat(track.getEndPosition().getLat()) + .endTime(track.getEndPosition().getTime()) + .build()); + } } } - + return filteredTracks.isEmpty() ? null : filteredTracks; }; } + + /** + * Haversine 거리 계산 (해리) + */ + private double calculateDistance(double lat1, double lon1, double lat2, double lon2) { + final double R = 3440.065; // 지구 반경 (해리) + double dLat = Math.toRadians(lat2 - lat1); + double dLon = Math.toRadians(lon2 - lon1); + double a = Math.sin(dLat/2) * Math.sin(dLat/2) + + Math.cos(Math.toRadians(lat1)) * Math.cos(Math.toRadians(lat2)) * + Math.sin(dLon/2) * Math.sin(dLon/2); + double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a)); + return R * c; + } - private void saveAbnormalTrack(VesselTrack track) { + private void saveAbnormalTrack(VesselTrack track, String abnormalReason) { try { // Job 이름 설정 abnormalTrackWriter.setJobName("vesselTrackAggregationJob"); @@ -220,7 +299,23 @@ public class VesselTrackStepConfig { @Bean @StepScope public ItemWriter> vesselTrackWriter() { - return vesselTrackBulkWriter; + // Bulk Writer + 캐시 업데이트를 래핑 + return chunk -> { + // 1. 기존 Writer로 DB 저장 + vesselTrackBulkWriter.write(chunk); + + // 2. 캐시 업데이트 (현재 버킷 종료 위치) + if (!currentBucketEndPositions.isEmpty()) { + List positions = new ArrayList<>(currentBucketEndPositions.values()); + previousBucketCache.putAll(positions); + + log.debug("Updated previous bucket cache with {} vessel positions", + currentBucketEndPositions.size()); + + // 다음 청크를 위해 초기화 + currentBucketEndPositions.clear(); + } + }; } @Bean diff --git a/src/main/java/gc/mda/signal_batch/batch/reader/VesselDataReader.java b/src/main/java/gc/mda/signal_batch/batch/reader/VesselDataReader.java index 74a8377..ef39105 100644 --- a/src/main/java/gc/mda/signal_batch/batch/reader/VesselDataReader.java +++ b/src/main/java/gc/mda/signal_batch/batch/reader/VesselDataReader.java @@ -7,6 +7,7 @@ import org.springframework.batch.item.database.JdbcPagingItemReader; import org.springframework.batch.item.database.Order; import org.springframework.batch.item.database.support.PostgresPagingQueryProvider; import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.RowMapper; @@ -32,6 +33,9 @@ public class VesselDataReader { private final DataSource collectDataSource; private final JdbcTemplate collectJdbcTemplate; + @Value("${vessel.filter.zero-coordinates.enabled:false}") + private boolean filterZeroCoordinates; + private static final DateTimeFormatter PARTITION_FORMATTER = DateTimeFormatter.ofPattern("yyMMdd"); public VesselDataReader( @@ -44,6 +48,17 @@ public class VesselDataReader { @PostConstruct public void init() { logDataSourceInfo(); + log.info("Zero coordinates filter enabled: {}", filterZeroCoordinates); + } + + /** + * 0 근처 좌표 필터링 조건 생성 + */ + private String getZeroCoordinatesFilter() { + if (filterZeroCoordinates) { + return "AND NOT (lat BETWEEN -1 AND 1 AND lon BETWEEN -1 AND 1) "; + } + return ""; } /** @@ -88,19 +103,20 @@ public class VesselDataReader { log.info("Using table: {}", tableName); // 최신 위치만 가져오는 SQL - DISTINCT ON 사용 - String sql = """ + String sql = String.format(""" SELECT DISTINCT ON (sig_src_cd, target_id) message_time, real_time, sig_src_cd, target_id, lat, lon, sog, cog, heading, ship_nm, ship_ty, rot, posacc, sensor_id, base_st_id, mode, gps_sttus, battery_sttus, vts_cd, mmsi, vpass_id, ship_no FROM signal.%s - WHERE message_time >= ? AND message_time < ? + WHERE message_time >= ? AND message_time < ? AND sig_src_cd != '000005' - AND lat BETWEEN -90 AND 90 + AND lat BETWEEN -90 AND 90 AND lon BETWEEN -180 AND 180 + %s ORDER BY sig_src_cd, target_id, message_time DESC - """.formatted(tableName); + """, tableName, getZeroCoordinatesFilter()); reader.setSql(sql); @@ -181,6 +197,7 @@ public class VesselDataReader { sql.append("vts_cd, mmsi, vpass_id, ship_no "); sql.append("FROM signal.").append(tableName).append(" "); sql.append("WHERE message_time >= ? AND message_time < ? AND sig_src_cd != '000005' "); + sql.append(getZeroCoordinatesFilter()); sql.append("ORDER BY message_time, sig_src_cd, target_id"); reader.setSql(sql.toString()); @@ -218,7 +235,10 @@ public class VesselDataReader { "vts_cd, mmsi, vpass_id, ship_no "); queryProvider.setFromClause("FROM signal." + tableName); - queryProvider.setWhereClause("WHERE message_time >= :startTime AND message_time < :endTime and sig_src_cd != '000005'"); + + String whereClause = "WHERE message_time >= :startTime AND message_time < :endTime and sig_src_cd != '000005' " + + getZeroCoordinatesFilter(); + queryProvider.setWhereClause(whereClause); Map sortKeys = new HashMap<>(); sortKeys.put("message_time", Order.ASCENDING); diff --git a/src/main/java/gc/mda/signal_batch/batch/writer/VesselTrackBulkWriter.java b/src/main/java/gc/mda/signal_batch/batch/writer/VesselTrackBulkWriter.java index a60b699..dad0abf 100644 --- a/src/main/java/gc/mda/signal_batch/batch/writer/VesselTrackBulkWriter.java +++ b/src/main/java/gc/mda/signal_batch/batch/writer/VesselTrackBulkWriter.java @@ -108,28 +108,90 @@ public class VesselTrackBulkWriter implements ItemWriter> { } } - // track_geom만 사용하는 단순화된 COPY + // 임시 테이블 + MERGE 패턴을 사용한 Bulk Upsert private void bulkInsertTracks(List tracks, String tableName) throws Exception { try (Connection conn = queryDataSource.getConnection()) { - BaseConnection baseConn = conn.unwrap(BaseConnection.class); - CopyManager copyManager = new CopyManager(baseConn); - - String copySql = String.format(""" - COPY %s ( - sig_src_cd, target_id, time_bucket, track_geom, - distance_nm, avg_speed, max_speed, point_count, - start_position, end_position - ) FROM STDIN - """, tableName); - - StringWriter writer = new StringWriter(); - for (VesselTrack track : tracks) { - writer.write(formatTrackLine(track)); - writer.write('\n'); + conn.setAutoCommit(false); + + try { + BaseConnection baseConn = conn.unwrap(BaseConnection.class); + CopyManager copyManager = new CopyManager(baseConn); + + // 1. 임시 테이블 생성 (UNLOGGED for performance) + String tempTableName = "temp_vessel_tracks_" + Thread.currentThread().getId(); + try (var stmt = conn.createStatement()) { + stmt.execute(String.format(""" + CREATE TEMP TABLE IF NOT EXISTS %s ( + sig_src_cd VARCHAR(10), + target_id VARCHAR(30), + time_bucket TIMESTAMP, + track_geom GEOMETRY, + distance_nm NUMERIC, + avg_speed NUMERIC, + max_speed NUMERIC, + point_count INTEGER, + start_position JSONB, + end_position JSONB + ) ON COMMIT DROP + """, tempTableName)); + + // 임시 테이블 비우기 (이전 실행에서 남은 데이터 제거) + stmt.execute("TRUNCATE " + tempTableName); + } + + // 2. COPY로 임시 테이블에 bulk insert + String copySql = String.format(""" + COPY %s ( + sig_src_cd, target_id, time_bucket, track_geom, + distance_nm, avg_speed, max_speed, point_count, + start_position, end_position + ) FROM STDIN + """, tempTableName); + + StringWriter writer = new StringWriter(); + for (VesselTrack track : tracks) { + writer.write(formatTrackLine(track)); + writer.write('\n'); + } + + long rowsCopied = copyManager.copyIn(copySql, new StringReader(writer.toString())); + + // 3. 임시 테이블에서 최종 테이블로 UPSERT + String upsertSql = String.format(""" + INSERT INTO %s ( + sig_src_cd, target_id, time_bucket, track_geom, + distance_nm, avg_speed, max_speed, point_count, + start_position, end_position + ) + SELECT + sig_src_cd, target_id, time_bucket, track_geom, + distance_nm, avg_speed, max_speed, point_count, + start_position, end_position + FROM %s + ON CONFLICT (sig_src_cd, target_id, time_bucket) + DO UPDATE SET + track_geom = EXCLUDED.track_geom, + distance_nm = EXCLUDED.distance_nm, + avg_speed = EXCLUDED.avg_speed, + max_speed = EXCLUDED.max_speed, + point_count = EXCLUDED.point_count, + start_position = EXCLUDED.start_position, + end_position = EXCLUDED.end_position + """, tableName, tempTableName); + + int rowsUpserted; + try (var stmt = conn.createStatement()) { + rowsUpserted = stmt.executeUpdate(upsertSql); + } + + conn.commit(); + log.info("Bulk upserted {} vessel tracks to {} (copied: {}, upserted: {})", + tracks.size(), tableName, rowsCopied, rowsUpserted); + + } catch (Exception e) { + conn.rollback(); + throw e; } - - long rowsInserted = copyManager.copyIn(copySql, new StringReader(writer.toString())); - log.info("Bulk inserted {} vessel tracks to {} (v2 only)", rowsInserted, tableName); } } diff --git a/src/main/java/gc/mda/signal_batch/domain/debug/DebugTimeController.java b/src/main/java/gc/mda/signal_batch/domain/debug/DebugTimeController.java index 09fa041..21990b4 100644 --- a/src/main/java/gc/mda/signal_batch/domain/debug/DebugTimeController.java +++ b/src/main/java/gc/mda/signal_batch/domain/debug/DebugTimeController.java @@ -1,5 +1,8 @@ package gc.mda.signal_batch.domain.debug; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.jdbc.core.JdbcTemplate; @@ -17,6 +20,7 @@ import java.util.*; @Slf4j @RestController @RequestMapping("/api/debug") +@Tag(name = "시간 분석 디버그 API", description = "항적 데이터의 시간 정보 및 Unix timestamp 분석 API") public class DebugTimeController { private final DataSource queryDataSource; @@ -26,11 +30,12 @@ public class DebugTimeController { } @GetMapping("/time-analysis") + @Operation(summary = "시간 데이터 분석", description = "특정 선박의 항적 데이터에서 시간 정보(time_bucket, Unix timestamp)를 상세 분석합니다. DB 서버 시간, 최근 데이터, 시간 차이 분석을 포함합니다") public Map analyzeTimeData( - @RequestParam(defaultValue = "000001") String sigSrcCd, - @RequestParam(defaultValue = "440331240") String targetId, - @RequestParam(defaultValue = "2025-08-26T08:02:59") String startTime, - @RequestParam(defaultValue = "2025-08-27T08:02:59") String endTime) { + @Parameter(description = "신호 소스 코드 (기본: 000001)") @RequestParam(defaultValue = "000001") String sigSrcCd, + @Parameter(description = "선박 ID (기본: 440331240)") @RequestParam(defaultValue = "440331240") String targetId, + @Parameter(description = "시작 시간 (형식: yyyy-MM-ddTHH:mm:ss)") @RequestParam(defaultValue = "2025-08-26T08:02:59") String startTime, + @Parameter(description = "종료 시간 (형식: yyyy-MM-ddTHH:mm:ss)") @RequestParam(defaultValue = "2025-08-27T08:02:59") String endTime) { JdbcTemplate jdbcTemplate = new JdbcTemplate(queryDataSource); Map result = new HashMap<>(); diff --git a/src/main/java/gc/mda/signal_batch/domain/gis/controller/GisController.java b/src/main/java/gc/mda/signal_batch/domain/gis/controller/GisController.java index 83a0af4..bcd73b5 100644 --- a/src/main/java/gc/mda/signal_batch/domain/gis/controller/GisController.java +++ b/src/main/java/gc/mda/signal_batch/domain/gis/controller/GisController.java @@ -23,7 +23,7 @@ import java.util.Map; @RestController @RequestMapping("/api/v1") @RequiredArgsConstructor -@Tag(name = "항적 조회 API", description = "해구 및 영역별 선박 항적 조회 및 통계 API") +@Tag(name = "항적 조회 API V1", description = "해구 및 영역별 선박 항적 조회 및 통계 API (WKT 응답)") public class GisController { private final GisService gisService; 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 new file mode 100644 index 0000000..81294d2 --- /dev/null +++ b/src/main/java/gc/mda/signal_batch/domain/gis/controller/GisControllerV2.java @@ -0,0 +1,207 @@ +package gc.mda.signal_batch.domain.gis.controller; + +import gc.mda.signal_batch.domain.gis.dto.GisBoundaryResponse; +import gc.mda.signal_batch.domain.vessel.dto.CompactVesselTrack; +import gc.mda.signal_batch.domain.vessel.dto.VesselStatsResponse; +import gc.mda.signal_batch.domain.vessel.dto.VesselTracksRequest; +import gc.mda.signal_batch.domain.vessel.dto.RecentVesselPositionDto; +import gc.mda.signal_batch.domain.gis.service.GisService; +import gc.mda.signal_batch.domain.gis.service.GisServiceV2; +import gc.mda.signal_batch.domain.vessel.service.VesselPositionService; +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.web.bind.annotation.*; + +import java.util.List; +import java.util.Map; + +/** + * GIS API V2 - WebSocket과 동일한 응답 구조 (CompactVesselTrack) + * + * V1과의 차이점: + * - 항적 조회 시 CompactVesselTrack 반환 (선박별 병합, 배열 기반) + * - 통합선박 필터링 옵션 지원 + * - 선박 메타정보(shipName, shipType, nationalCode) 포함 + */ +@Slf4j +@RestController +@RequestMapping("/api/v2") +@RequiredArgsConstructor +@Tag(name = "항적 조회 API V2", description = "해구/영역별 선박 항적 조회 API (WebSocket 호환 CompactVesselTrack 응답)") +public class GisControllerV2 { + + private final GisService gisService; + private final GisServiceV2 gisServiceV2; + private final VesselPositionService vesselPositionService; + + @GetMapping("/haegu/boundaries") + @Operation( + summary = "해구 경계 조회", + description = "모든 해구의 경계 정보를 GeoJSON 형식으로 반환합니다." + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "조회 성공", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = GisBoundaryResponse.class)))) + }) + public List getHaeguBoundaries() { + return gisService.getHaeguBoundaries(); + } + + @GetMapping("/haegu/vessel-stats") + @Operation( + summary = "해구별 선박 통계", + description = "지정된 시간 범위 내의 해구별 선박 통계를 조회합니다." + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "조회 성공") + }) + public Map getHaeguVesselStats( + @Parameter(description = "조회 시간 범위 (분)", example = "60") + @RequestParam(defaultValue = "60") int minutes) { + return gisService.getHaeguVesselStats(minutes); + } + + @GetMapping("/areas/boundaries") + @Operation( + summary = "사용자 정의 영역 경계 조회", + description = "모든 사용자 정의 영역의 경계 정보를 GeoJSON 형식으로 반환합니다." + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "조회 성공", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = GisBoundaryResponse.class)))) + }) + public List getAreaBoundaries() { + return gisService.getAreaBoundaries(); + } + + @GetMapping("/areas/vessel-stats") + @Operation( + summary = "영역별 선박 통계", + description = "지정된 시간 범위 내의 영역별 선박 통계를 조회합니다." + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "조회 성공") + }) + public Map getAreaVesselStats( + @Parameter(description = "조회 시간 범위 (분)", example = "60") + @RequestParam(defaultValue = "60") int minutes) { + return gisService.getAreaVesselStats(minutes); + } + + @GetMapping("/tracks/haegu/{haeguNo}") + @Operation( + summary = "해구별 선박 항적 조회", + description = """ + 특정 해구 내 선박 항적을 CompactVesselTrack 형식으로 반환합니다. + + **V1과의 차이점:** + - 선박별로 병합된 단일 객체 반환 (V1은 세그먼트별 분리) + - geometry: [[lon, lat], ...] 배열 형태 (V1은 WKT) + - timestamps, speeds 배열 포함 + - 선박 정보(shipName, shipType, nationalCode) 포함 + - 통합선박 필터링 지원 + """ + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "조회 성공", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = CompactVesselTrack.class)))) + }) + public List getHaeguTracks( + @Parameter(description = "해구 번호", example = "1", required = true) + @PathVariable Integer haeguNo, + @Parameter(description = "조회 시간 범위 (분, 최대 1440)", example = "60") + @RequestParam(defaultValue = "60") int minutes, + @Parameter(description = "통합선박 필터링 (0: 미적용, 1: 적용)", example = "0", + schema = @Schema(allowableValues = {"0", "1"})) + @RequestParam(defaultValue = "0") String isIntegration) { + + boolean filterByIntegration = "1".equals(isIntegration); + return gisServiceV2.getHaeguTracks(haeguNo, minutes, filterByIntegration); + } + + @GetMapping("/tracks/area/{areaId}") + @Operation( + summary = "영역별 선박 항적 조회", + description = """ + 특정 영역 내 선박 항적을 CompactVesselTrack 형식으로 반환합니다. + + **V1과의 차이점:** + - 선박별로 병합된 단일 객체 반환 (V1은 세그먼트별 분리) + - geometry: [[lon, lat], ...] 배열 형태 (V1은 WKT) + - timestamps, speeds 배열 포함 + - 선박 정보(shipName, shipType, nationalCode) 포함 + - 통합선박 필터링 지원 + """ + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "조회 성공", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = CompactVesselTrack.class)))) + }) + public List getAreaTracks( + @Parameter(description = "영역 ID", example = "AREA_001", required = true) + @PathVariable String areaId, + @Parameter(description = "조회 시간 범위 (분, 최대 1440)", example = "60") + @RequestParam(defaultValue = "60") int minutes, + @Parameter(description = "통합선박 필터링 (0: 미적용, 1: 적용)", example = "0", + schema = @Schema(allowableValues = {"0", "1"})) + @RequestParam(defaultValue = "0") String isIntegration) { + + boolean filterByIntegration = "1".equals(isIntegration); + return gisServiceV2.getAreaTracks(areaId, minutes, filterByIntegration); + } + + @PostMapping("/tracks/vessels") + @Operation( + summary = "선박별 항적 조회", + description = """ + 지정된 선박 목록의 항적을 시간 범위 내에서 조회합니다. + + **응답 구조:** CompactVesselTrack (WebSocket과 동일) + - 선박별 병합된 단일 객체 + - geometry/timestamps/speeds 배열 포함 + - 선박 메타정보 포함 + """ + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "조회 성공", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = CompactVesselTrack.class)))) + }) + public List getVesselTracks( + @io.swagger.v3.oas.annotations.parameters.RequestBody( + description = "선박 항적 조회 요청", + required = true, + content = @Content(schema = @Schema(implementation = VesselTracksRequest.class)) + ) + @RequestBody VesselTracksRequest request) { + return gisService.getVesselTracks(request); + } + + @GetMapping("/vessels/recent-positions") + @Operation( + summary = "최근 위치 업데이트된 선박 조회", + description = "지정된 시간(분) 이내에 위치가 업데이트된 모든 선박의 최신 위치 정보를 반환합니다." + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "조회 성공", + content = @Content(array = @ArraySchema(schema = @Schema(implementation = RecentVesselPositionDto.class)))), + @ApiResponse(responseCode = "400", description = "잘못된 파라미터 (minutes: 1~1440)") + }) + public List getRecentVesselPositions( + @Parameter(description = "조회 시간 범위 (분, 1~1440)", example = "5") + @RequestParam(defaultValue = "5") int minutes) { + + if (minutes <= 0 || minutes > 1440) { + throw new IllegalArgumentException("Minutes must be between 1 and 1440"); + } + + return vesselPositionService.getRecentVesselPositions(minutes); + } +} diff --git a/src/main/java/gc/mda/signal_batch/domain/gis/dto/GisBoundaryResponse.java b/src/main/java/gc/mda/signal_batch/domain/gis/dto/GisBoundaryResponse.java index 9d790fd..25d06e4 100644 --- a/src/main/java/gc/mda/signal_batch/domain/gis/dto/GisBoundaryResponse.java +++ b/src/main/java/gc/mda/signal_batch/domain/gis/dto/GisBoundaryResponse.java @@ -1,27 +1,39 @@ package gc.mda.signal_batch.domain.gis.dto; import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Builder; import lombok.Data; @Data @Builder +@Schema(description = "해구/영역 경계 정보") public class GisBoundaryResponse { + @JsonProperty("haegu_no") + @Schema(description = "해구 번호 (해구 조회 시, 1~약 200)", example = "93") private Integer haeguNo; - + @JsonProperty("area_id") + @Schema(description = "영역 ID (영역 조회 시)", example = "AREA_YEOSU_01") private String areaId; - + @JsonProperty("area_name") + @Schema(description = "영역명", example = "여수 연안") private String areaName; - + @JsonProperty("geom_json") - private String geomJson; // GeoJSON 형식의 geometry - + @Schema( + description = "GeoJSON 형식의 geometry (Polygon)", + example = "{\"type\":\"Polygon\",\"coordinates\":[[[127.0,34.2],[127.1,34.2],[127.1,34.3],[127.0,34.3],[127.0,34.2]]]}" + ) + private String geomJson; + @JsonProperty("center_lat") + @Schema(description = "중심점 위도", example = "34.22") private Double centerLat; - + @JsonProperty("center_lon") + @Schema(description = "중심점 경도", example = "127.05") private Double centerLon; } diff --git a/src/main/java/gc/mda/signal_batch/domain/gis/dto/TileAggregationRequest.java b/src/main/java/gc/mda/signal_batch/domain/gis/dto/TileAggregationRequest.java index 3b51cb2..9a7217d 100644 --- a/src/main/java/gc/mda/signal_batch/domain/gis/dto/TileAggregationRequest.java +++ b/src/main/java/gc/mda/signal_batch/domain/gis/dto/TileAggregationRequest.java @@ -1,5 +1,8 @@ package gc.mda.signal_batch.domain.gis.dto; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import gc.mda.signal_batch.global.config.FlexibleLocalDateTimeDeserializer; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -11,8 +14,31 @@ import java.time.LocalDateTime; @Builder @NoArgsConstructor @AllArgsConstructor +@Schema(description = "타일 집계 조회 요청") public class TileAggregationRequest { + + @Schema( + description = """ + 조회 시작 시간. + 지원 형식: ISO 8601 (2026-01-20T00:00:00, 2026-01-20T00:00:00Z), + 표준 형식 (2026-01-20 00:00:00), 압축 형식 (20260120000000) + """, + example = "2026-01-20T00:00:00" + ) + @JsonDeserialize(using = FlexibleLocalDateTimeDeserializer.class) private LocalDateTime fromDate; + + @Schema( + description = """ + 조회 종료 시간. + 지원 형식: ISO 8601 (2026-01-20T23:59:59, 2026-01-20T23:59:59Z), + 표준 형식 (2026-01-20 23:59:59), 압축 형식 (20260120235959) + """, + example = "2026-01-20T23:59:59" + ) + @JsonDeserialize(using = FlexibleLocalDateTimeDeserializer.class) private LocalDateTime toDate; + + @Schema(description = "타일 ID", example = "12_3456_7890") private String tileId; } diff --git a/src/main/java/gc/mda/signal_batch/domain/gis/service/GisService.java b/src/main/java/gc/mda/signal_batch/domain/gis/service/GisService.java index 8e4e963..37c79f7 100644 --- a/src/main/java/gc/mda/signal_batch/domain/gis/service/GisService.java +++ b/src/main/java/gc/mda/signal_batch/domain/gis/service/GisService.java @@ -8,6 +8,8 @@ import gc.mda.signal_batch.domain.vessel.dto.CompactVesselTrack; import gc.mda.signal_batch.domain.vessel.dto.IntegrationVessel; import gc.mda.signal_batch.domain.vessel.service.IntegrationVesselService; import gc.mda.signal_batch.global.util.IntegrationSignalConstants; +import gc.mda.signal_batch.global.util.NationalCodeUtil; +import gc.mda.signal_batch.global.util.ShipKindCodeConverter; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.jdbc.core.JdbcTemplate; @@ -568,11 +570,22 @@ public class GisService { // Get vessel info Map vesselInfo = getVesselInfo(vessel.getSigSrcCd(), vessel.getTargetId()); - + String shipName = vesselInfo.get("ship_name"); + String shipType = vesselInfo.get("ship_type"); + + // Calculate nationalCode (same as WebSocket) + String nationalCode = NationalCodeUtil.calculateNationalCode( + vessel.getSigSrcCd(), vessel.getTargetId()); + + // Calculate shipKindCode (same as WebSocket - using name pattern matching for buoy/net detection) + String shipKindCode = ShipKindCodeConverter.getShipKindCodeWithNamePattern( + vessel.getSigSrcCd(), shipType, shipName, vessel.getTargetId()); + return CompactVesselTrack.builder() .vesselId(vesselId) .sigSrcCd(vessel.getSigSrcCd()) .targetId(vessel.getTargetId()) + .nationalCode(nationalCode) .geometry(geometry) .timestamps(timestamps) .speeds(speeds) @@ -580,9 +593,9 @@ public class GisService { .avgSpeed(avgSpeed) .maxSpeed(maxSpeed) .pointCount(geometry.size()) - .shipName(vesselInfo.get("ship_name")) - .shipType(vesselInfo.get("ship_type")) - .shipKindCode(null) // Not available in current schema + .shipName(shipName) + .shipType(shipType) + .shipKindCode(shipKindCode) .build(); } 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 new file mode 100644 index 0000000..f80dbb7 --- /dev/null +++ b/src/main/java/gc/mda/signal_batch/domain/gis/service/GisServiceV2.java @@ -0,0 +1,370 @@ +package gc.mda.signal_batch.domain.gis.service; + +import gc.mda.signal_batch.domain.vessel.dto.CompactVesselTrack; +import gc.mda.signal_batch.domain.vessel.dto.TrackResponse; +import gc.mda.signal_batch.domain.vessel.dto.IntegrationVessel; +import gc.mda.signal_batch.domain.vessel.service.IntegrationVesselService; +import gc.mda.signal_batch.global.util.IntegrationSignalConstants; +import gc.mda.signal_batch.global.util.TrackConverter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +import javax.sql.DataSource; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.time.LocalDateTime; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.stream.Collectors; + +/** + * GIS 서비스 V2 - CompactVesselTrack 기반 응답 + * WebSocket API와 동일한 응답 구조 제공 + */ +@Slf4j +@Service +public class GisServiceV2 { + + private final DataSource queryDataSource; + private final IntegrationVesselService integrationVesselService; + + // 선박 정보 캐시 (TTL: 1시간) + private final ConcurrentHashMap vesselInfoCache = new ConcurrentHashMap<>(); + private static final long VESSEL_CACHE_TTL = 3600_000; // 1시간 + + public GisServiceV2(@Qualifier("queryDataSource") DataSource queryDataSource, + IntegrationVesselService integrationVesselService) { + this.queryDataSource = queryDataSource; + this.integrationVesselService = integrationVesselService; + } + + /** + * 해구별 선박 항적 조회 (V2 - CompactVesselTrack 반환) + */ + public List getHaeguTracks(Integer haeguNo, int minutes, boolean filterByIntegration) { + JdbcTemplate jdbcTemplate = new JdbcTemplate(queryDataSource); + List rawTracks = new ArrayList<>(); + + LocalDateTime now = LocalDateTime.now(); + LocalDateTime startTime = now.minusMinutes(minutes); + + if (minutes > 60) { + LocalDateTime currentHour = now.withMinute(0).withSecond(0).withNano(0); + + if (minutes <= 1440) { + // hourly 테이블에서 과거 데이터 조회 + String hourlySql = """ + SELECT DISTINCT t.sig_src_cd, t.target_id, t.time_bucket, + public.ST_AsText(t.track_geom) as track_geom, + t.distance_nm, t.avg_speed, t.max_speed, t.point_count + FROM signal.t_vessel_tracks_hourly t + WHERE EXISTS ( + SELECT 1 FROM signal.t_grid_vessel_tracks g + WHERE g.sig_src_cd = t.sig_src_cd + AND g.target_id = t.target_id + AND g.haegu_no = %d + AND g.time_bucket >= '%s' + ) + AND t.time_bucket >= '%s' + AND t.time_bucket < '%s' + ORDER BY t.sig_src_cd, t.target_id, t.time_bucket + """.formatted(haeguNo, startTime, startTime, currentHour); + + rawTracks.addAll(jdbcTemplate.query(hourlySql, this::mapTrackResponse)); + } + + // 5min 테이블에서 최근 데이터 조회 + String recentSql = """ + SELECT DISTINCT t.sig_src_cd, t.target_id, t.time_bucket, + public.ST_AsText(t.track_geom) as track_geom, + t.distance_nm, t.avg_speed, t.max_speed, t.point_count + FROM signal.t_vessel_tracks_5min t + WHERE EXISTS ( + SELECT 1 FROM signal.t_grid_vessel_tracks g + WHERE g.sig_src_cd = t.sig_src_cd + AND g.target_id = t.target_id + AND g.haegu_no = %d + AND g.time_bucket >= '%s' + ) + AND t.time_bucket >= '%s' + ORDER BY t.sig_src_cd, t.target_id, t.time_bucket + """.formatted(haeguNo, startTime, currentHour); + + rawTracks.addAll(jdbcTemplate.query(recentSql, this::mapTrackResponse)); + + } else { + // 1시간 이하는 5분 테이블만 사용 + String sql = """ + SELECT DISTINCT t.sig_src_cd, t.target_id, t.time_bucket, + public.ST_AsText(t.track_geom) as track_geom, + t.distance_nm, t.avg_speed, t.max_speed, t.point_count + FROM signal.t_vessel_tracks_5min t + WHERE EXISTS ( + SELECT 1 FROM signal.t_grid_vessel_tracks g + WHERE g.sig_src_cd = t.sig_src_cd + AND g.target_id = t.target_id + AND g.haegu_no = %d + AND g.time_bucket >= NOW() - INTERVAL '%d minutes' + ) + AND t.time_bucket >= NOW() - INTERVAL '%d minutes' + ORDER BY t.sig_src_cd, t.target_id, t.time_bucket + """.formatted(haeguNo, minutes, minutes); + + rawTracks = jdbcTemplate.query(sql, this::mapTrackResponse); + } + + // CompactVesselTrack으로 변환 + List result = TrackConverter.convert(rawTracks, this::getVesselInfo); + + // 통합선박 필터링 적용 + if (filterByIntegration && integrationVesselService.isEnabled()) { + result = filterByIntegration(result); + } + + log.debug("V2 API: Fetched {} compact tracks for haegu {} in last {} minutes", + result.size(), haeguNo, minutes); + + return result; + } + + /** + * 영역별 선박 항적 조회 (V2 - CompactVesselTrack 반환) + */ + public List getAreaTracks(String areaId, int minutes, boolean filterByIntegration) { + JdbcTemplate jdbcTemplate = new JdbcTemplate(queryDataSource); + List rawTracks = new ArrayList<>(); + + LocalDateTime now = LocalDateTime.now(); + LocalDateTime startTime = now.minusMinutes(minutes); + + if (minutes > 60) { + LocalDateTime currentHour = now.withMinute(0).withSecond(0).withNano(0); + + if (minutes <= 1440) { + // hourly 테이블에서 과거 데이터 조회 + String hourlySql = """ + SELECT DISTINCT t.sig_src_cd, t.target_id, t.time_bucket, + public.ST_AsText(t.track_geom) as track_geom, + t.distance_nm, t.avg_speed, t.max_speed, t.point_count + FROM signal.t_vessel_tracks_hourly t + WHERE EXISTS ( + SELECT 1 FROM signal.t_area_vessel_tracks a + WHERE a.sig_src_cd = t.sig_src_cd + AND a.target_id = t.target_id + AND a.area_id = '%s' + AND a.time_bucket >= '%s' + ) + AND t.time_bucket >= '%s' + AND t.time_bucket < '%s' + ORDER BY t.sig_src_cd, t.target_id, t.time_bucket + """.formatted(areaId, startTime, startTime, currentHour); + + rawTracks.addAll(jdbcTemplate.query(hourlySql, this::mapTrackResponse)); + } + + // 5min 테이블에서 최근 데이터 조회 + String recentSql = """ + SELECT DISTINCT t.sig_src_cd, t.target_id, t.time_bucket, + public.ST_AsText(t.track_geom) as track_geom, + t.distance_nm, t.avg_speed, t.max_speed, t.point_count + FROM signal.t_vessel_tracks_5min t + WHERE EXISTS ( + SELECT 1 FROM signal.t_area_vessel_tracks a + WHERE a.sig_src_cd = t.sig_src_cd + AND a.target_id = t.target_id + AND a.area_id = '%s' + AND a.time_bucket >= '%s' + ) + AND t.time_bucket >= '%s' + ORDER BY t.sig_src_cd, t.target_id, t.time_bucket + """.formatted(areaId, startTime, currentHour); + + rawTracks.addAll(jdbcTemplate.query(recentSql, this::mapTrackResponse)); + + } else { + // 1시간 이하는 5분 테이블만 사용 + String sql = """ + SELECT DISTINCT t.sig_src_cd, t.target_id, t.time_bucket, + public.ST_AsText(t.track_geom) as track_geom, + t.distance_nm, t.avg_speed, t.max_speed, t.point_count + FROM signal.t_vessel_tracks_5min t + WHERE EXISTS ( + SELECT 1 FROM signal.t_area_vessel_tracks a + WHERE a.sig_src_cd = t.sig_src_cd + AND a.target_id = t.target_id + AND a.area_id = '%s' + AND a.time_bucket >= NOW() - INTERVAL '%d minutes' + ) + AND t.time_bucket >= NOW() - INTERVAL '%d minutes' + ORDER BY t.sig_src_cd, t.target_id, t.time_bucket + """.formatted(areaId, minutes, minutes); + + rawTracks = jdbcTemplate.query(sql, this::mapTrackResponse); + } + + // CompactVesselTrack으로 변환 + List result = TrackConverter.convert(rawTracks, this::getVesselInfo); + + // 통합선박 필터링 적용 + if (filterByIntegration && integrationVesselService.isEnabled()) { + result = filterByIntegration(result); + } + + log.debug("V2 API: Fetched {} compact tracks for area {} in last {} minutes", + result.size(), areaId, minutes); + + return result; + } + + /** + * TrackResponse 매핑 + */ + private TrackResponse mapTrackResponse(ResultSet rs, int rowNum) throws SQLException { + return TrackResponse.builder() + .sigSrcCd(rs.getString("sig_src_cd")) + .targetId(rs.getString("target_id")) + .timeBucket(rs.getObject("time_bucket", LocalDateTime.class)) + .trackGeom(rs.getString("track_geom")) + .distanceNm(rs.getBigDecimal("distance_nm")) + .avgSpeed(rs.getBigDecimal("avg_speed")) + .maxSpeed(rs.getBigDecimal("max_speed")) + .pointCount(rs.getInt("point_count")) + .build(); + } + + /** + * 선박 정보 조회 (캐시 우선) + */ + private TrackConverter.VesselInfo getVesselInfo(String sigSrcCd, String targetId) { + String cacheKey = sigSrcCd + "_" + targetId; + + VesselInfoCache cached = vesselInfoCache.get(cacheKey); + if (cached != null && !cached.isExpired()) { + return new TrackConverter.VesselInfo(cached.shipName, cached.shipType); + } + + // DB 조회 + JdbcTemplate jdbcTemplate = new JdbcTemplate(queryDataSource); + try { + String sql = """ + SELECT ship_nm, ship_ty + FROM signal.t_vessel_latest_position + WHERE sig_src_cd = ? AND target_id = ? + LIMIT 1 + """; + + Map result = jdbcTemplate.queryForMap(sql, sigSrcCd, targetId); + String shipName = result.get("ship_nm") != null ? result.get("ship_nm").toString() : "-"; + String shipType = result.get("ship_ty") != null ? result.get("ship_ty").toString() : "-"; + + // 캐시 저장 + vesselInfoCache.put(cacheKey, new VesselInfoCache(shipName, shipType)); + + return new TrackConverter.VesselInfo(shipName, shipType); + } catch (Exception e) { + return new TrackConverter.VesselInfo("-", "-"); + } + } + + /** + * 통합선박 기준 필터링 + */ + private List filterByIntegration(List tracks) { + if (tracks == null || tracks.isEmpty()) { + return tracks; + } + + // 1. 모든 트랙의 통합선박 정보 조회 + Map vesselIntegrations = new HashMap<>(); + for (CompactVesselTrack track : tracks) { + String key = track.getSigSrcCd() + "_" + track.getTargetId(); + if (!vesselIntegrations.containsKey(key)) { + IntegrationVessel integration = integrationVesselService.findByVessel( + track.getSigSrcCd(), track.getTargetId() + ); + vesselIntegrations.put(key, integration); + } + } + + // 2. 통합선박별 그룹핑 + Map> groupedByIntegration = new HashMap<>(); + Map integrationMap = new HashMap<>(); + + long tempSeq = -1; + for (CompactVesselTrack track : tracks) { + String key = track.getSigSrcCd() + "_" + track.getTargetId(); + IntegrationVessel integration = vesselIntegrations.get(key); + + Long seq; + if (integration != null) { + seq = integration.getIntgrSeq(); + integrationMap.putIfAbsent(seq, integration); + } else { + seq = tempSeq--; + } + + groupedByIntegration.computeIfAbsent(seq, k -> new ArrayList<>()).add(track); + } + + // 3. 각 그룹에서 최고 우선순위 신호만 선택 + List result = new ArrayList<>(); + + for (Map.Entry> entry : groupedByIntegration.entrySet()) { + Long seq = entry.getKey(); + List groupTracks = entry.getValue(); + + if (seq < 0) { + // 통합정보 없는 단독 선박 + CompactVesselTrack firstTrack = groupTracks.get(0); + String soloIntegrationId = IntegrationSignalConstants.generateSoloIntegrationId( + firstTrack.getSigSrcCd(), + firstTrack.getTargetId() + ); + groupTracks.forEach(t -> t.setIntegrationTargetId(soloIntegrationId)); + result.addAll(groupTracks); + } else { + // 통합선박 → 존재하는 신호 중 최고 우선순위 선택 + IntegrationVessel integration = integrationMap.get(seq); + + Set existingSigSrcCds = groupTracks.stream() + .map(CompactVesselTrack::getSigSrcCd) + .collect(Collectors.toSet()); + + String selectedSigSrcCd = integrationVesselService.selectHighestPriorityFromExisting(existingSigSrcCds); + + List selectedTracks = groupTracks.stream() + .filter(t -> t.getSigSrcCd().equals(selectedSigSrcCd)) + .collect(Collectors.toList()); + + String integrationId = integration.generateIntegrationId(); + selectedTracks.forEach(t -> t.setIntegrationTargetId(integrationId)); + + result.addAll(selectedTracks); + } + } + + log.info("[INTEGRATION_FILTER] V2 API - Filtered {} tracks to {} tracks", tracks.size(), result.size()); + return result; + } + + /** + * 선박 정보 캐시 내부 클래스 + */ + private static class VesselInfoCache { + String shipName; + String shipType; + long cacheTime; + + VesselInfoCache(String shipName, String shipType) { + this.shipName = shipName; + this.shipType = shipType; + this.cacheTime = System.currentTimeMillis(); + } + + boolean isExpired() { + return System.currentTimeMillis() - cacheTime > VESSEL_CACHE_TTL; + } + } +} diff --git a/src/main/java/gc/mda/signal_batch/domain/passage/dto/SequentialPassageRequest.java b/src/main/java/gc/mda/signal_batch/domain/passage/dto/SequentialPassageRequest.java index 19172d9..f250a14 100644 --- a/src/main/java/gc/mda/signal_batch/domain/passage/dto/SequentialPassageRequest.java +++ b/src/main/java/gc/mda/signal_batch/domain/passage/dto/SequentialPassageRequest.java @@ -1,5 +1,7 @@ package gc.mda.signal_batch.domain.passage.dto; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import gc.mda.signal_batch.global.config.FlexibleLocalDateTimeDeserializer; import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; import lombok.Builder; @@ -17,13 +19,31 @@ import java.util.List; @AllArgsConstructor @Schema(description = "순차 구역 통과 조회 요청") public class SequentialPassageRequest { - + @NotNull(message = "조회 시작 시간은 필수입니다") - @Schema(description = "조회 시작 시간", example = "2025-08-01T00:00:00", required = true) + @Schema( + description = """ + 조회 시작 시간. + 지원 형식: ISO 8601 (2025-08-01T00:00:00, 2025-08-01T00:00:00Z), + 표준 형식 (2025-08-01 00:00:00), 압축 형식 (20250801000000) + """, + example = "2025-08-01T00:00:00", + required = true + ) + @JsonDeserialize(using = FlexibleLocalDateTimeDeserializer.class) private LocalDateTime startTime; - + @NotNull(message = "조회 종료 시간은 필수입니다") - @Schema(description = "조회 종료 시간", example = "2025-08-07T23:59:59", required = true) + @Schema( + description = """ + 조회 종료 시간. + 지원 형식: ISO 8601 (2025-08-07T23:59:59, 2025-08-07T23:59:59Z), + 표준 형식 (2025-08-07 23:59:59), 압축 형식 (20250807235959) + """, + example = "2025-08-07T23:59:59", + required = true + ) + @JsonDeserialize(using = FlexibleLocalDateTimeDeserializer.class) private LocalDateTime endTime; @Schema(description = "조회 유형 (GRID: 해구, AREA: 사용자정의구역)", example = "GRID", required = true) diff --git a/src/main/java/gc/mda/signal_batch/domain/passage/service/SequentialAreaTrackingService.java b/src/main/java/gc/mda/signal_batch/domain/passage/service/SequentialAreaTrackingService.java index 925dcd9..5fc9f3e 100644 --- a/src/main/java/gc/mda/signal_batch/domain/passage/service/SequentialAreaTrackingService.java +++ b/src/main/java/gc/mda/signal_batch/domain/passage/service/SequentialAreaTrackingService.java @@ -44,11 +44,11 @@ public class SequentialAreaTrackingService { sig_src_cd, target_id, haegu_no, - FIRpublic.ST_VALUE(time_bucket) OVER ( + FIRST_VALUE(time_bucket) OVER ( PARTITION BY sig_src_cd, target_id, haegu_no ORDER BY time_bucket ) as entry_time, - LApublic.ST_VALUE(time_bucket) OVER ( + LAST_VALUE(time_bucket) OVER ( PARTITION BY sig_src_cd, target_id, haegu_no ORDER BY time_bucket ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING @@ -103,11 +103,11 @@ public class SequentialAreaTrackingService { sig_src_cd, target_id, area_id, - FIRpublic.ST_VALUE(time_bucket) OVER ( + FIRST_VALUE(time_bucket) OVER ( PARTITION BY sig_src_cd, target_id, area_id ORDER BY time_bucket ) as entry_time, - LApublic.ST_VALUE(time_bucket) OVER ( + LAST_VALUE(time_bucket) OVER ( PARTITION BY sig_src_cd, target_id, area_id ORDER BY time_bucket ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING diff --git a/src/main/java/gc/mda/signal_batch/domain/ship/controller/ShipImageController.java b/src/main/java/gc/mda/signal_batch/domain/ship/controller/ShipImageController.java new file mode 100644 index 0000000..39a15e9 --- /dev/null +++ b/src/main/java/gc/mda/signal_batch/domain/ship/controller/ShipImageController.java @@ -0,0 +1,56 @@ +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; + +@Slf4j +@RestController +@RequestMapping("/api/v1/shipimg") +@RequiredArgsConstructor +@Tag(name = "Ship Image", description = "선박 이미지 API") +public class ShipImageController { + + 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("Requesting ship images for IMO: {}", imo); + + List images = shipImageService.getImagesByImo(imo); + + log.debug("Found {} images for IMO: {}", images.size(), imo); + + return ResponseEntity.ok(images); + } +} diff --git a/src/main/java/gc/mda/signal_batch/domain/ship/dto/ShipImageDto.java b/src/main/java/gc/mda/signal_batch/domain/ship/dto/ShipImageDto.java new file mode 100644 index 0000000..db77554 --- /dev/null +++ b/src/main/java/gc/mda/signal_batch/domain/ship/dto/ShipImageDto.java @@ -0,0 +1,18 @@ +package gc.mda.signal_batch.domain.ship.dto; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.Builder; +import lombok.Data; + +import java.time.LocalDate; + +@Data +@Builder +public class ShipImageDto { + private Integer picId; + private String path; + private String copyright; + + @JsonFormat(pattern = "yyyy-MM-dd") + private LocalDate date; +} diff --git a/src/main/java/gc/mda/signal_batch/domain/ship/repository/ShipImageRepository.java b/src/main/java/gc/mda/signal_batch/domain/ship/repository/ShipImageRepository.java new file mode 100644 index 0000000..a97989f --- /dev/null +++ b/src/main/java/gc/mda/signal_batch/domain/ship/repository/ShipImageRepository.java @@ -0,0 +1,58 @@ +package gc.mda.signal_batch.domain.ship.repository; + +import gc.mda.signal_batch.domain.ship.dto.ShipImageDto; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Repository; + +import java.sql.Date; +import java.util.List; + +@Slf4j +@Repository +public class ShipImageRepository { + + private final JdbcTemplate queryJdbcTemplate; + + public ShipImageRepository(@Qualifier("queryJdbcTemplate") JdbcTemplate queryJdbcTemplate) { + this.queryJdbcTemplate = queryJdbcTemplate; + } + + private static final String FIND_BY_IMO_SQL = """ + SELECT pic_id, copyright, dateofphoto + FROM signal.t_snp_ship_img + WHERE lrno = ? + ORDER BY dateofphoto DESC NULLS LAST, pic_id DESC + """; + + private static final RowMapper ROW_MAPPER = (rs, rowNum) -> { + ShipImageRawData data = new ShipImageRawData(); + data.picId = rs.getInt("pic_id"); + data.copyright = rs.getString("copyright"); + Date dateOfPhoto = rs.getDate("dateofphoto"); + data.dateOfPhoto = dateOfPhoto != null ? dateOfPhoto.toLocalDate() : null; + return data; + }; + + /** + * IMO(lrno)로 이미지 정보 조회 + * + * @param imo IMO 번호 + * @return 이미지 raw 데이터 목록 + */ + public List findByImo(Integer imo) { + return queryJdbcTemplate.query(FIND_BY_IMO_SQL, ROW_MAPPER, imo); + } + + /** + * Repository 내부에서 사용하는 Raw 데이터 클래스 + */ + public static class ShipImageRawData { + public Integer picId; + public String copyright; + public java.time.LocalDate dateOfPhoto; + } +} diff --git a/src/main/java/gc/mda/signal_batch/domain/ship/service/ShipImageService.java b/src/main/java/gc/mda/signal_batch/domain/ship/service/ShipImageService.java new file mode 100644 index 0000000..eb80200 --- /dev/null +++ b/src/main/java/gc/mda/signal_batch/domain/ship/service/ShipImageService.java @@ -0,0 +1,58 @@ +package gc.mda.signal_batch.domain.ship.service; + +import gc.mda.signal_batch.domain.ship.dto.ShipImageDto; +import gc.mda.signal_batch.domain.ship.repository.ShipImageRepository; +import gc.mda.signal_batch.domain.ship.repository.ShipImageRepository.ShipImageRawData; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +public class ShipImageService { + + private final ShipImageRepository shipImageRepository; + + /** + * IMO로 이미지 정보 목록 조회 + * + * @param imo IMO 번호 + * @return 이미지 정보 목록 (없으면 빈 배열) + */ + public List getImagesByImo(Integer imo) { + List rawDataList = shipImageRepository.findByImo(imo); + + return rawDataList.stream() + .map(this::toDto) + .collect(Collectors.toList()); + } + + /** + * Raw 데이터를 DTO로 변환 + */ + private ShipImageDto toDto(ShipImageRawData rawData) { + return ShipImageDto.builder() + .picId(rawData.picId) + .path(buildImagePath(rawData.picId)) + .copyright(rawData.copyright) + .date(rawData.dateOfPhoto) + .build(); + } + + /** + * pic_id로 이미지 경로 생성 + * 규칙: 폴더명 = pic_id / 100 + * 예: pic_id=816100 → /shipimg/8161/816100 + * + * @param picId pic_id + * @return 이미지 경로 + */ + private String buildImagePath(Integer picId) { + int folderName = picId / 100; + return String.format("/shipimg/%d/%d", folderName, picId); + } +} diff --git a/src/main/java/gc/mda/signal_batch/domain/track/controller/AbnormalTrackController.java b/src/main/java/gc/mda/signal_batch/domain/track/controller/AbnormalTrackController.java index 45b278b..12cfc72 100644 --- a/src/main/java/gc/mda/signal_batch/domain/track/controller/AbnormalTrackController.java +++ b/src/main/java/gc/mda/signal_batch/domain/track/controller/AbnormalTrackController.java @@ -1,10 +1,13 @@ package gc.mda.signal_batch.domain.track.controller; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; import gc.mda.signal_batch.domain.track.dto.AbnormalTrackResponse; import gc.mda.signal_batch.domain.track.dto.AbnormalTrackStatsResponse; import gc.mda.signal_batch.domain.track.service.AbnormalTrackService; +import gc.mda.signal_batch.global.config.FlexibleLocalDateTimeDeserializer; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -17,7 +20,6 @@ import java.time.LocalDateTime; import java.util.List; import java.util.Map; import java.math.BigDecimal; -import com.fasterxml.jackson.annotation.JsonFormat; /** @@ -135,31 +137,67 @@ public class AbnormalTrackController { @lombok.Data static class DetectRequest { - private String tableType; // "hourly" or "daily" - @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) - @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") + @Schema(description = "테이블 유형", example = "hourly", allowableValues = {"hourly", "daily"}) + private String tableType; + + @Schema( + description = """ + 조회 시작 시간. + 지원 형식: ISO 8601, 표준 형식 (YYYY-MM-DD HH:MM:SS), 압축 형식 (YYYYMMDDHHMMSS) + """, + example = "2026-01-20T00:00:00" + ) + @JsonDeserialize(using = FlexibleLocalDateTimeDeserializer.class) private LocalDateTime startTime; - @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) - @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") + + @Schema( + description = """ + 조회 종료 시간. + 지원 형식: ISO 8601, 표준 형식 (YYYY-MM-DD HH:MM:SS), 압축 형식 (YYYYMMDDHHMMSS) + """, + example = "2026-01-20T23:59:59" + ) + @JsonDeserialize(using = FlexibleLocalDateTimeDeserializer.class) private LocalDateTime endTime; - private BigDecimal minDistance; // 최소 거리 (nm) - private BigDecimal minSpeed; // 최소 평균속도 (knots) + + @Schema(description = "최소 거리 (nm)", example = "10.0") + private BigDecimal minDistance; + + @Schema(description = "최소 평균속도 (knots)", example = "50.0") + private BigDecimal minSpeed; } - + @lombok.Data static class MoveTracksRequest { - private String tableType; // "hourly" or "daily" + @Schema(description = "테이블 유형", example = "hourly", allowableValues = {"hourly", "daily"}) + private String tableType; + + @Schema(description = "이동할 항적 목록") private List tracks; + + @Schema(description = "비정상 유형", example = "SPEED_ANOMALY") private String abnormalType; + + @Schema(description = "이동 사유", example = "물리적으로 불가능한 속도") private String reason; } - + @lombok.Data public static class TrackIdentifier { + @Schema(description = "신호 소스 코드", example = "000001") private String sigSrcCd; + + @Schema(description = "타겟 ID", example = "440123456") private String targetId; - @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) - @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") + + @Schema( + description = """ + 시간 버킷. + 지원 형식: ISO 8601, 표준 형식 (YYYY-MM-DD HH:MM:SS), 압축 형식 (YYYYMMDDHHMMSS) + """, + example = "2026-01-20T10:00:00" + ) + @JsonDeserialize(using = FlexibleLocalDateTimeDeserializer.class) private LocalDateTime timeBucket; } 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 c598eae..d646ad2 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 @@ -2,12 +2,15 @@ package gc.mda.signal_batch.domain.vessel.dto; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.ArraySchema; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; import lombok.NoArgsConstructor; import java.util.List; + /** * 압축된 선박 궤적 데이터 전송용 DTO * LineStringM 대신 단순 배열로 전송하여 프론트엔드 파싱 부하 제거 @@ -17,32 +20,80 @@ import java.util.List; @NoArgsConstructor @AllArgsConstructor @JsonInclude(JsonInclude.Include.NON_NULL) +@Schema(description = "선박별 병합된 항적 데이터 (WebSocket/REST 공통)") public class CompactVesselTrack { - private String vesselId; // sig_src_cd + "_" + target_id - private String sigSrcCd; - private String targetId; - private String nationalCode; // National code based on sigSrcCd and targetId - - // 궤적 데이터 (배열 형태) - private List geometry; // [[lon, lat], ...] - - // MIGRATION_V2: 호환성을 위해 모두 String으로 처리 - @JsonProperty("timestamps") - private List timestamps; // String 리스트 (Unix timestamp도 String으로 변환) - - private List speeds; // [12.5, 13.2, ...] - - // 메타데이터 - private Double totalDistance; // 전체 이동거리 (nm) - private Double avgSpeed; // 평균속도 (knots) - private Double maxSpeed; // 최대속도 (knots) - private Integer pointCount; // 포인트 수 - - // 선박 정보 - private String shipName; // 선명 - private String shipType; // 선종 - private String shipKindCode; // 선박 종류 코드 - // 통합선박 정보 - private String integrationTargetId; // 통합선박 ID (형식: AIS_ENAV_VPASS_VTS-AIS_D-MF/HF) + @Schema(description = "선박 고유 ID (sigSrcCd_targetId)", example = "000001_440113620") + private String vesselId; + + @Schema(description = "신호 소스 코드", example = "000001") + private String sigSrcCd; + + @Schema(description = "타겟 ID (MMSI 등)", example = "440113620") + private String targetId; + + @Schema(description = "국적 코드 (MID 기반, MMSI 앞 3자리로 판별)", example = "KR") + private String nationalCode; + + @ArraySchema( + schema = @Schema( + description = "좌표 [경도, 위도]", + type = "array", + example = "[127.0638, 34.227527]" + ), + arraySchema = @Schema( + description = "궤적 좌표 배열 [[lon, lat], ...] - 시간순 정렬", + example = "[[127.0638, 34.227527], [127.063303, 34.226685], [127.063008, 34.226203]]" + ) + ) + private List geometry; + + @ArraySchema( + schema = @Schema(description = "Unix timestamp (초)", example = "1768878312"), + arraySchema = @Schema( + description = "각 좌표에 대응하는 Unix timestamp 배열", + example = "[\"1768878312\", \"1768878332\", \"1768878342\"]" + ) + ) + @JsonProperty("timestamps") + private List timestamps; + + @ArraySchema( + schema = @Schema(description = "속도 (knots)", example = "10.4"), + arraySchema = @Schema( + description = "각 좌표에 대응하는 속도 배열 (knots)", + example = "[10.4, 10.4, 10.67]" + ) + ) + private List speeds; + + @Schema(description = "전체 이동거리 (해리)", example = "1.64") + private Double totalDistance; + + @Schema(description = "평균 속도 (knots)", example = "10.54") + private Double avgSpeed; + + @Schema(description = "최대 속도 (knots)", example = "10.9") + private Double maxSpeed; + + @Schema(description = "궤적 포인트 수", example = "38") + private Integer pointCount; + + @Schema(description = "선박명", example = "SAM SUNG 2HO") + private String shipName; + + @Schema(description = "선종 코드 (AIS ship type)", example = "74") + private String shipType; + + @Schema( + description = "선박 종류 코드 (000020:어선, 000021:함정, 000022:여객선, 000023:화물선, 000024:유조선, 000025:관공선, 000027:기타)", + example = "000023" + ) + private String shipKindCode; + + @Schema( + description = "통합선박 ID (동일 선박의 다중 신호원 통합 식별자)", + example = "440113620___440113620_" + ) + private String integrationTargetId; } diff --git a/src/main/java/gc/mda/signal_batch/domain/vessel/dto/IntegrationVessel.java b/src/main/java/gc/mda/signal_batch/domain/vessel/dto/IntegrationVessel.java index f6ed9dc..fb34f0e 100644 --- a/src/main/java/gc/mda/signal_batch/domain/vessel/dto/IntegrationVessel.java +++ b/src/main/java/gc/mda/signal_batch/domain/vessel/dto/IntegrationVessel.java @@ -8,7 +8,7 @@ import lombok.NoArgsConstructor; /** * 통합선박 정보 DTO - * gis.t_ship_integration_sub 테이블 매핑 + * signal.t_ship_integration_sub 테이블 매핑 */ @Data @Builder diff --git a/src/main/java/gc/mda/signal_batch/domain/vessel/dto/RecentVesselPositionDto.java b/src/main/java/gc/mda/signal_batch/domain/vessel/dto/RecentVesselPositionDto.java index 12a6d74..79e0f51 100644 --- a/src/main/java/gc/mda/signal_batch/domain/vessel/dto/RecentVesselPositionDto.java +++ b/src/main/java/gc/mda/signal_batch/domain/vessel/dto/RecentVesselPositionDto.java @@ -16,37 +16,44 @@ import java.time.LocalDateTime; @AllArgsConstructor @Schema(description = "최근 위치 업데이트된 선박 정보") public class RecentVesselPositionDto { - @Schema(description = "신호원 코드", example = "000001") + + @Schema( + description = "신호원 코드 (000001:AIS, 000002:LRIT, 000003:VPASS, 000004:VTS-AIS 등)", + example = "000001" + ) private String sigSrcCd; - - @Schema(description = "대상 ID", example = "440331240") + + @Schema(description = "대상 ID (MMSI: 9자리, 한국선박 440/441로 시작)", example = "440113620") private String targetId; - - @Schema(description = "경도", example = "126.9779") + + @Schema(description = "경도 (WGS84)", example = "127.0638") private Double lon; - - @Schema(description = "위도", example = "37.5665") + + @Schema(description = "위도 (WGS84)", example = "34.227527") private Double lat; - - @Schema(description = "대지속도 (knots)", example = "12.5") + + @Schema(description = "대지속도 (knots)", example = "10.4") private BigDecimal sog; - - @Schema(description = "대지침로 (도)", example = "180.0") + + @Schema(description = "대지침로 (도, 0~360)", example = "215.3") private BigDecimal cog; - - @Schema(description = "선박명", example = "SEOM SARANG 11") + + @Schema(description = "선박명", example = "SAM SUNG 2HO") private String shipNm; - - @Schema(description = "선박 유형", example = "60") + + @Schema(description = "선박 유형 (AIS ship type)", example = "74") private String shipTy; - - @Schema(description = "선박 종류 코드", example = "000022") + + @Schema( + description = "선박 종류 코드 (000020:어선, 000022:여객선, 000023:화물선 등)", + example = "000023" + ) private String shipKindCode; - - @Schema(description = "국가 코드", example = "440") + + @Schema(description = "국가 코드 (MID 기반, KR=한국)", example = "KR") private String nationalCode; - - @Schema(description = "최종 업데이트 시간", example = "2025-08-28 15:30:00") + + @Schema(description = "최종 업데이트 시간", example = "2026-01-20 12:05:00") @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss") private LocalDateTime lastUpdate; } \ No newline at end of file diff --git a/src/main/java/gc/mda/signal_batch/domain/vessel/dto/TrackResponse.java b/src/main/java/gc/mda/signal_batch/domain/vessel/dto/TrackResponse.java index 43cab6e..e7df90c 100644 --- a/src/main/java/gc/mda/signal_batch/domain/vessel/dto/TrackResponse.java +++ b/src/main/java/gc/mda/signal_batch/domain/vessel/dto/TrackResponse.java @@ -1,6 +1,7 @@ package gc.mda.signal_batch.domain.vessel.dto; import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Builder; import lombok.Data; @@ -9,28 +10,44 @@ import java.time.LocalDateTime; @Data @Builder +@Schema(description = "항적 세그먼트 (V1 API용, WKT 기반)") public class TrackResponse { + @JsonProperty("sig_src_cd") + @Schema( + description = "신호 소스 코드 (000001:AIS, 000002:LRIT, 000003:VPASS, 000004:VTS-AIS 등)", + example = "000001" + ) private String sigSrcCd; - + @JsonProperty("target_id") + @Schema(description = "타겟 ID (MMSI: 9자리, 한국선박 440/441로 시작)", example = "440113620") private String targetId; - + @JsonProperty("track_geom") - private String trackGeom; // WKT format - + @Schema( + description = "항적 geometry (WKT LineStringM 형식: X=경도, Y=위도, M=Unix timestamp)", + example = "LINESTRING M(127.0638 34.227527 1768878312, 127.063303 34.226685 1768878332, 127.063008 34.226203 1768878342)" + ) + private String trackGeom; + @JsonProperty("distance_nm") + @Schema(description = "이동거리 (해리)", example = "1.64") private BigDecimal distanceNm; - + @JsonProperty("avg_speed") + @Schema(description = "평균 속도 (knots)", example = "10.54") private BigDecimal avgSpeed; - + @JsonProperty("max_speed") + @Schema(description = "최대 속도 (knots)", example = "10.9") private BigDecimal maxSpeed; - + @JsonProperty("point_count") + @Schema(description = "포인트 수", example = "38") private Integer pointCount; - + @JsonProperty("time_bucket") + @Schema(description = "시간 버킷 (5분/1시간/1일 집계 단위)", example = "2026-01-20T12:00:00") private LocalDateTime timeBucket; } diff --git a/src/main/java/gc/mda/signal_batch/domain/vessel/dto/VesselBucketPositionDto.java b/src/main/java/gc/mda/signal_batch/domain/vessel/dto/VesselBucketPositionDto.java new file mode 100644 index 0000000..aab5ee7 --- /dev/null +++ b/src/main/java/gc/mda/signal_batch/domain/vessel/dto/VesselBucketPositionDto.java @@ -0,0 +1,56 @@ +package gc.mda.signal_batch.domain.vessel.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 선박 버킷 종료 위치 DTO + * + * 용도: 버킷 간 점프 검출을 위한 이전 버킷 종료 위치 저장 + * + * 데이터 흐름: + * 1. 5분 집계 완료 후 각 선박의 버킷 종료 위치 저장 + * 2. 다음 버킷 처리 시 이전 위치와 비교하여 점프 검출 + * 3. 캐시 미스 시 t_vessel_latest_position 테이블에서 fallback 조회 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class VesselBucketPositionDto { + /** + * 신호원 코드 + */ + private String sigSrcCd; + + /** + * 대상 ID + */ + private String targetId; + + /** + * 경도 (버킷 종료 시점) + */ + private Double endLon; + + /** + * 위도 (버킷 종료 시점) + */ + private Double endLat; + + /** + * 버킷 종료 시간 + */ + private LocalDateTime endTime; + + /** + * 선박 키 생성 (sigSrcCd:targetId) + */ + public String getVesselKey() { + return sigSrcCd + ":" + targetId; + } +} diff --git a/src/main/java/gc/mda/signal_batch/domain/vessel/dto/VesselStatsResponse.java b/src/main/java/gc/mda/signal_batch/domain/vessel/dto/VesselStatsResponse.java index 34c63a7..342ec87 100644 --- a/src/main/java/gc/mda/signal_batch/domain/vessel/dto/VesselStatsResponse.java +++ b/src/main/java/gc/mda/signal_batch/domain/vessel/dto/VesselStatsResponse.java @@ -1,6 +1,7 @@ package gc.mda.signal_batch.domain.vessel.dto; import com.fasterxml.jackson.annotation.JsonProperty; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Builder; import lombok.Data; @@ -8,16 +9,22 @@ import java.math.BigDecimal; @Data @Builder +@Schema(description = "해구/영역별 선박 통계") public class VesselStatsResponse { + @JsonProperty("vessel_count") + @Schema(description = "고유 선박 수 (해당 영역 내 탐지된 선박)", example = "156") private Integer vesselCount; - + @JsonProperty("total_distance") + @Schema(description = "총 이동거리 (해리, 모든 선박 합산)", example = "1248.73") private BigDecimal totalDistance; - + @JsonProperty("avg_speed") + @Schema(description = "평균 속도 (knots)", example = "10.54") private BigDecimal avgSpeed; - + @JsonProperty("active_tracks") + @Schema(description = "활성 항적 수 (현재 이동 중인 선박)", example = "89") private Integer activeTracks; } 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 7dd5e97..481b9aa 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 @@ -1,5 +1,8 @@ package gc.mda.signal_batch.domain.vessel.dto; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import gc.mda.signal_batch.global.config.FlexibleLocalDateTimeDeserializer; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Data; import lombok.Builder; import lombok.AllArgsConstructor; @@ -12,25 +15,63 @@ import java.util.List; @Builder @AllArgsConstructor @NoArgsConstructor +@Schema(description = "선박별 항적 조회 요청") public class VesselTracksRequest { + + @Schema( + description = """ + 조회 시작 시간. + 지원 형식: ISO 8601 (2026-01-20T12:00:00), UTC (2026-01-20T12:00:00Z), + 표준 형식 (2026-01-20 12:00:00), 압축 형식 (20260120120000) + """, + example = "2026-01-20T12:00:00", + required = true + ) + @JsonDeserialize(using = FlexibleLocalDateTimeDeserializer.class) private LocalDateTime startTime; + + @Schema( + description = """ + 조회 종료 시간. + 지원 형식: ISO 8601 (2026-01-20T12:10:00), UTC (2026-01-20T12:10:00Z), + 표준 형식 (2026-01-20 12:10:00), 압축 형식 (20260120121000) + """, + example = "2026-01-20T12:10:00", + required = true + ) + @JsonDeserialize(using = FlexibleLocalDateTimeDeserializer.class) private LocalDateTime endTime; + + @Schema(description = "조회할 선박 목록", required = true) private List vessels; - /** - * 통합선박신호 모드 - * "0": 기본 모드 - 전체 항적 반환 (기본값) - * "1": 통합 모드 - 동일 선박의 여러 신호 중 최고 우선순위만 반환 - */ + @Schema( + description = "통합선박 필터링 모드 (0: 전체 항적, 1: 통합선박 우선순위 적용)", + example = "0", + allowableValues = {"0", "1"} + ) @Builder.Default private String isIntegration = "0"; - + @Data @Builder @AllArgsConstructor @NoArgsConstructor + @Schema(description = "선박 식별자") public static class VesselIdentifier { + + @Schema( + description = "신호 소스 코드 (000001:AIS, 000002:LRIT, 000003:VPASS, 000004:VTS-AIS, 000019:항공기 등)", + example = "000001", + required = true + ) private String sigSrcCd; + + @Schema( + description = "타겟 ID (MMSI: 9자리, 한국선박 440/441로 시작)", + example = "440113620", + required = true + ) private String targetId; } } diff --git a/src/main/java/gc/mda/signal_batch/domain/vessel/service/IntegrationVesselService.java b/src/main/java/gc/mda/signal_batch/domain/vessel/service/IntegrationVesselService.java index 5f3989b..4cfe261 100644 --- a/src/main/java/gc/mda/signal_batch/domain/vessel/service/IntegrationVesselService.java +++ b/src/main/java/gc/mda/signal_batch/domain/vessel/service/IntegrationVesselService.java @@ -167,7 +167,7 @@ public class IntegrationVesselService { SELECT intgr_seq, ais, enav, vpass, vts_ais, d_mf_hf, ais_ship_nm, enav_ship_nm, vpass_ship_nm, vts_ais_ship_nm, d_mf_hf_ship_nm, integration_ship_ty - FROM gis.t_ship_integration_sub + FROM signal.t_ship_integration_sub """; List vessels = jdbcTemplate.query(sql, (rs, rowNum) -> diff --git a/src/main/java/gc/mda/signal_batch/domain/vessel/service/VesselPreviousBucketCache.java b/src/main/java/gc/mda/signal_batch/domain/vessel/service/VesselPreviousBucketCache.java new file mode 100644 index 0000000..d088218 --- /dev/null +++ b/src/main/java/gc/mda/signal_batch/domain/vessel/service/VesselPreviousBucketCache.java @@ -0,0 +1,303 @@ +package gc.mda.signal_batch.domain.vessel.service; + +import com.github.benmanes.caffeine.cache.Cache; +import gc.mda.signal_batch.domain.vessel.dto.VesselBucketPositionDto; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.cache.CacheManager; +import org.springframework.cache.caffeine.CaffeineCache; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.jdbc.core.RowMapper; +import org.springframework.stereotype.Service; + +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Timestamp; +import java.time.LocalDateTime; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.ConcurrentMap; + +/** + * 선박 이전 버킷 종료 위치 캐시 관리 서비스 + * + * 용도: 버킷 간 점프 검출을 위한 이전 버킷 종료 위치 캐싱 + * + * 캐시 구조: + * - Key: "{sigSrcCd}:{targetId}" (예: "000001:440123456") + * - Value: VesselBucketPositionDto (endLon, endLat, endTime) + * - TTL: 120분 (위성 AIS 고려) + * + * 데이터 흐름: + * 1. 5분 집계 시작 시 캐시에서 이전 버킷 위치 조회 + * 2. 캐시 미스 시 t_vessel_latest_position 테이블에서 최근 2시간 데이터 조회 (fallback) + * 3. 현재 버킷과 이전 버킷 간 점프 검출 + * 4. 처리 완료 후 현재 버킷 종료 위치를 캐시에 업데이트 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class VesselPreviousBucketCache { + + private final CacheManager cacheManager; + + @Qualifier("queryJdbcTemplate") + private final JdbcTemplate queryJdbcTemplate; + + private static final String CACHE_NAME = "vesselPreviousBuckets"; + + // DB 전체 조회 중복 방지 플래그 + private volatile boolean fallbackLoaded = false; + + // DB 조회 통계 + private volatile int totalDbQueriesCount = 0; + private volatile int totalVesselsLoadedFromDb = 0; + + /** + * 캐시 키 생성 + */ + private String createKey(String sigSrcCd, String targetId) { + return sigSrcCd + ":" + targetId; + } + + /** + * 단일 선박의 버킷 종료 위치 조회 + * + * @param sigSrcCd 신호원 코드 + * @param targetId 대상 ID + * @return 이전 버킷 종료 위치 (캐시 미스 시 null) + */ + public VesselBucketPositionDto get(String sigSrcCd, String targetId) { + String key = createKey(sigSrcCd, targetId); + org.springframework.cache.Cache cache = getCache(); + + if (cache != null) { + org.springframework.cache.Cache.ValueWrapper wrapper = cache.get(key); + if (wrapper != null && wrapper.get() instanceof VesselBucketPositionDto) { + return (VesselBucketPositionDto) wrapper.get(); + } + } + + return null; + } + + /** + * 여러 선박의 이전 버킷 위치 일괄 조회 (캐시 + DB Fallback) + * + * @param vesselKeys 조회할 선박 키 목록 (sigSrcCd:targetId) + * @return 선박 키 -> 이전 버킷 위치 매핑 + */ + public Map getBatch(List vesselKeys) { + Map result = new HashMap<>(); + + if (vesselKeys == null || vesselKeys.isEmpty()) { + return result; + } + + ConcurrentMap nativeCache = getNativeCache(); + int cacheHits = 0; + int cacheMisses = 0; + + // 1. 캐시에서 조회 + for (String vesselKey : vesselKeys) { + if (nativeCache != null && nativeCache.containsKey(vesselKey)) { + Object value = nativeCache.get(vesselKey); + if (value instanceof VesselBucketPositionDto position) { + result.put(vesselKey, position); + cacheHits++; + } + } else { + cacheMisses++; + } + } + + // 캐시 조회 로그 제거 (Job 레벨 통계로 대체) + + // 2. 캐시 미스 시 DB에서 일괄 조회 (최근 2시간) - 첫 실행 시 1회만 + if (cacheMisses > 0 && !fallbackLoaded) { + synchronized (this) { + if (!fallbackLoaded) { + long startTime = System.currentTimeMillis(); + Map dbResults = loadFromDatabaseBatch(); + long elapsed = System.currentTimeMillis() - startTime; + + // 전체 결과를 캐시에 저장 (요청 여부와 관계없이 모두 저장) + dbResults.values().forEach(this::put); + + // 요청된 선박들을 결과에 추가 + for (String vesselKey : vesselKeys) { + if (!result.containsKey(vesselKey) && dbResults.containsKey(vesselKey)) { + result.put(vesselKey, dbResults.get(vesselKey)); + } + } + + totalDbQueriesCount++; + totalVesselsLoadedFromDb += dbResults.size(); + fallbackLoaded = true; + + log.info("Previous bucket cache initialized: loaded {} vessels from DB in {}ms", + dbResults.size(), elapsed); + } + } + } + + return result; + } + + /** + * DB에서 최근 2시간 데이터 일괄 조회 (Fallback) + */ + private Map loadFromDatabaseBatch() { + Map result = new HashMap<>(); + + // t_vessel_latest_position 테이블에서 최근 2시간 데이터 조회 + String sql = """ + SELECT + sig_src_cd, + target_id, + lon, + lat, + last_update + FROM signal.t_vessel_latest_position + WHERE last_update >= NOW() - INTERVAL '2 hours' + AND sig_src_cd NOT IN ('000004', '000005') + """; + + try { + List positions = queryJdbcTemplate.query(sql, new BucketPositionRowMapper()); + + for (VesselBucketPositionDto position : positions) { + result.put(position.getVesselKey(), position); + } + + log.debug("Queried {} vessel positions from t_vessel_latest_position (last 2 hours)", positions.size()); + + } catch (Exception e) { + log.error("Failed to load previous bucket positions from DB", e); + } + + return result; + } + + /** + * 단일 선박 위치 캐시 저장 + */ + public void put(VesselBucketPositionDto position) { + if (position == null || position.getSigSrcCd() == null || position.getTargetId() == null) { + log.warn("Invalid position data, skipping cache: {}", position); + return; + } + + String key = createKey(position.getSigSrcCd(), position.getTargetId()); + getCache().put(key, position); + } + + /** + * 여러 선박 위치 일괄 캐시 저장 + */ + public void putAll(List positions) { + if (positions == null || positions.isEmpty()) { + log.debug("No positions to cache"); + return; + } + + int count = 0; + for (VesselBucketPositionDto position : positions) { + if (position.getSigSrcCd() != null && position.getTargetId() != null) { + put(position); + count++; + } + } + + log.debug("Cached {} vessel bucket positions (Total cache size: {})", + count, getCacheSize()); + } + + /** + * 캐시 전체 삭제 + */ + public void clear() { + org.springframework.cache.Cache cache = getCache(); + if (cache != null) { + cache.clear(); + fallbackLoaded = false; + log.info("Previous bucket cache cleared"); + } + } + + /** + * Job 시작 시 플래그 리셋 + */ + public void resetFallbackFlag() { + fallbackLoaded = false; + totalDbQueriesCount = 0; + totalVesselsLoadedFromDb = 0; + log.debug("Fallback flag and statistics reset"); + } + + /** + * Job 완료 시 통계 출력 + */ + public void logJobStatistics() { + if (totalDbQueriesCount > 0) { + log.info("Bucket jump detection stats - Cache size: {}, DB queries: {}, Vessels loaded: {}", + getCacheSize(), totalDbQueriesCount, totalVesselsLoadedFromDb); + } + } + + /** + * Spring Cache 객체 가져오기 + */ + private org.springframework.cache.Cache getCache() { + return cacheManager.getCache(CACHE_NAME); + } + + /** + * Caffeine Cache 네이티브 객체 가져오기 + */ + private Cache getCaffeineCache() { + org.springframework.cache.Cache cache = getCache(); + if (cache instanceof CaffeineCache caffeineCache) { + return caffeineCache.getNativeCache(); + } + return null; + } + + /** + * ConcurrentMap 가져오기 (배치 조회용) + */ + private ConcurrentMap getNativeCache() { + Cache caffeineCache = getCaffeineCache(); + if (caffeineCache != null) { + return caffeineCache.asMap(); + } + return null; + } + + /** + * 현재 캐시 크기 조회 + */ + private int getCacheSize() { + ConcurrentMap nativeCache = getNativeCache(); + return nativeCache != null ? nativeCache.size() : 0; + } + + /** + * RowMapper 구현 + */ + private static class BucketPositionRowMapper implements RowMapper { + @Override + public VesselBucketPositionDto mapRow(ResultSet rs, int rowNum) throws SQLException { + return VesselBucketPositionDto.builder() + .sigSrcCd(rs.getString("sig_src_cd")) + .targetId(rs.getString("target_id")) + .endLon(rs.getDouble("lon")) + .endLat(rs.getDouble("lat")) + .endTime(rs.getTimestamp("last_update") != null ? + rs.getTimestamp("last_update").toLocalDateTime() : null) + .build(); + } + } +} diff --git a/src/main/java/gc/mda/signal_batch/global/config/BatchConfig.java b/src/main/java/gc/mda/signal_batch/global/config/BatchConfig.java index b1495ca..4b0c156 100644 --- a/src/main/java/gc/mda/signal_batch/global/config/BatchConfig.java +++ b/src/main/java/gc/mda/signal_batch/global/config/BatchConfig.java @@ -1,17 +1,18 @@ package gc.mda.signal_batch.global.config; import org.springframework.batch.core.configuration.JobRegistry; -import org.springframework.batch.core.configuration.annotation.EnableBatchProcessing; +import org.springframework.batch.core.configuration.support.DefaultBatchConfiguration; import org.springframework.batch.core.configuration.support.JobRegistryBeanPostProcessor; -import org.springframework.batch.core.explore.JobExplorer; -import org.springframework.batch.core.explore.support.JobExplorerFactoryBean; import org.springframework.batch.core.launch.JobLauncher; import org.springframework.batch.core.launch.support.TaskExecutorJobLauncher; import org.springframework.batch.core.repository.JobRepository; +import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Lazy; import org.springframework.context.annotation.Profile; import org.springframework.core.task.TaskExecutor; import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; @@ -21,10 +22,37 @@ import javax.sql.DataSource; import java.util.concurrent.ThreadPoolExecutor; @Configuration -@EnableBatchProcessing @Profile("!query") // query 프로파일에서는 배치 처리 비활성화 @ConditionalOnProperty(name = "vessel.batch.scheduler.enabled", havingValue = "true", matchIfMissing = true) -public class BatchConfig { +public class BatchConfig extends DefaultBatchConfiguration { + + @Value("${spring.batch.jdbc.table-prefix:BATCH_}") + private String tablePrefix; + + @Autowired + @Qualifier("batchDataSource") + @Lazy + private DataSource batchDataSource; + + @Autowired + @Qualifier("batchTransactionManager") + @Lazy + private PlatformTransactionManager batchTransactionManager; + + @Override + protected DataSource getDataSource() { + return batchDataSource; + } + + @Override + protected PlatformTransactionManager getTransactionManager() { + return batchTransactionManager; + } + + @Override + protected String getTablePrefix() { + return tablePrefix; + } @Bean public TaskExecutor batchTaskExecutor() { @@ -77,14 +105,4 @@ public class BatchConfig { postProcessor.setJobRegistry(jobRegistry); return postProcessor; } - - @Bean - public JobExplorer jobExplorer(@Qualifier("batchDataSource") DataSource dataSource, - @Qualifier("batchTransactionManager") PlatformTransactionManager transactionManager) throws Exception { - JobExplorerFactoryBean factory = new JobExplorerFactoryBean(); - factory.setDataSource(dataSource); - factory.setTransactionManager(transactionManager); - factory.afterPropertiesSet(); - return factory.getObject(); - } -} \ No newline at end of file +} diff --git a/src/main/java/gc/mda/signal_batch/global/config/CacheConfig.java b/src/main/java/gc/mda/signal_batch/global/config/CacheConfig.java index 7a7731b..76feb32 100644 --- a/src/main/java/gc/mda/signal_batch/global/config/CacheConfig.java +++ b/src/main/java/gc/mda/signal_batch/global/config/CacheConfig.java @@ -32,21 +32,40 @@ public class CacheConfig { @Value("${vessel.batch.cache.latest-position.max-size:50000}") private int cacheMaxSize; + @Value("${vessel.batch.cache.previous-bucket.ttl-minutes:120}") + private int previousBucketTtlMinutes; + + @Value("${vessel.batch.cache.previous-bucket.max-size:50000}") + private int previousBucketMaxSize; + @Bean public CacheManager cacheManager() { - CaffeineCacheManager cacheManager = new CaffeineCacheManager("vesselLatestPositions"); - cacheManager.setCaffeine(caffeineCacheBuilder()); + CaffeineCacheManager cacheManager = new CaffeineCacheManager(); - log.info("Initialized Caffeine CacheManager with TTL={}min, MaxSize={}", - cacheTtlMinutes, cacheMaxSize); + // 1. vesselLatestPositions 캐시 (최신 위치 API용) + cacheManager.registerCustomCache("vesselLatestPositions", + latestPositionCacheBuilder().build()); + + // 2. vesselPreviousBuckets 캐시 (버킷 점프 검출용) + cacheManager.registerCustomCache("vesselPreviousBuckets", + previousBucketCacheBuilder().build()); + + log.info("Initialized Caffeine CacheManager:"); + log.info(" - vesselLatestPositions: TTL={}min, MaxSize={}", cacheTtlMinutes, cacheMaxSize); + log.info(" - vesselPreviousBuckets: TTL={}min, MaxSize={}", previousBucketTtlMinutes, previousBucketMaxSize); return cacheManager; } - private Caffeine caffeineCacheBuilder() { + /** + * 최신 위치 캐시 빌더 (API 응답용) + */ + private Caffeine latestPositionCacheBuilder() { return Caffeine.newBuilder() - // TTL: 60분 후 자동 만료 (비활성 선박 제거) - .expireAfterWrite(cacheTtlMinutes, TimeUnit.MINUTES) + // TTL: 마지막 접근 후 60분 (읽기/쓰기 시 리셋) + // - 활성 선박: API 조회 또는 스케줄러 갱신 → TTL 계속 리셋 + // - 비활성 선박: 60분 미접근 → 자동 제거 + .expireAfterAccess(cacheTtlMinutes, TimeUnit.MINUTES) // 최대 선박 수 제한 (메모리 보호) .maximumSize(cacheMaxSize) @@ -57,4 +76,24 @@ public class CacheConfig { // 초기 용량 설정 (rehashing 최소화) .initialCapacity(1000); } + + /** + * 이전 버킷 위치 캐시 빌더 (버킷 점프 검출용) + */ + private Caffeine previousBucketCacheBuilder() { + return Caffeine.newBuilder() + // TTL: 마지막 접근 후 120분 (읽기/쓰기 시 리셋) + // - 활성 선박: 5분마다 접근 → TTL 계속 갱신 → 영구 유지 + // - 비활성 선박: 120분 미접근 → 자동 제거 + .expireAfterAccess(previousBucketTtlMinutes, TimeUnit.MINUTES) + + // 최대 선박 수 제한 + .maximumSize(previousBucketMaxSize) + + // 통계 수집 활성화 + .recordStats() + + // 초기 용량 설정 + .initialCapacity(1000); + } } diff --git a/src/main/java/gc/mda/signal_batch/global/config/DataSourceConfigProperties.java b/src/main/java/gc/mda/signal_batch/global/config/DataSourceConfigProperties.java index 402d197..a7cf0b6 100644 --- a/src/main/java/gc/mda/signal_batch/global/config/DataSourceConfigProperties.java +++ b/src/main/java/gc/mda/signal_batch/global/config/DataSourceConfigProperties.java @@ -6,7 +6,7 @@ import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Profile; @Configuration -@Profile("prod") +@Profile({"prod", "prod-mpr", "dev", "local"}) @ConfigurationProperties(prefix = "spring.datasource") @Data public class DataSourceConfigProperties { diff --git a/src/main/java/gc/mda/signal_batch/global/config/FlexibleLocalDateTimeDeserializer.java b/src/main/java/gc/mda/signal_batch/global/config/FlexibleLocalDateTimeDeserializer.java new file mode 100644 index 0000000..2e56564 --- /dev/null +++ b/src/main/java/gc/mda/signal_batch/global/config/FlexibleLocalDateTimeDeserializer.java @@ -0,0 +1,47 @@ +package gc.mda.signal_batch.global.config; + +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonDeserializer; +import gc.mda.signal_batch.global.util.DateTimeParseUtil; + +import java.io.IOException; +import java.time.LocalDateTime; + +/** + * 다양한 날짜/시간 형식을 지원하는 Jackson Deserializer + * + *

지원 형식:

+ *
    + *
  • ISO 8601: 2026-01-20T10:00:00
  • + *
  • ISO 8601 (UTC): 2026-01-20T10:00:00Z
  • + *
  • ISO 8601 (Offset): 2026-01-20T10:00:00+09:00
  • + *
  • 표준 형식: 2026-01-20 10:00:00
  • + *
  • 초 생략: 2026-01-20T10:00, 2026-01-20 10:00
  • + *
  • 압축 형식: 20260120100000
  • + *
+ * + *

사용 예:

+ *
{@code
+ * @JsonDeserialize(using = FlexibleLocalDateTimeDeserializer.class)
+ * private LocalDateTime startTime;
+ * }
+ * + * @see DateTimeParseUtil + */ +public class FlexibleLocalDateTimeDeserializer extends JsonDeserializer { + + @Override + public LocalDateTime deserialize(JsonParser p, DeserializationContext ctxt) throws IOException { + String value = p.getValueAsString(); + if (value == null || value.isBlank()) { + return null; + } + + try { + return DateTimeParseUtil.parse(value); + } catch (IllegalArgumentException e) { + throw new IOException("날짜/시간 파싱 실패: " + value + ". " + e.getMessage(), e); + } + } +} diff --git a/src/main/java/gc/mda/signal_batch/global/config/ProdDataSourceConfig.java b/src/main/java/gc/mda/signal_batch/global/config/ProdDataSourceConfig.java index 9b62d2c..3173f01 100644 --- a/src/main/java/gc/mda/signal_batch/global/config/ProdDataSourceConfig.java +++ b/src/main/java/gc/mda/signal_batch/global/config/ProdDataSourceConfig.java @@ -15,7 +15,7 @@ import org.springframework.transaction.PlatformTransactionManager; import javax.sql.DataSource; @Configuration -@Profile("prod") +@Profile({"prod", "prod-mpr"}) public class ProdDataSourceConfig { private final DataSourceConfigProperties properties; 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 b3485de..f9de431 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 @@ -57,15 +57,15 @@ import org.springframework.context.annotation.Configuration; ``` """, contact = @Contact( - name = "Signal Batch Team", - email = "signal-batch@mda.gc" + name = "GC MDA Team", + email = "htlee@gcsc.co.kr" ), license = @License( name = "Internal Use Only" ) ), servers = { - @Server(url = "http://10.26.252.48:8090", description = "Development Server (QueryDB)"), + @Server(url = "http://10.26.252.51:8090", description = "Development Server (QueryDB)"), @Server(url = "http://10.26.252.39:8090", description = "Production Server"), @Server(url = "http://localhost:8090", description = "Local Development Server") } @@ -77,14 +77,27 @@ public class SwaggerConfig { return GroupedOpenApi.builder() .group("1-track-api") .displayName("항적 조회 API") - .pathsToMatch("/api/v1/tracks/**", "/api/v1/haegu/**", "/api/v1/areas/**", "/api/v1/passages/**", "/api/v1/vessels/**") + .pathsToMatch( + "/api/v1/tracks/**", "/api/v1/haegu/**", "/api/v1/areas/**", + "/api/v1/passages/**", "/api/v1/vessels/**", + "/api/v2/**" + ) + .build(); + } + + @Bean + public GroupedOpenApi shipImageApi() { + return GroupedOpenApi.builder() + .group("2-ship-image-api") + .displayName("선박 이미지 API") + .pathsToMatch("/api/v1/shipimg/**") .build(); } @Bean public GroupedOpenApi abnormalTrackApi() { return GroupedOpenApi.builder() - .group("2-abnormal-track-api") + .group("3-abnormal-track-api") .displayName("비정상 항적 검출 API") .pathsToMatch("/api/v1/abnormal-tracks/**") .build(); @@ -93,7 +106,7 @@ public class SwaggerConfig { @Bean public GroupedOpenApi tileApi() { return GroupedOpenApi.builder() - .group("3-tile-api") + .group("4-tile-api") .displayName("타일 집계 API") .pathsToMatch("/api/v1/tiles/**", "/api/tiles/**") .build(); @@ -102,7 +115,7 @@ public class SwaggerConfig { @Bean public GroupedOpenApi performanceApi() { return GroupedOpenApi.builder() - .group("4-performance-api") + .group("5-performance-api") .displayName("성능 최적화 API") .pathsToMatch("/api/v1/performance/**") .build(); @@ -111,7 +124,7 @@ public class SwaggerConfig { @Bean public GroupedOpenApi adminApi() { return GroupedOpenApi.builder() - .group("5-admin-api") + .group("6-admin-api") .displayName("관리자 API") .pathsToMatch("/admin/**") .build(); @@ -120,9 +133,27 @@ public class SwaggerConfig { @Bean public GroupedOpenApi monitoringApi() { return GroupedOpenApi.builder() - .group("6-monitoring-api") + .group("7-monitoring-api") .displayName("모니터링 API") - .pathsToMatch("/monitor/**", "/actuator/**", "/api/websocket/**") + .pathsToMatch("/monitor/**", "/actuator/**", "/api/websocket/**", "/api/monitoring/**") + .build(); + } + + @Bean + public GroupedOpenApi migrationApi() { + return GroupedOpenApi.builder() + .group("8-migration-api") + .displayName("마이그레이션 API") + .pathsToMatch("/api/migration/**") + .build(); + } + + @Bean + public GroupedOpenApi debugApi() { + return GroupedOpenApi.builder() + .group("9-debug-api") + .displayName("디버그 API") + .pathsToMatch("/api/debug/**") .build(); } } \ No newline at end of file diff --git a/src/main/java/gc/mda/signal_batch/global/util/DateTimeParseUtil.java b/src/main/java/gc/mda/signal_batch/global/util/DateTimeParseUtil.java new file mode 100644 index 0000000..d6648e1 --- /dev/null +++ b/src/main/java/gc/mda/signal_batch/global/util/DateTimeParseUtil.java @@ -0,0 +1,176 @@ +package gc.mda.signal_batch.global.util; + +import java.time.LocalDateTime; +import java.time.ZonedDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; +import java.util.Arrays; +import java.util.List; + +/** + * 다양한 날짜/시간 형식을 파싱하는 유틸리티 클래스 + * + *

지원 형식:

+ *
    + *
  • ISO 8601 (T 포함): 2026-01-20T10:00:00
  • + *
  • ISO 8601 with timezone: 2026-01-20T10:00:00Z, 2026-01-20T10:00:00+09:00
  • + *
  • 표준 형식 (공백 구분): 2026-01-20 10:00:00
  • + *
  • 초 생략 형식: 2026-01-20T10:00, 2026-01-20 10:00
  • + *
  • 압축 형식: 20260120100000 (YYYYMMDDHHMMSS)
  • + *
  • 압축 형식 (초 생략): 202601201000 (YYYYMMDDHHMM)
  • + *
+ * + *

사용 예:

+ *
{@code
+ * LocalDateTime dt1 = DateTimeParseUtil.parse("2026-01-20T10:00:00");
+ * LocalDateTime dt2 = DateTimeParseUtil.parse("2026-01-20 10:00:00");
+ * LocalDateTime dt3 = DateTimeParseUtil.parse("20260120100000");
+ * }
+ */ +public class DateTimeParseUtil { + + /** + * 지원하는 날짜/시간 형식 목록 (우선순위 순) + */ + private static final List FORMATTERS = Arrays.asList( + // ISO 8601 with timezone (Z or offset) + DateTimeFormatter.ISO_ZONED_DATE_TIME, + DateTimeFormatter.ISO_OFFSET_DATE_TIME, + // ISO 8601 standard + DateTimeFormatter.ISO_LOCAL_DATE_TIME, + // Standard format with space + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"), + // Without seconds + DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm"), + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm"), + // Compact format (YYYYMMDDHHMMSS) + DateTimeFormatter.ofPattern("yyyyMMddHHmmss"), + // Compact format without seconds (YYYYMMDDHHMM) + DateTimeFormatter.ofPattern("yyyyMMddHHmm") + ); + + /** + * Timezone 포함 형식 판별용 + */ + private static final List ZONED_FORMATTERS = Arrays.asList( + DateTimeFormatter.ISO_ZONED_DATE_TIME, + DateTimeFormatter.ISO_OFFSET_DATE_TIME + ); + + private DateTimeParseUtil() { + // Utility class - prevent instantiation + } + + /** + * 다양한 형식의 날짜/시간 문자열을 LocalDateTime으로 파싱 + * + * @param dateTimeString 파싱할 날짜/시간 문자열 + * @return 파싱된 LocalDateTime + * @throws IllegalArgumentException 지원하지 않는 형식인 경우 + */ + public static LocalDateTime parse(String dateTimeString) { + if (dateTimeString == null || dateTimeString.isBlank()) { + throw new IllegalArgumentException("날짜/시간 문자열이 null이거나 비어있습니다."); + } + + String trimmed = dateTimeString.trim(); + + // Try zoned formatters first for Z or offset patterns + if (trimmed.contains("Z") || trimmed.matches(".*[+-]\\d{2}:\\d{2}$")) { + for (DateTimeFormatter formatter : ZONED_FORMATTERS) { + try { + ZonedDateTime zdt = ZonedDateTime.parse(trimmed, formatter); + return zdt.toLocalDateTime(); + } catch (DateTimeParseException ignored) { + // Try next formatter + } + } + } + + // Try other formatters + for (DateTimeFormatter formatter : FORMATTERS) { + if (ZONED_FORMATTERS.contains(formatter)) { + continue; // Skip zoned formatters already tried + } + try { + return LocalDateTime.parse(trimmed, formatter); + } catch (DateTimeParseException ignored) { + // Try next formatter + } + } + + throw new IllegalArgumentException( + "지원하지 않는 날짜/시간 형식입니다: " + dateTimeString + "\n" + + "지원 형식: ISO 8601 (2026-01-20T10:00:00, 2026-01-20T10:00:00Z), " + + "표준 형식 (2026-01-20 10:00:00), 압축 형식 (20260120100000)" + ); + } + + /** + * 날짜/시간 문자열 파싱 시도 (실패 시 null 반환) + * + * @param dateTimeString 파싱할 날짜/시간 문자열 + * @return 파싱된 LocalDateTime 또는 null + */ + public static LocalDateTime parseOrNull(String dateTimeString) { + try { + return parse(dateTimeString); + } catch (IllegalArgumentException e) { + return null; + } + } + + /** + * 날짜/시간 문자열이 파싱 가능한지 확인 + * + * @param dateTimeString 확인할 날짜/시간 문자열 + * @return 파싱 가능 여부 + */ + public static boolean isValid(String dateTimeString) { + return parseOrNull(dateTimeString) != null; + } + + /** + * LocalDateTime을 ISO 8601 형식으로 포맷 + * + * @param dateTime 포맷할 LocalDateTime + * @return ISO 8601 형식 문자열 (예: 2026-01-20T10:00:00) + */ + public static String formatIso(LocalDateTime dateTime) { + if (dateTime == null) { + return null; + } + return dateTime.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME); + } + + /** + * LocalDateTime을 표준 형식으로 포맷 + * + * @param dateTime 포맷할 LocalDateTime + * @return 표준 형식 문자열 (예: 2026-01-20 10:00:00) + */ + public static String formatStandard(LocalDateTime dateTime) { + if (dateTime == null) { + return null; + } + return dateTime.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")); + } + + /** + * 지원 형식 설명 문자열 반환 (API 문서용) + * + * @return 지원 형식 설명 + */ + public static String getSupportedFormatsDescription() { + return """ + 지원 형식: + • ISO 8601: 2026-01-20T10:00:00 + • ISO 8601 (UTC): 2026-01-20T10:00:00Z + • ISO 8601 (Offset): 2026-01-20T10:00:00+09:00 + • 표준 형식: 2026-01-20 10:00:00 + • 초 생략: 2026-01-20T10:00, 2026-01-20 10:00 + • 압축 형식: 20260120100000 (YYYYMMDDHHMMSS) + • 압축 형식 (초 생략): 202601201000 (YYYYMMDDHHMM) + """; + } +} 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 162de21..40ab556 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 @@ -26,20 +26,28 @@ import java.util.stream.IntStream; public class PartitionManager { private final JdbcTemplate queryJdbcTemplate; + private final JdbcTemplate collectJdbcTemplate; private final gc.mda.signal_batch.global.config.PartitionRetentionConfig retentionConfig; public PartitionManager( @Qualifier("queryJdbcTemplate") JdbcTemplate queryJdbcTemplate, + @Qualifier("collectJdbcTemplate") JdbcTemplate collectJdbcTemplate, gc.mda.signal_batch.global.config.PartitionRetentionConfig retentionConfig) { this.queryJdbcTemplate = queryJdbcTemplate; + this.collectJdbcTemplate = collectJdbcTemplate; this.retentionConfig = retentionConfig; } private static final DateTimeFormatter PARTITION_DATE_FORMAT = DateTimeFormatter.ofPattern("yyMMdd"); private static final DateTimeFormatter PARTITION_MONTH_FORMAT = DateTimeFormatter.ofPattern("yyyy_MM"); - // 일별 파티션 테이블 목록 (queryDB만 관리) - private static final List DAILY_PARTITION_TABLES = List.of( + // CollectDB 일별 파티션 테이블 목록 + private static final List COLLECT_DAILY_PARTITION_TABLES = List.of( + "sig_test" + ); + + // QueryDB 일별 파티션 테이블 목록 + private static final List QUERY_DAILY_PARTITION_TABLES = List.of( "t_vessel_tracks_5min", "t_grid_vessel_tracks", "t_grid_tracks_summary", @@ -52,12 +60,11 @@ public class PartitionManager { // 월별 파티션 테이블 목록 private static final List MONTHLY_PARTITION_TABLES = List.of( "t_vessel_tracks_hourly", - "t_grid_vessel_tracks_hourly", + "t_vessel_tracks_daily", "t_grid_tracks_summary_hourly", "t_grid_tracks_summary_daily", "t_area_tracks_summary_hourly", "t_area_tracks_summary_daily", - "t_vessel_tracks_daily", "t_abnormal_tracks" ); @@ -68,7 +75,10 @@ public class PartitionManager { public void initialize() { log.info("========== PartitionManager Initialization =========="); - // DataSource 정보 로깅 (queryDB만) + // DataSource 정보 로깅 + log.info("=== Collect DataSource Info ==="); + DataSourceLogger.logJdbcTemplateInfo("PartitionManager-Collect", collectJdbcTemplate); + log.info("=== Query DataSource Info ==="); DataSourceLogger.logJdbcTemplateInfo("PartitionManager-Query", queryJdbcTemplate); @@ -113,7 +123,7 @@ public class PartitionManager { try { LocalDate today = LocalDate.now(); - // 1. 일별 파티션 생성 + // 1. 일별 파티션 생성 (CollectDB + QueryDB) createDailyPartitions(today, retentionConfig.getFutureDays()); // 2. 월별 파티션 생성 (매월 1일에 다음달 파티션 생성) @@ -138,13 +148,20 @@ public class PartitionManager { } /** - * 현재 존재하는 테이블 확인 (queryDB만) + * 현재 존재하는 테이블 확인 */ private void checkExistingTables() { - log.info("Checking existing tables in queryDB..."); + log.info("Checking existing tables..."); - // 일별 파티션 테이블 확인 - for (String table : DAILY_PARTITION_TABLES) { + // CollectDB 일별 파티션 테이블 확인 + log.info("--- CollectDB Tables ---"); + for (String table : COLLECT_DAILY_PARTITION_TABLES) { + checkTableExists("signal", table, collectJdbcTemplate, "CollectDB"); + } + + // QueryDB 일별 파티션 테이블 확인 + log.info("--- QueryDB Tables ---"); + for (String table : QUERY_DAILY_PARTITION_TABLES) { checkTableExists("signal", table, queryJdbcTemplate, "QueryDB"); } @@ -166,7 +183,7 @@ public class PartitionManager { } /** - * 일별 파티션 생성 (queryDB만) + * 일별 파티션 생성 (CollectDB + QueryDB) */ private void createDailyPartitions(LocalDate startDate, int days) { log.info("Creating daily partitions for {} days starting from {}", days, startDate); @@ -176,8 +193,14 @@ public class PartitionManager { IntStream.range(0, days).forEach(offset -> { LocalDate targetDate = startDate.plusDays(offset); - for (String table : DAILY_PARTITION_TABLES) { - tasks.add(new PartitionTask("signal", table, targetDate, queryJdbcTemplate, "daily")); + // CollectDB 파티션 + for (String table : COLLECT_DAILY_PARTITION_TABLES) { + tasks.add(new PartitionTask("signal", table, targetDate, collectJdbcTemplate, "daily", "CollectDB")); + } + + // QueryDB 파티션 + for (String table : QUERY_DAILY_PARTITION_TABLES) { + tasks.add(new PartitionTask("signal", table, targetDate, queryJdbcTemplate, "daily", "QueryDB")); } }); @@ -186,7 +209,7 @@ public class PartitionManager { } /** - * 월별 파티션 생성 + * 월별 파티션 생성 (QueryDB만) */ private void createMonthlyPartitions(LocalDate targetMonth) { log.info("Creating monthly partitions for {}", targetMonth.format(DateTimeFormatter.ofPattern("yyyy-MM"))); @@ -194,7 +217,7 @@ public class PartitionManager { List tasks = new ArrayList<>(); for (String table : MONTHLY_PARTITION_TABLES) { - tasks.add(new PartitionTask("signal", table, targetMonth, queryJdbcTemplate, "monthly")); + tasks.add(new PartitionTask("signal", table, targetMonth, queryJdbcTemplate, "monthly", "QueryDB")); } // 병렬 처리 @@ -235,19 +258,19 @@ public class PartitionManager { try { // 파티션 존재 확인 if (partitionExists(task.schema, partitionName, task.jdbcTemplate)) { - log.debug("[QueryDB] Partition already exists: {}.{}", task.schema, partitionName); + log.debug("[{}] Partition already exists: {}.{}", task.dbType, task.schema, partitionName); return; } // 파티션 생성 task.jdbcTemplate.execute(createSql); - log.info("[QueryDB] Created partition: {}.{}", task.schema, partitionName); + log.info("[{}] Created partition: {}.{}", task.dbType, task.schema, partitionName); // 파티션별 인덱스 생성 createPartitionIndexes(task.schema, partitionName, task.baseTable, task.jdbcTemplate); } catch (Exception e) { - log.error("[QueryDB] Failed to create partition: {}.{}", task.schema, partitionName, e); + log.error("[{}] Failed to create partition: {}.{}", task.dbType, task.schema, partitionName, e); } } @@ -384,13 +407,6 @@ public class PartitionManager { )); } } - // 해구별 hourly 테이블 진입 이력 인덱스 - if (baseTable.equals("t_grid_vessel_tracks_hourly")) { - indexSqls.add(String.format( - "CREATE INDEX CONCURRENTLY IF NOT EXISTS %s_vessel_time_idx ON %s.%s (sig_src_cd, target_id, time_bucket DESC)", - partitionName, schema, partitionName - )); - } } // 일별 궤적 테이블 else if (baseTable.contains("daily")) { @@ -459,38 +475,54 @@ public class PartitionManager { log.info("Cleanup Date: {}", today); try { - // 일별 파티션 테이블 정리 - log.info("--- Daily Partition Tables ---"); - for (String tableName : DAILY_PARTITION_TABLES) { + // CollectDB 일별 파티션 테이블 정리 + log.info("--- CollectDB Daily Partition Tables ---"); + for (String tableName : COLLECT_DAILY_PARTITION_TABLES) { int retentionDays = retentionConfig.getRetentionDays(tableName); if (retentionDays <= 0) { - log.info("[{}] Unlimited retention (days={}). Skipping cleanup.", tableName, retentionDays); + log.info("[CollectDB:{}] Unlimited retention (days={}). Skipping cleanup.", tableName, retentionDays); continue; } LocalDate cutoffDate = today.minusDays(retentionDays); - log.info("[{}] Retention: {} days, Cutoff: {}", tableName, retentionDays, cutoffDate); + log.info("[CollectDB:{}] Retention: {} days, Cutoff: {}", tableName, retentionDays, cutoffDate); - dropPartitionsForTable(tableName, cutoffDate); + dropPartitionsForTable(tableName, cutoffDate, collectJdbcTemplate, "CollectDB"); } - // 월별 파티션 테이블 정리 + // QueryDB 일별 파티션 테이블 정리 + log.info("--- QueryDB Daily Partition Tables ---"); + for (String tableName : QUERY_DAILY_PARTITION_TABLES) { + int retentionDays = retentionConfig.getRetentionDays(tableName); + + if (retentionDays <= 0) { + log.info("[QueryDB:{}] Unlimited retention (days={}). Skipping cleanup.", tableName, retentionDays); + continue; + } + + LocalDate cutoffDate = today.minusDays(retentionDays); + log.info("[QueryDB:{}] Retention: {} days, Cutoff: {}", tableName, retentionDays, cutoffDate); + + dropPartitionsForTable(tableName, cutoffDate, queryJdbcTemplate, "QueryDB"); + } + + // QueryDB 월별 파티션 테이블 정리 log.info(""); - log.info("--- Monthly Partition Tables ---"); + log.info("--- QueryDB Monthly Partition Tables ---"); for (String tableName : MONTHLY_PARTITION_TABLES) { int retentionMonths = retentionConfig.getRetentionMonths(tableName); if (retentionMonths <= 0) { - log.info("[{}] Unlimited retention (months={}). Skipping cleanup.", tableName, retentionMonths); + log.info("[QueryDB:{}] Unlimited retention (months={}). Skipping cleanup.", tableName, retentionMonths); continue; } // 월 단위 계산: N개월 전의 1일 LocalDate cutoffDate = today.minusMonths(retentionMonths).withDayOfMonth(1); - log.info("[{}] Retention: {} months, Cutoff: {}", tableName, retentionMonths, cutoffDate); + log.info("[QueryDB:{}] Retention: {} months, Cutoff: {}", tableName, retentionMonths, cutoffDate); - dropPartitionsForTable(tableName, cutoffDate); + dropPartitionsForTable(tableName, cutoffDate, queryJdbcTemplate, "QueryDB"); } log.info("========== Partition Cleanup Completed =========="); @@ -503,34 +535,60 @@ public class PartitionManager { /** * 개별 테이블의 파티션 삭제 */ - private void dropPartitionsForTable(String tableName, LocalDate cutoffDate) { + private void dropPartitionsForTable(String tableName, LocalDate cutoffDate, JdbcTemplate jdbcTemplate, String dbType) { int droppedCount = 0; int keptCount = 0; + int skippedCount = 0; + + // 월별 파티션 테이블인지 확인 + boolean isMonthlyTable = MONTHLY_PARTITION_TABLES.contains(tableName); try { - List partitions = findPartitions("signal", tableName + "_%", queryJdbcTemplate); + List partitions = findPartitions("signal", tableName + "_%", jdbcTemplate); for (PartitionInfo partition : partitions) { if (partition.partitionDate == null) { - log.warn(" [{}] Cannot determine partition date, skipping: {}", - tableName, partition.tableName); + log.warn(" [{}:{}] Cannot determine partition date, skipping: {}", + dbType, tableName, partition.tableName); + skippedCount++; + continue; + } + + // 파티션 패턴 검증: 일별 vs 월별 + boolean isDailyPattern = partition.tableName.matches(".*_\\d{6}$"); + boolean isMonthlyPattern = partition.tableName.matches(".*_\\d{4}_\\d{2}$"); + + // 일별 파티션 정리 시: 월별 파티션은 건너뛰기 + if (!isMonthlyTable && !isDailyPattern) { + log.debug(" [{}:{}] Skipping non-daily partition during daily cleanup: {}", + dbType, tableName, partition.tableName); + skippedCount++; + continue; + } + + // 월별 파티션 정리 시: 일별 파티션은 건너뛰기 + if (isMonthlyTable && !isMonthlyPattern) { + log.debug(" [{}:{}] Skipping non-monthly partition during monthly cleanup: {}", + dbType, tableName, partition.tableName); + skippedCount++; continue; } if (partition.partitionDate.isBefore(cutoffDate)) { - dropPartition(partition, queryJdbcTemplate); + dropPartition(partition, jdbcTemplate); droppedCount++; - log.info(" [{}] Dropped: {} (date: {})", - tableName, partition.tableName, partition.partitionDate); + log.info(" [{}:{}] Dropped: {} (date: {})", + dbType, tableName, partition.tableName, partition.partitionDate); } else { keptCount++; } } - log.info(" [{}] Result: dropped={}, kept={}", tableName, droppedCount, keptCount); + log.info(" [{}:{}] Result: dropped={}, kept={}, skipped={}", + dbType, tableName, droppedCount, keptCount, skippedCount); } catch (Exception e) { - log.error(" [{}] Failed to clean partitions: {}", tableName, e.getMessage(), e); + log.error(" [{}:{}] Failed to clean partitions: {}", dbType, tableName, e.getMessage(), e); } } @@ -716,13 +774,15 @@ public class PartitionManager { LocalDate date; JdbcTemplate jdbcTemplate; String partitionType; // "daily" or "monthly" + String dbType; // "CollectDB" or "QueryDB" - PartitionTask(String schema, String baseTable, LocalDate date, JdbcTemplate jdbcTemplate, String partitionType) { + PartitionTask(String schema, String baseTable, LocalDate date, JdbcTemplate jdbcTemplate, String partitionType, String dbType) { this.schema = schema; this.baseTable = baseTable; this.date = date; this.jdbcTemplate = jdbcTemplate; this.partitionType = partitionType; + this.dbType = dbType; } } diff --git a/src/main/java/gc/mda/signal_batch/global/util/ShipKindCodeConverter.java b/src/main/java/gc/mda/signal_batch/global/util/ShipKindCodeConverter.java index 6b8d13b..44b0685 100644 --- a/src/main/java/gc/mda/signal_batch/global/util/ShipKindCodeConverter.java +++ b/src/main/java/gc/mda/signal_batch/global/util/ShipKindCodeConverter.java @@ -124,9 +124,85 @@ public class ShipKindCodeConverter { return SHIP_KIND_MAP.getOrDefault(key, "000027"); // 기본값: 기타 } + /** + * sig_src_cd, shipType, shipName, targetId를 조합하여 shipKindCode를 반환 + * 선박명 패턴 매칭을 통해 어망/부이(000028) 우선 판별 + * + * @param sigSrcCd 신호 소스 코드 (ex: 000001, 000004, ...) + * @param shipType 선박 타입 (ex: 30, B005, ...) + * @param shipName 선박명 (ex: "부이-123", "어망.설치선", ...) + * @param targetId 타겟 ID (MMSI 등, ex: "440123456", "123456789") + * @return shipKindCode (ex: 000020, 000028, ...) 매칭되지 않으면 000027(기타) + */ + public static String getShipKindCodeWithNamePattern(String sigSrcCd, String shipType, String shipName, String targetId) { + // 1. 어망/부이 패턴 체크 조건: + // - AIS(000001) 또는 VTS-AIS(000004) + // - 한국 국적 선박 제외 (target_id가 440 또는 441로 시작하지 않음) + // - 선박명에 어망/부이 패턴 포함 + if (isAisOrVtsAis(sigSrcCd) && !isKoreanVessel(targetId) && containsBuoyPattern(shipName)) { + return "000028"; // 어망/부이 + } + + // 2. 기존 로직 수행 + return getShipKindCode(sigSrcCd, shipType); + } + + /** + * 하위 호환성을 위한 오버로드 메서드 (targetId 없이 호출 시) + * @deprecated targetId를 포함한 메서드 사용 권장 + */ + @Deprecated + public static String getShipKindCodeWithNamePattern(String sigSrcCd, String shipType, String shipName) { + return getShipKindCodeWithNamePattern(sigSrcCd, shipType, shipName, null); + } + + /** + * AIS(000001) 또는 VTS-AIS(000004) 신호원인지 확인 + */ + private static boolean isAisOrVtsAis(String sigSrcCd) { + return "000001".equals(sigSrcCd) || "000004".equals(sigSrcCd); + } + + /** + * 한국 국적 선박인지 확인 (MMSI가 440 또는 441로 시작) + * 한국 국적 선박은 선박명에 특수문자가 포함되어도 어망/부이가 아님 + */ + private static boolean isKoreanVessel(String targetId) { + if (targetId == null || targetId.length() < 3) { + return false; + } + String prefix = targetId.substring(0, 3); + return "440".equals(prefix) || "441".equals(prefix); + } + + /** + * 선박명에 어망/부이 패턴이 포함되어 있는지 확인 + * + * 패턴 규칙: + * - '%' 포함 시 → 어망/부이 + * - '-'와 '.'가 동시에 포함 시 → 어망/부이 (예: "ABC-5.5", "ABC.5-5") + * - '-'만 또는 '.'만 포함 시 → 일반 선박 (예: "ABC NO.5", "S-92") + */ + private static boolean containsBuoyPattern(String shipName) { + if (shipName == null || shipName.isEmpty()) { + return false; + } + + // '%' 포함 시 어망/부이 + if (shipName.contains("%")) { + return true; + } + + // '-'와 '.'가 동시에 포함될 때만 어망/부이 + boolean hasDash = shipName.contains("-"); + boolean hasDot = shipName.contains("."); + + return hasDash && hasDot; + } + /** * 선박 종류 명칭 반환 - * + * * @param shipKindCode 선박 종류 코드 * @return 선박 종류 명칭 */ @@ -139,6 +215,7 @@ public class ShipKindCodeConverter { case "000024": return "유조선"; case "000025": return "관공선"; case "000027": return "기타"; + case "000028": return "어망/부이"; default: return "기타"; } } diff --git a/src/main/java/gc/mda/signal_batch/global/util/TrackConverter.java b/src/main/java/gc/mda/signal_batch/global/util/TrackConverter.java new file mode 100644 index 0000000..2486205 --- /dev/null +++ b/src/main/java/gc/mda/signal_batch/global/util/TrackConverter.java @@ -0,0 +1,231 @@ +package gc.mda.signal_batch.global.util; + +import gc.mda.signal_batch.domain.vessel.dto.CompactVesselTrack; +import gc.mda.signal_batch.domain.vessel.dto.TrackResponse; +import lombok.extern.slf4j.Slf4j; +import org.locationtech.jts.geom.Coordinate; +import org.locationtech.jts.geom.LineString; +import org.locationtech.jts.io.ParseException; +import org.locationtech.jts.io.WKTReader; + +import java.util.*; +import java.util.stream.Collectors; + +/** + * TrackResponse (WKT 기반) → CompactVesselTrack (배열 기반) 변환 유틸리티 + * WebSocket API와 REST API의 응답 구조 통일을 위해 사용 + */ +@Slf4j +public class TrackConverter { + + private static final WKTReader wktReader = new WKTReader(); + + /** + * 선박별로 분리된 TrackResponse 리스트를 CompactVesselTrack 리스트로 변환 + * - 동일 선박의 여러 세그먼트를 병합 + * - LineStringM을 geometry/timestamps/speeds 배열로 파싱 + * + * @param trackResponses TrackResponse 리스트 (세그먼트별 분리) + * @param vesselInfoProvider 선박 정보 조회 함수 (sigSrcCd, targetId → VesselInfo) + * @return CompactVesselTrack 리스트 (선박별 병합) + */ + public static List convert( + List trackResponses, + VesselInfoProvider vesselInfoProvider) { + + if (trackResponses == null || trackResponses.isEmpty()) { + return Collections.emptyList(); + } + + // 1. 선박별로 그룹핑 + Map> byVessel = trackResponses.stream() + .collect(Collectors.groupingBy( + t -> t.getSigSrcCd() + "_" + t.getTargetId(), + LinkedHashMap::new, + Collectors.toList() + )); + + // 2. 각 선박별로 세그먼트 병합 및 변환 + List result = new ArrayList<>(); + + for (Map.Entry> entry : byVessel.entrySet()) { + String vesselId = entry.getKey(); + List segments = entry.getValue(); + + CompactVesselTrack compactTrack = mergeSegments(vesselId, segments, vesselInfoProvider); + if (compactTrack != null && compactTrack.getPointCount() > 0) { + result.add(compactTrack); + } + } + + return result; + } + + /** + * 단일 선박의 여러 세그먼트를 하나의 CompactVesselTrack으로 병합 + */ + private static CompactVesselTrack mergeSegments( + String vesselId, + List segments, + VesselInfoProvider vesselInfoProvider) { + + if (segments == null || segments.isEmpty()) { + return null; + } + + // 시간순 정렬 + segments.sort(Comparator.comparing(TrackResponse::getTimeBucket)); + + String sigSrcCd = segments.get(0).getSigSrcCd(); + String targetId = segments.get(0).getTargetId(); + + // 누적 데이터 + List allGeometry = new ArrayList<>(); + List allTimestamps = new ArrayList<>(); + List allSpeeds = new ArrayList<>(); + double totalDistance = 0; + double maxSpeed = 0; + + Coordinate prevCoord = null; + long prevTimeMillis = 0; + + for (TrackResponse segment : segments) { + String trackGeom = segment.getTrackGeom(); + if (trackGeom == null || trackGeom.isEmpty() || "LINESTRING EMPTY".equals(trackGeom)) { + continue; + } + + try { + LineString lineString = (LineString) wktReader.read(trackGeom); + + if (lineString.getNumPoints() == 0) { + continue; + } + + for (int i = 0; i < lineString.getNumPoints(); i++) { + Coordinate coord = lineString.getCoordinateN(i); + allGeometry.add(new double[]{coord.x, coord.y}); + + // M값 (Unix timestamp 초 단위) + long unixTimestamp = (long) coord.getM(); + allTimestamps.add(String.valueOf(unixTimestamp)); + + // 속도 계산 + double speed = 0.0; + long currentTimeMillis = unixTimestamp * 1000; + + if (prevCoord != null && prevTimeMillis > 0) { + double distance = calculateDistanceNm(prevCoord.y, prevCoord.x, coord.y, coord.x); + long timeDiff = currentTimeMillis - prevTimeMillis; + if (timeDiff > 0) { + speed = distance / (timeDiff / 3600000.0); // knots + } + } + allSpeeds.add(speed); + + prevCoord = coord; + prevTimeMillis = currentTimeMillis; + } + + // 통계 누적 + if (segment.getDistanceNm() != null) { + totalDistance += segment.getDistanceNm().doubleValue(); + } + if (segment.getMaxSpeed() != null) { + maxSpeed = Math.max(maxSpeed, segment.getMaxSpeed().doubleValue()); + } + + } catch (ParseException e) { + log.warn("Failed to parse LineStringM for vessel {}: {}", vesselId, e.getMessage()); + } + } + + if (allGeometry.isEmpty()) { + return null; + } + + // 선박 정보 조회 + VesselInfo vesselInfo = vesselInfoProvider != null + ? vesselInfoProvider.getVesselInfo(sigSrcCd, targetId) + : new VesselInfo("-", "-"); + + // 국적 코드 계산 + String nationalCode = NationalCodeUtil.calculateNationalCode(sigSrcCd, targetId); + + // shipKindCode 계산 + String shipKindCode = ShipKindCodeConverter.getShipKindCodeWithNamePattern( + sigSrcCd, vesselInfo.getShipType(), vesselInfo.getShipName(), targetId); + + // 평균 속도 계산 + double avgSpeed = allSpeeds.stream() + .filter(s -> s > 0) + .mapToDouble(Double::doubleValue) + .average() + .orElse(0.0); + + return CompactVesselTrack.builder() + .vesselId(vesselId) + .sigSrcCd(sigSrcCd) + .targetId(targetId) + .nationalCode(nationalCode) + .geometry(allGeometry) + .timestamps(allTimestamps) + .speeds(allSpeeds) + .totalDistance(totalDistance) + .avgSpeed(Math.round(avgSpeed * 100.0) / 100.0) + .maxSpeed(Math.round(maxSpeed * 100.0) / 100.0) + .pointCount(allGeometry.size()) + .shipName(vesselInfo.getShipName()) + .shipType(vesselInfo.getShipType()) + .shipKindCode(shipKindCode) + .build(); + } + + /** + * 두 좌표 사이의 거리 계산 (Haversine 공식, 해리) + */ + private static double calculateDistanceNm(double lat1, double lon1, double lat2, double lon2) { + double lat1Rad = Math.toRadians(lat1); + double lat2Rad = Math.toRadians(lat2); + double deltaLat = lat2Rad - lat1Rad; + double deltaLon = Math.toRadians(lon2 - lon1); + + double a = Math.sin(deltaLat / 2) * Math.sin(deltaLat / 2) + + Math.cos(lat1Rad) * Math.cos(lat2Rad) * + Math.sin(deltaLon / 2) * Math.sin(deltaLon / 2); + double c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + + // 지구 반지름 (nm) + double R = 3440.065; + return R * c; + } + + /** + * 선박 정보 조회 인터페이스 + */ + @FunctionalInterface + public interface VesselInfoProvider { + VesselInfo getVesselInfo(String sigSrcCd, String targetId); + } + + /** + * 선박 정보 DTO + */ + public static class VesselInfo { + private final String shipName; + private final String shipType; + + public VesselInfo(String shipName, String shipType) { + this.shipName = shipName != null ? shipName : "-"; + this.shipType = shipType != null ? shipType : "-"; + } + + public String getShipName() { + return shipName; + } + + public String getShipType() { + return shipType; + } + } +} diff --git a/src/main/java/gc/mda/signal_batch/global/util/VesselTrackDataJobListener.java b/src/main/java/gc/mda/signal_batch/global/util/VesselTrackDataJobListener.java index 0108035..0764353 100644 --- a/src/main/java/gc/mda/signal_batch/global/util/VesselTrackDataJobListener.java +++ b/src/main/java/gc/mda/signal_batch/global/util/VesselTrackDataJobListener.java @@ -2,6 +2,7 @@ package gc.mda.signal_batch.global.util; import gc.mda.signal_batch.domain.vessel.model.VesselData; import gc.mda.signal_batch.domain.gis.cache.AreaBoundaryCache; +import gc.mda.signal_batch.domain.vessel.service.VesselPreviousBucketCache; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -28,16 +29,21 @@ public class VesselTrackDataJobListener implements JobExecutionListener { private final JdbcTemplate collectJdbcTemplate; private final VesselTrackDataHolder vesselTrackDataHolder; - private final AreaBoundaryCache areaBoundaryCache; // 추가 - + private final AreaBoundaryCache areaBoundaryCache; + private final VesselPreviousBucketCache previousBucketCache; + @Value("${vessel.batch.fetch-size:50000}") private int fetchSize; - + @BeforeJob public void beforeJob(JobExecution jobExecution) { // Area/Haegu 경계 캐시 갱신 areaBoundaryCache.refresh(); log.info("Refreshed area boundary cache"); + + // 이전 버킷 캐시 Fallback 플래그 리셋 (새 Job 실행 시 1회만 DB 조회) + previousBucketCache.resetFallbackFlag(); + log.info("Reset previous bucket cache fallback flag"); LocalDateTime startTime = LocalDateTime.parse( jobExecution.getJobParameters().getString("startTime")); @@ -111,6 +117,10 @@ public class VesselTrackDataJobListener implements JobExecutionListener { @Override public void afterJob(JobExecution jobExecution) { + // DB 조회 통계 출력 + previousBucketCache.logJobStatistics(); + + // 데이터 정리 vesselTrackDataHolder.clear(); log.debug("Cleared vessel track data after job completion"); } diff --git a/src/main/java/gc/mda/signal_batch/global/websocket/service/ChunkedTrackStreamingService.java b/src/main/java/gc/mda/signal_batch/global/websocket/service/ChunkedTrackStreamingService.java index 2bbb9f3..fd759f7 100644 --- a/src/main/java/gc/mda/signal_batch/global/websocket/service/ChunkedTrackStreamingService.java +++ b/src/main/java/gc/mda/signal_batch/global/websocket/service/ChunkedTrackStreamingService.java @@ -535,8 +535,9 @@ public class ChunkedTrackStreamingService { accumulator.shipName = vesselInfo.shipName; accumulator.shipType = vesselInfo.shipType; - // shipKindCode 계산 - accumulator.shipKindCode = ShipKindCodeConverter.getShipKindCode(sigSrcCd, vesselInfo.shipType); + // shipKindCode 계산 (선박명 패턴 매칭 포함 - 어망/부이 판별) + accumulator.shipKindCode = ShipKindCodeConverter.getShipKindCodeWithNamePattern( + sigSrcCd, vesselInfo.shipType, vesselInfo.shipName, targetId); // 테스트용 로그 - 처음 10개 선박만 // if (vesselCount <= 10) { @@ -568,8 +569,9 @@ public class ChunkedTrackStreamingService { // log.info("[{}] Range [{} - {}] = {} minutes, {} buckets expected", // tableName, range.getStart(), range.getEnd(), rangeMinutes, expectedBuckets); -// log.info("[{}] Processed {} tracks for {} unique vessels (avg {:.1f} tracks/vessel)", -// tableName, trackCount, vesselCount, vesselCount > 0 ? (double)trackCount/vesselCount : 0); +// log.info("[{}] Processed {} tracks for {} unique vessels (avg {} tracks/vessel)", +// tableName, trackCount, vesselCount, +// vesselCount > 0 ? String.format("%.1f", (double)trackCount/vesselCount) : "0.0"); // log.debug("[{}] Vessel count in this chunk: {} (cumulative in vesselMap: {})", // tableName, vesselCount, vesselMap.size()); } @@ -849,8 +851,9 @@ public class ChunkedTrackStreamingService { accumulator.shipName = vesselInfo.shipName; accumulator.shipType = vesselInfo.shipType; - // shipKindCode 계산 - accumulator.shipKindCode = ShipKindCodeConverter.getShipKindCode(track.getSigSrcCd(), vesselInfo.shipType); + // shipKindCode 계산 (선박명 패턴 매칭 포함 - 어망/부이 판별) + accumulator.shipKindCode = ShipKindCodeConverter.getShipKindCodeWithNamePattern( + track.getSigSrcCd(), vesselInfo.shipType, vesselInfo.shipName, track.getTargetId()); mergedMap.put(vesselId, accumulator); } @@ -1761,8 +1764,9 @@ public class ChunkedTrackStreamingService { acc.targetId = finalTargetId; acc.shipName = info.shipName; acc.shipType = info.shipType; - // shipKindCode 계산 추가 - acc.shipKindCode = ShipKindCodeConverter.getShipKindCode(finalSigSrcCd, info.shipType); + // shipKindCode 계산 (선박명 패턴 매칭 포함 - 어망/부이 판별) + acc.shipKindCode = ShipKindCodeConverter.getShipKindCodeWithNamePattern( + finalSigSrcCd, info.shipType, info.shipName, finalTargetId); return acc; }); @@ -2406,7 +2410,9 @@ public class ChunkedTrackStreamingService { acc.targetId = finalTargetId; acc.shipName = info.shipName; acc.shipType = info.shipType; - acc.shipKindCode = ShipKindCodeConverter.getShipKindCode(finalSigSrcCd, info.shipType); + // shipKindCode 계산 (선박명 패턴 매칭 포함 - 어망/부이 판별) + acc.shipKindCode = ShipKindCodeConverter.getShipKindCodeWithNamePattern( + finalSigSrcCd, info.shipType, info.shipName, finalTargetId); return acc; }); diff --git a/src/main/java/gc/mda/signal_batch/monitoring/controller/BatchAdminController.java b/src/main/java/gc/mda/signal_batch/monitoring/controller/BatchAdminController.java index 6d6e5ea..093a605 100644 --- a/src/main/java/gc/mda/signal_batch/monitoring/controller/BatchAdminController.java +++ b/src/main/java/gc/mda/signal_batch/monitoring/controller/BatchAdminController.java @@ -1,6 +1,9 @@ package gc.mda.signal_batch.monitoring.controller; import gc.mda.signal_batch.monitoring.service.BatchMetadataCleanupService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.batch.core.*; @@ -28,6 +31,7 @@ import java.util.stream.Collectors; @RequestMapping("/admin/batch") @ConditionalOnProperty(name = "vessel.batch.scheduler.enabled", havingValue = "true", matchIfMissing = true) @RequiredArgsConstructor +@Tag(name = "배치 관리 API", description = "Spring Batch Job 실행, 중지, 재시작 및 통계 조회 API") public class BatchAdminController { @Autowired @@ -57,10 +61,11 @@ public class BatchAdminController { * Job 실행 */ @PostMapping("/job/run") + @Operation(summary = "배치 Job 실행", description = "지정된 배치 Job을 실행합니다. vesselAggregationJob, vesselTrackAggregationJob, dailyAggregationJob 지원") public ResponseEntity> runJob( - @RequestParam(required = false) String jobName, - @RequestParam(required = false) String startTime, - @RequestParam(required = false) String endTime) { + @Parameter(description = "Job 이름 (기본: vesselAggregationJob)") @RequestParam(required = false) String jobName, + @Parameter(description = "시작 시간 (형식: yyyy-MM-ddTHH:mm:ss)") @RequestParam(required = false) String startTime, + @Parameter(description = "종료 시간 (형식: yyyy-MM-ddTHH:mm:ss)") @RequestParam(required = false) String endTime) { try { Job job; @@ -113,7 +118,9 @@ public class BatchAdminController { * Job 중지 */ @PostMapping("/job/stop/{executionId}") - public ResponseEntity> stopJob(@PathVariable Long executionId) { + @Operation(summary = "실행 중인 Job 중지", description = "실행 중인 배치 Job을 중지합니다") + public ResponseEntity> stopJob( + @Parameter(description = "Job 실행 ID") @PathVariable Long executionId) { try { boolean stopped = jobOperator.stop(executionId); @@ -133,7 +140,9 @@ public class BatchAdminController { * Job 재시작 */ @PostMapping("/job/restart/{executionId}") - public ResponseEntity> restartJob(@PathVariable Long executionId) { + @Operation(summary = "실패한 Job 재시작", description = "실패한 배치 Job을 재시작합니다") + public ResponseEntity> restartJob( + @Parameter(description = "Job 실행 ID") @PathVariable Long executionId) { try { Long newExecutionId = jobOperator.restart(executionId); @@ -153,7 +162,9 @@ public class BatchAdminController { * 실패한 Job을 ABANDONED 상태로 변경 (재시도 방지) */ @PostMapping("/job/abandon/{executionId}") - public ResponseEntity> abandonJob(@PathVariable Long executionId) { + @Operation(summary = "Job ABANDONED 처리", description = "실패한 Job을 ABANDONED 상태로 변경하여 재시도를 방지합니다") + public ResponseEntity> abandonJob( + @Parameter(description = "Job 실행 ID") @PathVariable Long executionId) { try { JobExecution execution = jobExplorer.getJobExecution(executionId); @@ -214,6 +225,7 @@ public class BatchAdminController { * 모든 실패한 Job을 ABANDONED로 변경 */ @PostMapping("/job/abandon-all-failed") + @Operation(summary = "모든 실패한 Job ABANDONED 처리", description = "모든 실패 상태의 Job을 일괄로 ABANDONED 상태로 변경합니다") public ResponseEntity> abandonAllFailedJobs() { try { int abandonedCount = 0; @@ -272,6 +284,7 @@ public class BatchAdminController { * 실행 중인 Job 목록 */ @GetMapping("/job/running") + @Operation(summary = "실행 중인 Job 목록", description = "현재 실행 중인 모든 배치 Job 목록을 조회합니다") public ResponseEntity>> getRunningJobs() { try { List> runningJobs = new ArrayList<>(); @@ -315,9 +328,10 @@ public class BatchAdminController { * Job 실행 이력 */ @GetMapping("/job/history") + @Operation(summary = "Job 실행 이력 조회", description = "배치 Job의 실행 이력을 조회합니다") public ResponseEntity>> getJobHistory( - @RequestParam(required = false) String jobName, - @RequestParam(defaultValue = "50") int limit) { + @Parameter(description = "Job 이름 (미지정시 전체)") @RequestParam(required = false) String jobName, + @Parameter(description = "조회 개수") @RequestParam(defaultValue = "50") int limit) { try { List> history = new ArrayList<>(); @@ -452,8 +466,9 @@ public class BatchAdminController { * 실패한 Job 목록 */ @GetMapping("/job/failed") + @Operation(summary = "실패한 Job 목록", description = "지정된 시간 내의 실패한 Job 목록을 조회합니다") public ResponseEntity>> getFailedJobs( - @RequestParam(defaultValue = "24") int hoursBack) { + @Parameter(description = "조회 기간 (시간)") @RequestParam(defaultValue = "24") int hoursBack) { try { LocalDateTime since = LocalDateTime.now().minusHours(hoursBack); @@ -523,8 +538,9 @@ public class BatchAdminController { * 배치 통계 */ @GetMapping("/statistics") + @Operation(summary = "배치 통계 조회", description = "지정된 기간의 배치 실행 통계를 조회합니다") public ResponseEntity> getBatchStatistics( - @RequestParam(defaultValue = "7") int days) { + @Parameter(description = "조회 기간 (일)") @RequestParam(defaultValue = "7") int days) { try { LocalDateTime since = LocalDateTime.now().minusDays(days); @@ -639,6 +655,7 @@ public class BatchAdminController { * 일별 처리 통계 (Dashboard 차트용) */ @GetMapping("/daily-stats") + @Operation(summary = "일별 처리 통계", description = "최근 7일간 일별 배치 처리 통계를 조회합니다 (대시보드 차트용)") public ResponseEntity> getDailyStatistics() { try { Map result = new HashMap<>(); @@ -753,6 +770,7 @@ public class BatchAdminController { * 배치 메타데이터 정리 실행 */ @PostMapping("/cleanup") + @Operation(summary = "배치 메타데이터 정리", description = "오래된 배치 Job 메타데이터를 정리합니다") public ResponseEntity> cleanupBatchMetadata() { try { log.info("Manual batch metadata cleanup requested"); @@ -806,6 +824,7 @@ public class BatchAdminController { * 손상된 ExecutionContext 데이터 정리 (패키지 변경으로 인한 역직렬화 오류) */ @PostMapping("/cleanup-corrupted") + @Operation(summary = "손상된 ExecutionContext 정리", description = "패키지 변경으로 인한 역직렬화 오류가 발생한 ExecutionContext 데이터를 정리합니다") public ResponseEntity> cleanupCorruptedExecutionContext() { try { log.info("Manual corrupted ExecutionContext cleanup requested"); @@ -833,6 +852,7 @@ public class BatchAdminController { * 배치 메타데이터 현재 상태 조회 */ @GetMapping("/metadata-status") + @Operation(summary = "배치 메타데이터 상태", description = "배치 메타데이터 테이블의 현재 상태를 조회합니다") public ResponseEntity> getBatchMetadataStatus() { try { // 여기서는 간단하게 현재 테이블 상태만 조회 diff --git a/src/main/java/gc/mda/signal_batch/monitoring/controller/DataSourceDebugController.java b/src/main/java/gc/mda/signal_batch/monitoring/controller/DataSourceDebugController.java index f45b83e..c2db053 100644 --- a/src/main/java/gc/mda/signal_batch/monitoring/controller/DataSourceDebugController.java +++ b/src/main/java/gc/mda/signal_batch/monitoring/controller/DataSourceDebugController.java @@ -1,6 +1,8 @@ package gc.mda.signal_batch.monitoring.controller; import gc.mda.signal_batch.global.util.DataSourceLogger; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Qualifier; @@ -23,6 +25,7 @@ import java.util.Map; @RequestMapping("/admin/debug") @RequiredArgsConstructor @ConditionalOnProperty(name = "vessel.batch.scheduler.enabled", havingValue = "true", matchIfMissing = true) +@Tag(name = "DataSource 디버그 API", description = "DataSource 연결 상태 및 테이블 정보 확인 API") public class DataSourceDebugController { @Qualifier("collectDataSource") @@ -41,6 +44,7 @@ public class DataSourceDebugController { private final JdbcTemplate queryJdbcTemplate; @GetMapping("/datasources") + @Operation(summary = "DataSource 정보 조회", description = "Collect, Query, Batch DataSource의 연결 정보, 스키마, 연결 테스트 결과를 조회합니다") public Map getDataSourceInfo() { Map result = new HashMap<>(); @@ -53,6 +57,7 @@ public class DataSourceDebugController { } @GetMapping("/check-tables") + @Operation(summary = "테이블 존재 여부 확인", description = "Collect DB와 Query DB의 주요 테이블 존재 여부 및 크기를 확인합니다") public Map checkTables() { Map result = new HashMap<>(); diff --git a/src/main/java/gc/mda/signal_batch/monitoring/controller/MetricsController.java b/src/main/java/gc/mda/signal_batch/monitoring/controller/MetricsController.java index a3691b6..2feaeb0 100644 --- a/src/main/java/gc/mda/signal_batch/monitoring/controller/MetricsController.java +++ b/src/main/java/gc/mda/signal_batch/monitoring/controller/MetricsController.java @@ -1,5 +1,8 @@ package gc.mda.signal_batch.monitoring.controller; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.batch.core.JobExecution; @@ -19,6 +22,7 @@ import java.util.*; @RequestMapping("/admin") @RequiredArgsConstructor @ConditionalOnProperty(name = "vessel.batch.scheduler.enabled", havingValue = "true", matchIfMissing = true) +@Tag(name = "메트릭스 API", description = "시스템 메트릭, Job 통계, 대해구별 집계 및 데이터 정합성 검증 API") public class MetricsController { private final JobExplorer jobExplorer; @@ -26,6 +30,7 @@ public class MetricsController { @GetMapping("/metrics/summary") + @Operation(summary = "시스템 메트릭 요약", description = "Job 통계, 메모리 사용량, 스레드 수, DB 연결 수 등 시스템 전반의 메트릭을 조회합니다") public Map getMetricsSummary() { Map summary = new HashMap<>(); @@ -80,7 +85,9 @@ public class MetricsController { } @GetMapping("/jobs/recent") - public List> getRecentJobs(@RequestParam(defaultValue = "10") int count) { + @Operation(summary = "최근 Job 실행 이력", description = "vesselAggregationJob의 최근 실행 이력을 조회합니다. 상태, 시작/종료 시간, 처리 건수를 포함합니다") + public List> getRecentJobs( + @Parameter(description = "조회할 Job 수 (기본: 10)") @RequestParam(defaultValue = "10") int count) { List> jobs = new ArrayList<>(); try { @@ -124,6 +131,7 @@ public class MetricsController { } @GetMapping("/haegu/stats") + @Operation(summary = "대해구별 통계", description = "최근 15분 데이터 기준 대해구별 선박 수, 밀도 등 통계를 조회합니다. 대해구/소해구 레벨 데이터를 집계합니다") public List> getHaeguStats() { List> results = new ArrayList<>(); @@ -216,6 +224,7 @@ public class MetricsController { * 대해구 데이터 정합성 검증 */ @GetMapping("/haegu/validate") + @Operation(summary = "대해구 데이터 정합성 검증", description = "타일 레벨별 분포, 중복 데이터, 대해구-소해구 집계 정합성을 검증합니다") public Map validateHaeguData() { Map validation = new HashMap<>(); diff --git a/src/main/java/gc/mda/signal_batch/monitoring/controller/MigrationController.java b/src/main/java/gc/mda/signal_batch/monitoring/controller/MigrationController.java index f0137e9..b45c80a 100644 --- a/src/main/java/gc/mda/signal_batch/monitoring/controller/MigrationController.java +++ b/src/main/java/gc/mda/signal_batch/monitoring/controller/MigrationController.java @@ -1,6 +1,9 @@ package gc.mda.signal_batch.monitoring.controller; import gc.mda.signal_batch.migration.unix_timestamp.MissingDataFiller; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -20,12 +23,15 @@ import java.util.Map; @RequestMapping("/api/migration") @ConditionalOnProperty(name = "vessel.batch.scheduler.enabled", havingValue = "true", matchIfMissing = true) @RequiredArgsConstructor +@Tag(name = "마이그레이션 API", description = "Unix timestamp 기반 항적 데이터 마이그레이션 및 누락 데이터 복구 API") public class MigrationController { private final MissingDataFiller missingDataFiller; @PostMapping("/fill-hourly") + @Operation(summary = "Hourly 누락 데이터 채우기", description = "지정된 시간의 누락된 Hourly 집계 데이터를 5분 데이터로부터 생성합니다") public ResponseEntity> fillHourlyData( + @Parameter(description = "대상 시간 (형식: yyyy-MM-dd HH:mm:ss)") @RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime timeBucket) { try { @@ -46,7 +52,9 @@ public class MigrationController { } @PostMapping("/fill-daily") + @Operation(summary = "Daily 누락 데이터 채우기", description = "지정된 날짜의 누락된 Daily 집계 데이터를 Hourly 데이터로부터 생성합니다") public ResponseEntity> fillDailyData( + @Parameter(description = "대상 날짜 (형식: yyyy-MM-dd)") @RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate date) { try { @@ -67,7 +75,9 @@ public class MigrationController { } @GetMapping("/status") + @Operation(summary = "마이그레이션 상태 조회", description = "지정된 날짜의 마이그레이션 상태를 조회합니다") public ResponseEntity> getStatus( + @Parameter(description = "대상 날짜 (형식: yyyy-MM-dd)") @RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate date) { String sql = """ @@ -93,11 +103,12 @@ public class MigrationController { * - M값이 잘못되었으면(9시간 차이) 수정 */ @PostMapping("/fix-unix-timestamps") + @Operation(summary = "Unix timestamp 검증 및 수정", description = "track_geom_v2가 비어있거나 M값이 잘못된 레코드를 자동으로 수정합니다") public ResponseEntity> fixUnixTimestamps( - @RequestParam String tableName, // "5min", "hourly", "daily" - @RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime startTime, - @RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime endTime, - @RequestParam(defaultValue = "1000") int batchSize) { + @Parameter(description = "테이블 타입 (5min, hourly, daily)") @RequestParam String tableName, + @Parameter(description = "시작 시간") @RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime startTime, + @Parameter(description = "종료 시간") @RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime endTime, + @Parameter(description = "배치 크기") @RequestParam(defaultValue = "1000") int batchSize) { try { FixResult result = missingDataFiller.fixUnixTimestamps(tableName, startTime, endTime, batchSize); @@ -125,9 +136,10 @@ public class MigrationController { * Unix timestamp 검증 (수정 없이 확인만) */ @GetMapping("/verify-unix-timestamps") + @Operation(summary = "Unix timestamp 검증", description = "지정된 테이블과 시간의 Unix timestamp 정합성을 검증합니다 (수정 없음)") public ResponseEntity> verifyUnixTimestamps( - @RequestParam String tableName, - @RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime timeBucket) { + @Parameter(description = "테이블 타입 (5min, hourly, daily)") @RequestParam String tableName, + @Parameter(description = "대상 시간") @RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") LocalDateTime timeBucket) { try { VerifyResult result = missingDataFiller.verifyUnixTimestamps(tableName, timeBucket); diff --git a/src/main/java/gc/mda/signal_batch/monitoring/controller/MonitoringController.java b/src/main/java/gc/mda/signal_batch/monitoring/controller/MonitoringController.java index 38853d1..713ae18 100644 --- a/src/main/java/gc/mda/signal_batch/monitoring/controller/MonitoringController.java +++ b/src/main/java/gc/mda/signal_batch/monitoring/controller/MonitoringController.java @@ -1,5 +1,7 @@ package gc.mda.signal_batch.monitoring.controller; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; @@ -14,6 +16,7 @@ import java.util.*; @RequestMapping("/monitor") @RequiredArgsConstructor @ConditionalOnProperty(name = "vessel.batch.scheduler.enabled", havingValue = "true", matchIfMissing = true) +@Tag(name = "시스템 모니터링 API", description = "데이터 처리 지연, 해구별 현황, 처리량 및 데이터 품질 모니터링 API") public class MonitoringController { private final JdbcTemplate collectJdbcTemplate; @@ -23,6 +26,7 @@ public class MonitoringController { * 데이터 처리 지연 상태 확인 */ @GetMapping("/delay") + @Operation(summary = "데이터 처리 지연 상태", description = "수집DB와 조회DB 간의 데이터 처리 지연 시간을 확인합니다") public Map getProcessingDelay() { Map result = new HashMap<>(); @@ -76,6 +80,7 @@ public class MonitoringController { * 대해구별 실시간 처리 현황 */ @GetMapping("/haegu/realtime") + @Operation(summary = "대해구별 실시간 현황", description = "최신 타일 데이터 기준 대해구별 선박 통계를 조회합니다") public List> getRealtimeHaeguStatus() { String sql = """ WITH recent_data AS ( @@ -123,6 +128,7 @@ public class MonitoringController { * 시스템 처리량 메트릭 */ @GetMapping("/throughput") + @Operation(summary = "시스템 처리량 메트릭", description = "최근 1시간 처리량 및 파티션 크기 정보를 조회합니다") public Map getThroughputMetrics() { Map metrics = new HashMap<>(); @@ -185,6 +191,7 @@ public class MonitoringController { */ @SuppressWarnings("null") @GetMapping("/quality") + @Operation(summary = "데이터 품질 검증", description = "중복 데이터 및 누락 타일을 확인하여 데이터 품질을 검증합니다") public Map checkDataQuality() { Map quality = new HashMap<>(); diff --git a/src/main/java/gc/mda/signal_batch/monitoring/controller/WebSocketMonitoringController.java b/src/main/java/gc/mda/signal_batch/monitoring/controller/WebSocketMonitoringController.java index bba7c5d..6ffe1da 100644 --- a/src/main/java/gc/mda/signal_batch/monitoring/controller/WebSocketMonitoringController.java +++ b/src/main/java/gc/mda/signal_batch/monitoring/controller/WebSocketMonitoringController.java @@ -2,6 +2,9 @@ package gc.mda.signal_batch.monitoring.controller; import gc.mda.signal_batch.monitoring.service.TrackStreamingMetrics; import gc.mda.signal_batch.global.websocket.service.StompTrackStreamingService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; @@ -16,6 +19,7 @@ import java.util.Map; @RestController @RequestMapping("/api/websocket") @RequiredArgsConstructor +@Tag(name = "WebSocket 모니터링 API", description = "WebSocket 스트리밍 상태 및 쿼리 진행 상황 모니터링 API") public class WebSocketMonitoringController { private final TrackStreamingMetrics trackStreamingMetrics; @@ -25,6 +29,7 @@ public class WebSocketMonitoringController { * WebSocket 스트리밍 현황 조회 */ @GetMapping("/status") + @Operation(summary = "WebSocket 스트리밍 현황", description = "활성 쿼리 수, 메모리 사용량, 서버 시간 등 WebSocket 스트리밍 전반의 상태를 조회합니다") public ResponseEntity> getStreamingStatus() { Map status = new HashMap<>(); @@ -50,7 +55,9 @@ public class WebSocketMonitoringController { * 특정 쿼리의 상태 조회 */ @GetMapping("/query/{queryId}/status") - public ResponseEntity> getQueryStatus(@PathVariable String queryId) { + @Operation(summary = "쿼리 상태 조회", description = "특정 WebSocket 쿼리의 진행 상태, 메시지, 진행률, 스트리밍된 항적 수를 조회합니다") + public ResponseEntity> getQueryStatus( + @Parameter(description = "쿼리 ID") @PathVariable String queryId) { Map result = new HashMap<>(); var queryStatus = trackStreamingService.getQueryStatus(queryId); @@ -66,6 +73,7 @@ public class WebSocketMonitoringController { * WebSocket 테스트 페이지로 리다이렉트 */ @GetMapping("/test") + @Operation(summary = "WebSocket 테스트 페이지", description = "WebSocket 스트리밍 테스트 페이지로 리다이렉트합니다") public RedirectView redirectToTestPage() { return new RedirectView("/websocket/track-streaming-test.html"); } diff --git a/src/main/java/gc/mda/signal_batch/monitoring/service/BatchMetadataCleanupService.java b/src/main/java/gc/mda/signal_batch/monitoring/service/BatchMetadataCleanupService.java index ea80a7a..7740a41 100644 --- a/src/main/java/gc/mda/signal_batch/monitoring/service/BatchMetadataCleanupService.java +++ b/src/main/java/gc/mda/signal_batch/monitoring/service/BatchMetadataCleanupService.java @@ -5,6 +5,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Profile; import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.scheduling.annotation.Scheduled; import org.springframework.stereotype.Service; @@ -21,6 +22,7 @@ import java.time.format.DateTimeFormatter; */ @Slf4j @Service +@Profile("!query") // query 프로파일에서는 배치 메타데이터 정리 비활성화 @ConditionalOnProperty(name = "vessel.batch.scheduler.enabled", havingValue = "true", matchIfMissing = true) public class BatchMetadataCleanupService { diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index e564482..e698a60 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -102,6 +102,9 @@ logging: # 개발 환경 배치 설정 (성능 최적화) vessel: # spring 하위가 아닌 최상위 레벨 + # 통합선박 설정 + integration: + enabled: true # 통합선박 기능 활성화 여부 batch: # Area Statistics 처리를 위한 별도 설정 area-statistics: @@ -194,6 +197,12 @@ vessel: # spring 하위가 아닌 최상위 레벨 cache-enabled: true cache-size: 2000 + cache: + latest-position: + enabled: true # 운영 환경: 활성화 + ttl-minutes: 60 # 60분 TTL + max-size: 80000 # 최대 50,000척 + refresh-interval-minutes: 2 # 2분치 데이터 조회 (수집 지연 고려) # 비정상 궤적 검출 설정 (개선됨) abnormal-detection: diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml index ad13fcf..e2630fa 100644 --- a/src/main/resources/application-local.yml +++ b/src/main/resources/application-local.yml @@ -1,20 +1,42 @@ +# 로컬 개발 환경 설정 +# 단일 PostgreSQL 인스턴스를 모든 DataSource가 공유 server: port: 8090 spring: - # 로컬 환경에서는 하나의 DB만 사용 datasource: - url: jdbc:postgresql://localhost:5432/mdadb2?stringtype=unspecified¤tSchema=signal&TimeZone=Asia/Seoul - username: mda - password: mda#8932 - driver-class-name: org.postgresql.Driver - hikari: - pool-name: LocalHikariPool - connection-timeout: 30000 - idle-timeout: 600000 - max-lifetime: 1800000 - maximum-pool-size: 20 - minimum-idle: 5 + # 로컬 수집 DB (동일 DB 사용) + collect: + jdbc-url: jdbc:postgresql://localhost:5432/mdadb2?stringtype=unspecified¤tSchema=signal&TimeZone=Asia/Seoul + username: mda + password: mda#8932 + driver-class-name: org.postgresql.Driver + hikari: + pool-name: LocalCollectPool + maximum-pool-size: 10 + minimum-idle: 2 + + # 로컬 조회 DB (동일 DB 사용) + query: + jdbc-url: jdbc:postgresql://localhost:5432/mdadb2?stringtype=unspecified¤tSchema=signal&TimeZone=Asia/Seoul + username: mda + password: mda#8932 + driver-class-name: org.postgresql.Driver + hikari: + pool-name: LocalQueryPool + maximum-pool-size: 10 + minimum-idle: 2 + + # 로컬 배치 메타 DB (동일 DB 사용) + batch: + jdbc-url: jdbc:postgresql://localhost:5432/mdadb2?stringtype=unspecified¤tSchema=signal&TimeZone=Asia/Seoul + username: mda + password: mda#8932 + driver-class-name: org.postgresql.Driver + hikari: + pool-name: LocalBatchPool + maximum-pool-size: 5 + minimum-idle: 1 batch: job: @@ -27,13 +49,35 @@ logging: level: root: INFO gc.mda.signal_batch: DEBUG + gc.mda.signal_batch.batch: DEBUG org.springframework.batch: DEBUG org.springframework.jdbc: DEBUG org.springframework.transaction: DEBUG # 로컬 환경 배치 설정 vessel: + # 통합선박 기능 비활성화 (로컬에서는 테이블 없을 수 있음) + integration: + enabled: false + batch: + # 스케줄러 설정 - 로컬에서는 비활성화 + scheduler: + enabled: false + incremental: + delay-minutes: 3 + + # 비정상 궤적 검출 비활성화 + abnormal-detection: + enabled: false + + # 로컬 최적화 설정 chunk-size: 1000 page-size: 1000 - partition-size: 4 # 로컬에서는 적은 수의 파티션 사용 \ No newline at end of file + partition-size: 4 + fetch-size: 10000 + + # 캐시 비활성화 + cache: + latest-position: + enabled: false diff --git a/src/main/resources/application-prod-mpr.yml b/src/main/resources/application-prod-mpr.yml new file mode 100644 index 0000000..ff1ea83 --- /dev/null +++ b/src/main/resources/application-prod-mpr.yml @@ -0,0 +1,278 @@ +server: + port: 8090 + tomcat: + threads: + max: 200 + min-spare: 10 + connection-timeout: 60000 # 60초로 증가 + max-http-form-post-size: 50MB # 50MB로 증가 + max-swallow-size: 50MB # 50MB로 증가 + max-connections: 10000 # 최대 연결 수 + accept-count: 100 # 대기열 크기 + +spring: + datasource: + # 원격 수집 DB + collect: + jdbc-url: jdbc:postgresql://10.26.252.39:5432/mdadb?currentSchema=signal&options=-csearch_path=signal,public&assumeMinServerVersion=12&reWriteBatchedInserts=true + username: mda + password: mda#8932 + driver-class-name: org.postgresql.Driver + hikari: + pool-name: CollectHikariPool + connection-timeout: 30000 # 원격 연결이므로 타임아웃 증가 + idle-timeout: 600000 + max-lifetime: 1800000 + maximum-pool-size: 20 # 10 -> 20 증가 + minimum-idle: 5 # 2 -> 5 증가 + # 원격 연결 안정성을 위한 추가 설정 + connection-test-query: SELECT 1 + validation-timeout: 5000 + leak-detection-threshold: 60000 # 커넥션 누수 감지 (60초) + connection-init-sql: "SET TIME ZONE 'Asia/Seoul'; SET search_path TO signal, public;" + + # 원격 조회 DB + query: + jdbc-url: jdbc:postgresql://10.29.17.90:5432/mpcdb2?currentSchema=signal&assumeMinServerVersion=12&reWriteBatchedInserts=true + username: mpc + password: mpc#8932 + driver-class-name: org.postgresql.Driver + hikari: + pool-name: QueryHikariPool + connection-timeout: 5000 + idle-timeout: 600000 + max-lifetime: 1800000 + maximum-pool-size: 60 # 20 -> 40 증가 + minimum-idle: 10 # 5 -> 10 증가 + connection-test-query: SELECT 1 + validation-timeout: 5000 + leak-detection-threshold: 60000 # 커넥션 누수 감지 (60초) + # PostGIS 함수를 위해 public 스키마를 search_path에 명시적으로 추가 + connection-init-sql: "SET TIME ZONE 'Asia/Seoul'; SET search_path TO signal, public, pg_catalog;" + statement-cache-size: 250 + data-source-properties: + prepareThreshold: 3 + preparedStatementCacheQueries: 250 + + # 로컬 배치 메타 DB + batch: + jdbc-url: jdbc:postgresql://10.26.252.51:5432/mdadb?currentSchema=public&assumeMinServerVersion=12&reWriteBatchedInserts=true + username: mda + password: mda#8932 + driver-class-name: org.postgresql.Driver + hikari: + pool-name: BatchHikariPool + maximum-pool-size: 20 # 10 → 30 증가 + minimum-idle: 10 # 2 → 10 증가 + connection-timeout: 30000 # 30초 타임아웃 + idle-timeout: 600000 + max-lifetime: 1800000 + leak-detection-threshold: 60000 # 커넥션 누수 감지 (60초) + connection-init-sql: "SET TIME ZONE 'Asia/Seoul'; SET search_path TO public, signal;" + + # Request 크기 설정 + servlet: + multipart: + max-file-size: 50MB + max-request-size: 50MB + + # Spring Batch 설정 + batch: + job: + enabled: false + jdbc: + initialize-schema: never # always에서 never로 변경 (이미 수동으로 생성했으므로) + table-prefix: BATCH_ + +logging: + level: + root: INFO + gc.mda.signal_batch: DEBUG + gc.mda.signal_batch.global.util: INFO + gc.mda.signal_batch.global.websocket.service: INFO + gc.mda.signal_batch.batch.writer: INFO + gc.mda.signal_batch.batch.reader: INFO + gc.mda.signal_batch.batch.processor: INFO + gc.mda.signal_batch.domain: INFO + gc.mda.signal_batch.monitoring: DEBUG + gc.mda.signal_batch.monitoring.controller: INFO + org.springframework.batch: INFO + org.springframework.jdbc: WARN + org.postgresql: WARN + com.zaxxer.hikari: INFO + +# 개발 환경 배치 설정 (성능 최적화) +vessel: # spring 하위가 아닌 최상위 레벨 + # 통합선박 설정 + integration: + enabled: true # 통합선박 기능 활성화 여부 + batch: + # Area Statistics 처리를 위한 별도 설정 + area-statistics: + chunk-size: 1000 # 5000 → 1000 + batch-size: 500 # 새로 추가 + + chunk-size: 10000 + page-size: 5000 + partition-size: 12 + + # 성능 최적화 설정 + optimization: + enabled: true + dynamic-chunk-sizing: true + memory-optimization: true + cache-optimization: true + thread-pool-optimization: true + # 동적 청크 크기 조정 + chunk: + min-size: 1000 + max-size: 20000 + adjustment-factor: 0.2 + # 메모리 임계값 + memory: + warning-threshold: 70 + critical-threshold: 85 + optimization-threshold: 80 + # 캐시 설정 + cache: + min-hit-rate: 70 + area-boundary-size: 5000 + + # Reader 최적화 + fetch-size: 200000 + use-cursor-reader: true + + # Bulk Insert 최적화 + bulk-insert: + batch-size: 10000 + parallel-threads: 8 + use-binary-copy: false + + # Writer 설정 + writer: + use-advisory-lock: false + parallel-threads: 4 + + # 재시도 설정 + retry: + max-attempts: 3 + initial-interval: 1000 + max-interval: 10000 + multiplier: 2 + + # 스케줄러 설정 + scheduler: + enabled: true + incremental: + delay-minutes: 3 # 데이터 수집 지연 고려 + + # Batch 메타데이터 정리 설정 + metadata: + cleanup: + enabled: true # 자동 정리 활성화 + retention-days: 30 # 보존 기간 (30일) + dry-run: false # false: 실제 삭제, true: 테스트만 + + # 궤적 비정상 검출 설정 + track: + abnormal-detection: + large-gap-threshold-hours: 6 # 이 시간 이상 gap은 연결 안함 + extreme-speed-threshold: 1000 # 이 속도 이상은 무조건 비정상 (knots) + enable-merger-filtering: false # VesselTrackMerger 필터링 활성화 (기본: false) + + # 타임아웃 설정 + query-timeout: 1800 # 30분 + lock: + timeout: 30 + max-retry: 5 + + # Health Check 설정 + health: + job-timeout-hours: 2 + min-partition-count: 1 + + # 그리드 설정 + grid: + mode: haegu + haegu: + cache-enabled: true + cache-size: 2000 + + # 선박 최신 위치 캐시 설정 (운영 환경 활성화) + cache: + latest-position: + enabled: true # 운영 환경: 활성화 + ttl-minutes: 60 # 60분 TTL + max-size: 50000 # 최대 50,000척 + refresh-interval-minutes: 2 # 2분치 데이터 조회 (수집 지연 고려) + + + # 비정상 궤적 검출 설정 (개선됨) + abnormal-detection: + enabled: true + 5min-speed-threshold: 500 # 5분 집계 비정상 속도 임계값 (200 knots로 완화) + # 비정상 판정 기준 (명백한 비정상만 검출하도록 완화) + thresholds: + # 정박/저속 판단 기준 + min-movement-nm: 0.05 # 최소 이동거리 (정박 판단) + stationary-speed-knots: 0.5 # 정박 속도 기준 + + # 선박 관련 임계값 + vessel-physical-limit-knots: 100.0 # 선박 물리적 한계 + vessel-abnormal-speed-knots: 200.0 # 선박 명백한 비정상 속도 + + # 항공기 관련 임계값 + aircraft-physical-limit-knots: 600.0 # 항공기 물리적 한계 + aircraft-abnormal-speed-knots: 800.0 # 항공기 명백한 비정상 속도 + + # 거리 관련 임계값 + base-distance-5min-nm: 20.0 # 5분 기준 거리 (20nm로 완화) + extreme-distance-5min-nm: 100.0 # 5분간 극단적 이동거리 + + # Hourly/Daily 전용 임계값 + hourly-daily-speed-limit: 500.0 # 시간/일 집계시 극단적 속도만 검출 + + # 기타 설정 + distance-tolerance: 3.0 # 거리 허용 배수 (3.0으로 완화) + time-scaling-method: "sqrt" # 시간 스케일링 방법 (sqrt) + # 캐시 설정 + cache: + previous-track-size: 10000 # 이전 궤적 캐시 크기 + ttl-hours: 24 # 캐시 TTL + # 처리 옵션 + processing: + remove-abnormal-segments: false # 비정상 구간 제거 여부 + save-corrected-tracks: true # 보정된 궤적 저장 여부 + exclude-stationary-vessels: true # 정박 선박 제외 여부 + lenient-mode: true # 관대한 모드 활성화 + + # 파티션 관리 설정 (운영 환경 - application.yml 설정 오버라이드) + partition: + # 운영 환경에서는 더 긴 보관 기간 설정 가능 + default-retention: + daily-partitions-retention-days: 7 # 일별 파티션 7일 보관 + monthly-partitions-retention-months: 3 # 월별 파티션 3개월 보관 + + tables: + # 중요 데이터는 더 오래 보관 + t_area_vessel_tracks: + retention-days: 60 # 구역별 선박 항적: 60일 + t_grid_vessel_tracks: + retention-days: 30 # 해구별 선박 항적: 30일 + t_abnormal_tracks: + retention-months: 0 # 비정상 항적: 무한 보관 + +# 액추에이터 설정 +management: + endpoints: + web: + exposure: + include: health,info,metrics,prometheus,env,loggers,threaddump,heapdump,scheduledtasks + endpoint: + health: + show-details: always + show-components: always + metrics: + tags: + application: vessel-batch-aggregation + environment: prod \ No newline at end of file diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index be9349d..ae8702c 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -1,5 +1,5 @@ server: - port: 8090 + port: 18090 tomcat: threads: max: 200 @@ -14,7 +14,7 @@ spring: datasource: # 원격 수집 DB collect: - jdbc-url: jdbc:postgresql://10.26.252.39:5432/mdadb?currentSchema=signal&options=-csearch_path=signal,public&assumeMinServerVersion=12&reWriteBatchedInserts=true + jdbc-url: jdbc:postgresql://10.188.171.182:5432/mdadb?currentSchema=signal&options=-csearch_path=signal,public&assumeMinServerVersion=12&reWriteBatchedInserts=true username: mda password: mda#8932 driver-class-name: org.postgresql.Driver @@ -33,9 +33,9 @@ spring: # 원격 조회 DB query: - jdbc-url: jdbc:postgresql://10.29.17.90:5432/mpcdb2?currentSchema=signal&assumeMinServerVersion=12&reWriteBatchedInserts=true - username: mpc - password: mpc#8932 + jdbc-url: jdbc:postgresql://10.188.171.182:5432/mdadb?currentSchema=signal&assumeMinServerVersion=12&reWriteBatchedInserts=true + username: mda + password: mda#8932 driver-class-name: org.postgresql.Driver hikari: pool-name: QueryHikariPool @@ -54,9 +54,9 @@ spring: prepareThreshold: 3 preparedStatementCacheQueries: 250 - # 로컬 배치 메타 DB + # 로컬 배치 메타 DB (signal 스키마 사용) batch: - jdbc-url: jdbc:postgresql://10.26.252.51:5432/mdadb?currentSchema=public&assumeMinServerVersion=12&reWriteBatchedInserts=true + jdbc-url: jdbc:postgresql://10.188.171.182:5432/mdadb?currentSchema=signal&assumeMinServerVersion=12&reWriteBatchedInserts=true username: mda password: mda#8932 driver-class-name: org.postgresql.Driver @@ -68,7 +68,7 @@ spring: idle-timeout: 600000 max-lifetime: 1800000 leak-detection-threshold: 60000 # 커넥션 누수 감지 (60초) - connection-init-sql: "SET TIME ZONE 'Asia/Seoul'; SET search_path TO public, signal;" + connection-init-sql: "SET TIME ZONE 'Asia/Seoul'; SET search_path TO signal, public;" # Request 크기 설정 servlet: @@ -82,7 +82,7 @@ spring: enabled: false jdbc: initialize-schema: never # always에서 never로 변경 (이미 수동으로 생성했으므로) - table-prefix: BATCH_ + table-prefix: signal.BATCH_ logging: level: @@ -103,6 +103,9 @@ logging: # 개발 환경 배치 설정 (성능 최적화) vessel: # spring 하위가 아닌 최상위 레벨 + # 통합선박 설정 + integration: + enabled: true # 통합선박 기능 활성화 여부 batch: # Area Statistics 처리를 위한 별도 설정 area-statistics: @@ -167,13 +170,13 @@ vessel: # spring 하위가 아닌 최상위 레벨 metadata: cleanup: enabled: true # 자동 정리 활성화 - retention-days: 30 # 보존 기간 (30일) + retention-days: 21 # 보존 기간 (30일) dry-run: false # false: 실제 삭제, true: 테스트만 # 궤적 비정상 검출 설정 track: abnormal-detection: - large-gap-threshold-hours: 6 # 이 시간 이상 gap은 연결 안함 + large-gap-threshold-hours: 4 # 이 시간 이상 gap은 연결 안함 extreme-speed-threshold: 1000 # 이 속도 이상은 무조건 비정상 (knots) enable-merger-filtering: false # VesselTrackMerger 필터링 활성화 (기본: false) @@ -200,7 +203,7 @@ vessel: # spring 하위가 아닌 최상위 레벨 latest-position: enabled: true # 운영 환경: 활성화 ttl-minutes: 60 # 60분 TTL - max-size: 50000 # 최대 50,000척 + max-size: 60000 # 최대 60,000척 refresh-interval-minutes: 2 # 2분치 데이터 조회 (수집 지연 고려) @@ -238,9 +241,9 @@ vessel: # spring 하위가 아닌 최상위 레벨 ttl-hours: 24 # 캐시 TTL # 처리 옵션 processing: - remove-abnormal-segments: false # 비정상 구간 제거 여부 + remove-abnormal-segments: true # 비정상 구간 제거 여부 save-corrected-tracks: true # 보정된 궤적 저장 여부 - exclude-stationary-vessels: true # 정박 선박 제외 여부 + exclude-stationary-vessels: false # 정박 선박 제외 여부 lenient-mode: true # 관대한 모드 활성화 # 파티션 관리 설정 (운영 환경 - application.yml 설정 오버라이드) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 5f37e3c..394fc36 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -126,11 +126,17 @@ management: # 커스텀 설정 vessel: + # 데이터 필터 설정 + filter: + zero-coordinates: + enabled: ${FILTER_ZERO_COORDS:false} # 0 근처 좌표 필터링 (기본: 비활성화) + # true일 경우 lat/lon이 -1 ~ 1 범위인 데이터를 제외 + # 통합선박 설정 integration: - enabled: ${INTEGRATION_ENABLED:true} # 통합선박 기능 활성화 여부 + enabled: ${INTEGRATION_ENABLED:false} # 통합선박 기능 활성화 여부 cache: - refresh-cron: "0 0 3 * * ?" # 매일 03:00 갱신 + refresh-cron: "0 0 6 * * ?" # 매일 06:00 갱신 batch: chunk-size: ${BATCH_CHUNK_SIZE:10000} @@ -160,7 +166,11 @@ vessel: # 테이블별 보관 기간 (기본값과 다를 경우만 설정) tables: - # 일별 파티션 테이블 (단위: 일) + # CollectDB 일별 파티션 테이블 (단위: 일) + sig_test: + retention-days: 14 # 14일 보관 + + # QueryDB 일별 파티션 테이블 (단위: 일) t_vessel_tracks_5min: retention-days: 7 # 7일 보관 t_area_vessel_tracks: @@ -180,9 +190,7 @@ vessel: t_vessel_tracks_hourly: retention-months: 2 # 2개월 보관 t_vessel_tracks_daily: - retention-months: 6 # 6개월 보관 - t_grid_vessel_tracks_hourly: - retention-months: 2 # 2개월 보관 + retention-months: 60 # 60개월 보관 t_grid_tracks_summary_hourly: retention-months: 2 # 2개월 보관 t_grid_tracks_summary_daily: @@ -246,6 +254,11 @@ vessel: max-size: 50000 # 최대 선박 수: 50,000척 refresh-interval-minutes: 2 # 갱신 주기 데이터 범위: 2분치 조회 (수집 지연 고려) + # 이전 버킷 위치 캐시 설정 (버킷 간 점프 검출용) + previous-bucket: + ttl-minutes: 120 # 캐시 TTL: 120분 (위성 AIS 30~60분 간격 고려) + max-size: 100000 # 최대 선박 수: 100,000척 (2시간 누적 고려) + # Swagger/OpenAPI 설정 springdoc: api-docs: diff --git a/src/main/resources/static/admin/batch-admin.html b/src/main/resources/static/admin/batch-admin.html index 2ac82c3..ed58086 100644 --- a/src/main/resources/static/admin/batch-admin.html +++ b/src/main/resources/static/admin/batch-admin.html @@ -3,7 +3,7 @@ - Vessel Batch Admin + 선박 배치 관리 @@ -129,7 +129,7 @@