diff --git a/src/main/java/com/snp/batch/SnpBatchApplication.java b/src/main/java/com/snp/batch/SnpBatchApplication.java index bf4315f..b59535c 100644 --- a/src/main/java/com/snp/batch/SnpBatchApplication.java +++ b/src/main/java/com/snp/batch/SnpBatchApplication.java @@ -2,10 +2,12 @@ package com.snp.batch; import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.context.properties.ConfigurationPropertiesScan; import org.springframework.scheduling.annotation.EnableScheduling; @SpringBootApplication @EnableScheduling +@ConfigurationPropertiesScan public class SnpBatchApplication { public static void main(String[] args) { diff --git a/src/main/java/com/snp/batch/global/config/MaritimeApiWebClientConfig.java b/src/main/java/com/snp/batch/global/config/MaritimeApiWebClientConfig.java index e954a9c..ddeb443 100644 --- a/src/main/java/com/snp/batch/global/config/MaritimeApiWebClientConfig.java +++ b/src/main/java/com/snp/batch/global/config/MaritimeApiWebClientConfig.java @@ -96,7 +96,7 @@ public class MaritimeApiWebClientConfig { .defaultHeaders(headers -> headers.setBasicAuth(maritimeApiUsername, maritimeApiPassword)) .codecs(configurer -> configurer .defaultCodecs() - .maxInMemorySize(30 * 1024 * 1024)) // 30MB 버퍼 + .maxInMemorySize(100 * 1024 * 1024)) // 100MB 버퍼 .build(); } } diff --git a/src/main/java/com/snp/batch/global/controller/BatchController.java b/src/main/java/com/snp/batch/global/controller/BatchController.java index db66315..fc95e42 100644 --- a/src/main/java/com/snp/batch/global/controller/BatchController.java +++ b/src/main/java/com/snp/batch/global/controller/BatchController.java @@ -178,7 +178,7 @@ public class BatchController { } } - @PutMapping("/schedules/{jobName}") + @PostMapping("/schedules/{jobName}/update") public ResponseEntity> updateSchedule( @PathVariable String jobName, @RequestBody Map request) { @@ -206,7 +206,7 @@ public class BatchController { @ApiResponse(responseCode = "200", description = "삭제 성공"), @ApiResponse(responseCode = "500", description = "삭제 실패") }) - @DeleteMapping("/schedules/{jobName}") + @PostMapping("/schedules/{jobName}/delete") public ResponseEntity> deleteSchedule( @Parameter(description = "배치 작업 이름", required = true) @PathVariable String jobName) { @@ -226,7 +226,7 @@ public class BatchController { } } - @PatchMapping("/schedules/{jobName}/toggle") + @PostMapping("/schedules/{jobName}/toggle") public ResponseEntity> toggleSchedule( @PathVariable String jobName, @RequestBody Map request) { diff --git a/src/main/java/com/snp/batch/jobs/aistarget/batch/config/AisTargetImportJobConfig.java b/src/main/java/com/snp/batch/jobs/aistarget/batch/config/AisTargetImportJobConfig.java index c26f40a..d5f7ccf 100644 --- a/src/main/java/com/snp/batch/jobs/aistarget/batch/config/AisTargetImportJobConfig.java +++ b/src/main/java/com/snp/batch/jobs/aistarget/batch/config/AisTargetImportJobConfig.java @@ -6,6 +6,7 @@ import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity; import com.snp.batch.jobs.aistarget.batch.processor.AisTargetDataProcessor; import com.snp.batch.jobs.aistarget.batch.reader.AisTargetDataReader; import com.snp.batch.jobs.aistarget.batch.writer.AisTargetDataWriter; +import com.snp.batch.jobs.aistarget.classifier.Core20CacheManager; import lombok.extern.slf4j.Slf4j; import org.springframework.batch.core.Job; import org.springframework.batch.core.JobExecution; @@ -43,6 +44,7 @@ public class AisTargetImportJobConfig extends BaseJobConfig se.getWriteCount()) - .sum()); + .sum(), + core20CacheManager.size()); } }); } diff --git a/src/main/java/com/snp/batch/jobs/aistarget/batch/entity/AisTargetEntity.java b/src/main/java/com/snp/batch/jobs/aistarget/batch/entity/AisTargetEntity.java index 57bb322..0e4e1a9 100644 --- a/src/main/java/com/snp/batch/jobs/aistarget/batch/entity/AisTargetEntity.java +++ b/src/main/java/com/snp/batch/jobs/aistarget/batch/entity/AisTargetEntity.java @@ -82,4 +82,21 @@ public class AisTargetEntity extends BaseEntity { // ========== 타임스탬프 ========== private OffsetDateTime receivedDate; private OffsetDateTime collectedAt; // 배치 수집 시점 + + // ========== ClassType 분류 정보 ========== + /** + * 선박 클래스 타입 + * - "A": Core20에 등록된 선박 (Class A AIS 장착 의무 선박) + * - "B": Core20 미등록 선박 (Class B AIS 또는 미등록) + * - null: 미분류 (캐시 저장 전) + */ + private String classType; + + /** + * Core20 테이블의 MMSI 값 + * - Class A인 경우에만 값이 있을 수 있음 + * - Class A이지만 Core20에 MMSI가 없는 경우 null + * - Class B인 경우 항상 null + */ + private String core20Mmsi; } diff --git a/src/main/java/com/snp/batch/jobs/aistarget/batch/repository/AisTargetRepositoryImpl.java b/src/main/java/com/snp/batch/jobs/aistarget/batch/repository/AisTargetRepositoryImpl.java index 98204b3..d70e02b 100644 --- a/src/main/java/com/snp/batch/jobs/aistarget/batch/repository/AisTargetRepositoryImpl.java +++ b/src/main/java/com/snp/batch/jobs/aistarget/batch/repository/AisTargetRepositoryImpl.java @@ -46,7 +46,7 @@ public class AisTargetRepositoryImpl implements AisTargetRepository { received_date, collected_at, created_at, updated_at ) VALUES ( ?, ?, ?, ?, ?, ?, ?, - ?, ?, public.ST_SetSRID(public.ST_MakePoint(?, ?), 4326), + ?, ?, ST_SetSRID(ST_MakePoint(?, ?), 4326), ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, @@ -203,9 +203,9 @@ public class AisTargetRepositoryImpl implements AisTargetRepository { SELECT DISTINCT ON (mmsi) * FROM %s WHERE message_timestamp BETWEEN ? AND ? - AND public.ST_DWithin( + AND ST_DWithin( geom::geography, - public.ST_SetSRID(public.ST_MakePoint(?, ?), 4326)::geography, + ST_SetSRID(ST_MakePoint(?, ?), 4326)::geography, ? ) ORDER BY mmsi, message_timestamp DESC diff --git a/src/main/java/com/snp/batch/jobs/aistarget/batch/writer/AisTargetDataWriter.java b/src/main/java/com/snp/batch/jobs/aistarget/batch/writer/AisTargetDataWriter.java index 05e302f..76f81b0 100644 --- a/src/main/java/com/snp/batch/jobs/aistarget/batch/writer/AisTargetDataWriter.java +++ b/src/main/java/com/snp/batch/jobs/aistarget/batch/writer/AisTargetDataWriter.java @@ -4,6 +4,7 @@ import com.snp.batch.common.batch.writer.BaseWriter; import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity; import com.snp.batch.jobs.aistarget.batch.repository.AisTargetRepository; import com.snp.batch.jobs.aistarget.cache.AisTargetCacheManager; +import com.snp.batch.jobs.aistarget.classifier.AisClassTypeClassifier; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; @@ -13,8 +14,9 @@ import java.util.List; * AIS Target 데이터 Writer * * 동작: - * - UPSERT 방식으로 DB 저장 (PK: mmsi + message_timestamp) - * - 동시에 캐시에도 최신 위치 정보 업데이트 + * 1. UPSERT 방식으로 DB 저장 (PK: mmsi + message_timestamp) + * 2. ClassType 분류 (Core20 캐시 기반 A/B 분류) + * 3. 캐시에 최신 위치 정보 업데이트 (classType, core20Mmsi 포함) */ @Slf4j @Component @@ -22,23 +24,30 @@ public class AisTargetDataWriter extends BaseWriter { private final AisTargetRepository aisTargetRepository; private final AisTargetCacheManager cacheManager; + private final AisClassTypeClassifier classTypeClassifier; public AisTargetDataWriter( AisTargetRepository aisTargetRepository, - AisTargetCacheManager cacheManager) { + AisTargetCacheManager cacheManager, + AisClassTypeClassifier classTypeClassifier) { super("AisTarget"); this.aisTargetRepository = aisTargetRepository; this.cacheManager = cacheManager; + this.classTypeClassifier = classTypeClassifier; } @Override protected void writeItems(List items) throws Exception { log.debug("AIS Target 데이터 저장 시작: {} 건", items.size()); - // 1. DB 저장 + // 1. DB 저장 (classType 없이 원본 데이터만 저장) aisTargetRepository.batchUpsert(items); - // 2. 캐시 업데이트 (최신 위치 정보) + // 2. ClassType 분류 (캐시 저장 전에 분류) + // - Core20 캐시의 IMO와 매칭하여 classType(A/B), core20Mmsi 설정 + classTypeClassifier.classifyAll(items); + + // 3. 캐시 업데이트 (classType, core20Mmsi 포함) cacheManager.putAll(items); log.debug("AIS Target 데이터 저장 완료: {} 건 (캐시 크기: {})", diff --git a/src/main/java/com/snp/batch/jobs/aistarget/cache/AisTargetFilterUtil.java b/src/main/java/com/snp/batch/jobs/aistarget/cache/AisTargetFilterUtil.java index ecffa32..c884954 100644 --- a/src/main/java/com/snp/batch/jobs/aistarget/cache/AisTargetFilterUtil.java +++ b/src/main/java/com/snp/batch/jobs/aistarget/cache/AisTargetFilterUtil.java @@ -2,10 +2,12 @@ package com.snp.batch.jobs.aistarget.cache; import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity; import com.snp.batch.jobs.aistarget.web.dto.AisTargetFilterRequest; +import com.snp.batch.jobs.aistarget.web.dto.AisTargetSearchRequest; import com.snp.batch.jobs.aistarget.web.dto.NumericCondition; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; +import java.util.Collections; import java.util.List; import java.util.stream.Collectors; @@ -16,6 +18,7 @@ import java.util.stream.Collectors; * - SOG, COG, Heading: 숫자 범위 조건 * - Destination: 문자열 부분 일치 * - Status: 다중 선택 일치 + * - ClassType: 선박 클래스 타입 (A/B) */ @Slf4j @Component @@ -45,6 +48,7 @@ public class AisTargetFilterUtil { .filter(entity -> matchesHeading(entity, request)) .filter(entity -> matchesDestination(entity, request)) .filter(entity -> matchesStatus(entity, request)) + .filter(entity -> matchesClassType(entity, request)) .collect(Collectors.toList()); long elapsed = System.currentTimeMillis() - startTime; @@ -54,6 +58,54 @@ public class AisTargetFilterUtil { return result; } + /** + * AisTargetSearchRequest 기반 ClassType 필터링 + * + * @param entities 원본 엔티티 목록 + * @param request 검색 조건 + * @return 필터링된 엔티티 목록 + */ + public List filterByClassType(List entities, AisTargetSearchRequest request) { + if (entities == null || entities.isEmpty()) { + return Collections.emptyList(); + } + + if (!request.hasClassTypeFilter()) { + return entities; + } + + long startTime = System.currentTimeMillis(); + + List result = entities.parallelStream() + .filter(entity -> matchesClassType(entity, request.getClassType())) + .collect(Collectors.toList()); + + long elapsed = System.currentTimeMillis() - startTime; + log.debug("ClassType 필터링 완료 - 입력: {}, 결과: {}, 필터: {}, 소요: {}ms", + entities.size(), result.size(), request.getClassType(), elapsed); + + return result; + } + + /** + * 문자열 classType으로 직접 필터링 + */ + private boolean matchesClassType(AisTargetEntity entity, String classTypeFilter) { + if (classTypeFilter == null) { + return true; + } + + String entityClassType = entity.getClassType(); + + // classType이 미분류(null)인 데이터 처리 + if (entityClassType == null) { + // B 필터인 경우 미분류 데이터도 포함 (보수적 접근) + return "B".equalsIgnoreCase(classTypeFilter); + } + + return classTypeFilter.equalsIgnoreCase(entityClassType); + } + /** * SOG (속도) 조건 매칭 */ @@ -150,4 +202,28 @@ public class AisTargetFilterUtil { return request.getStatusList().stream() .anyMatch(status -> entityStatus.equalsIgnoreCase(status.trim())); } + + /** + * ClassType (선박 클래스 타입) 조건 매칭 + * + * - A: Core20에 등록된 선박 + * - B: Core20 미등록 선박 + * - 필터 미지정: 전체 통과 + * - classType이 null인 데이터: B 필터에만 포함 (보수적 접근) + */ + private boolean matchesClassType(AisTargetEntity entity, AisTargetFilterRequest request) { + if (!request.hasClassTypeFilter()) { + return true; + } + + String entityClassType = entity.getClassType(); + + // classType이 미분류(null)인 데이터 처리 + if (entityClassType == null) { + // B 필터인 경우 미분류 데이터도 포함 (보수적 접근) + return "B".equalsIgnoreCase(request.getClassType()); + } + + return request.getClassType().equalsIgnoreCase(entityClassType); + } } diff --git a/src/main/java/com/snp/batch/jobs/aistarget/classifier/AisClassTypeClassifier.java b/src/main/java/com/snp/batch/jobs/aistarget/classifier/AisClassTypeClassifier.java new file mode 100644 index 0000000..e8672ce --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/aistarget/classifier/AisClassTypeClassifier.java @@ -0,0 +1,160 @@ +package com.snp.batch.jobs.aistarget.classifier; + +import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.util.Optional; +import java.util.regex.Pattern; + +/** + * AIS Target ClassType 분류기 + * + * 분류 기준: + * - Core20 테이블에 IMO가 등록되어 있으면 Class A + * - 등록되어 있지 않으면 Class B (기본값) + * + * 분류 결과: + * - classType: "A" 또는 "B" + * - core20Mmsi: Core20에 등록된 MMSI (Class A일 때만, nullable) + * + * 특이 케이스: + * 1. IMO가 0이거나 null → Class B + * 2. IMO가 7자리가 아닌 의미없는 숫자 → Class B + * 3. IMO가 7자리이지만 Core20에 미등록 → Class B + * 4. IMO가 Core20에 있지만 MMSI가 null → Class A, core20Mmsi = null + * + * 향후 제거 가능하도록 독립적인 모듈로 구현 + */ +@Slf4j +@Component +public class AisClassTypeClassifier { + + /** + * 유효한 IMO 패턴 (7자리 숫자) + */ + private static final Pattern IMO_PATTERN = Pattern.compile("^\\d{7}$"); + + private final Core20CacheManager core20CacheManager; + + /** + * ClassType 분류 기능 활성화 여부 + */ + @Value("${app.batch.class-type.enabled:true}") + private boolean enabled; + + public AisClassTypeClassifier(Core20CacheManager core20CacheManager) { + this.core20CacheManager = core20CacheManager; + } + + /** + * 단일 Entity의 ClassType 분류 + * + * @param entity AIS Target Entity + */ + public void classify(AisTargetEntity entity) { + if (!enabled || entity == null) { + return; + } + + Long imo = entity.getImo(); + + // 1. IMO가 null이거나 0이면 Class B + if (imo == null || imo == 0) { + setClassB(entity); + return; + } + + // 2. IMO가 7자리 숫자인지 확인 + String imoStr = String.valueOf(imo); + if (!isValidImo(imoStr)) { + setClassB(entity); + return; + } + + // 3. Core20 캐시에서 IMO 존재 여부 확인 + if (core20CacheManager.containsImo(imoStr)) { + // Class A - Core20에 등록된 선박 + entity.setClassType("A"); + + // Core20의 MMSI 조회 (nullable - Core20에 MMSI가 없을 수도 있음) + Optional core20Mmsi = core20CacheManager.getMmsiByImo(imoStr); + entity.setCore20Mmsi(core20Mmsi.orElse(null)); + + return; + } + + // 4. Core20에 없음 - Class B + setClassB(entity); + } + + /** + * 여러 Entity 일괄 분류 + * + * @param entities AIS Target Entity 목록 + */ + public void classifyAll(List entities) { + if (!enabled || entities == null || entities.isEmpty()) { + return; + } + + int classACount = 0; + int classBCount = 0; + int classAWithMmsi = 0; + int classAWithoutMmsi = 0; + + for (AisTargetEntity entity : entities) { + classify(entity); + + if ("A".equals(entity.getClassType())) { + classACount++; + if (entity.getCore20Mmsi() != null) { + classAWithMmsi++; + } else { + classAWithoutMmsi++; + } + } else { + classBCount++; + } + } + + if (log.isDebugEnabled()) { + log.debug("ClassType 분류 완료 - 총: {}, Class A: {} (MMSI있음: {}, MMSI없음: {}), Class B: {}", + entities.size(), classACount, classAWithMmsi, classAWithoutMmsi, classBCount); + } + } + + /** + * Class B로 설정 (기본값) + */ + private void setClassB(AisTargetEntity entity) { + entity.setClassType("B"); + entity.setCore20Mmsi(null); + } + + /** + * 유효한 IMO 번호인지 확인 (7자리 숫자) + * + * @param imo IMO 문자열 + * @return 유효 여부 + */ + private boolean isValidImo(String imo) { + return imo != null && IMO_PATTERN.matcher(imo).matches(); + } + + /** + * 기능 활성화 여부 + */ + public boolean isEnabled() { + return enabled; + } + + /** + * Core20 캐시 상태 확인 + */ + public boolean isCacheReady() { + return core20CacheManager.isLoaded(); + } +} diff --git a/src/main/java/com/snp/batch/jobs/aistarget/classifier/Core20CacheManager.java b/src/main/java/com/snp/batch/jobs/aistarget/classifier/Core20CacheManager.java new file mode 100644 index 0000000..0a63ae8 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/aistarget/classifier/Core20CacheManager.java @@ -0,0 +1,219 @@ +package com.snp.batch.jobs.aistarget.classifier; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; +import java.util.LinkedHashMap; +import java.util.Map; +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Core20 테이블의 IMO → MMSI 매핑 캐시 매니저 + * + * 동작: + * - 애플리케이션 시작 시 또는 첫 조회 시 자동 로딩 + * - 매일 지정된 시간(기본 04:00)에 전체 갱신 + * - TTL 없음 (명시적 갱신만) + * + * 데이터 구조: + * - Key: IMO/LRNO (7자리 문자열, NOT NULL) + * - Value: MMSI (문자열, NULLABLE - 빈 문자열로 저장) + * + * 특이사항: + * - Core20에 IMO는 있지만 MMSI가 null인 경우도 존재 + * - 이 경우 containsImo()는 true, getMmsiByImo()는 Optional.empty() + * - ConcurrentHashMap은 null을 허용하지 않으므로 빈 문자열("")을 sentinel 값으로 사용 + */ +@Slf4j +@Component +public class Core20CacheManager { + + private final JdbcTemplate jdbcTemplate; + private final Core20Properties properties; + + /** + * MMSI가 없는 경우를 나타내는 sentinel 값 + * ConcurrentHashMap은 null을 허용하지 않으므로 빈 문자열 사용 + */ + private static final String NO_MMSI = ""; + + /** + * IMO → MMSI 매핑 캐시 + * - Key: IMO (NOT NULL) + * - Value: MMSI (빈 문자열이면 MMSI 없음) + */ + private volatile Map imoToMmsiMap = new ConcurrentHashMap<>(); + + /** + * 마지막 갱신 시간 + */ + private volatile LocalDateTime lastRefreshTime; + + /** + * Core20 캐시 갱신 시간 (기본: 04시) + */ + @Value("${app.batch.class-type.refresh-hour:4}") + private int refreshHour; + + public Core20CacheManager(JdbcTemplate jdbcTemplate, Core20Properties properties) { + this.jdbcTemplate = jdbcTemplate; + this.properties = properties; + } + + /** + * IMO로 MMSI 조회 + * + * @param imo IMO 번호 (문자열) + * @return MMSI 값 (없거나 null/빈 문자열이면 Optional.empty) + */ + public Optional getMmsiByImo(String imo) { + ensureCacheLoaded(); + + if (imo == null || !imoToMmsiMap.containsKey(imo)) { + return Optional.empty(); + } + + String mmsi = imoToMmsiMap.get(imo); + + // MMSI가 빈 문자열(NO_MMSI)인 경우 + if (mmsi == null || mmsi.isEmpty()) { + return Optional.empty(); + } + + return Optional.of(mmsi); + } + + /** + * IMO 존재 여부만 확인 (MMSI 유무와 무관) + * - Core20에 등록된 선박인지 판단하는 용도 + * - MMSI가 null이어도 IMO가 있으면 true + * + * @param imo IMO 번호 + * @return Core20에 등록 여부 + */ + public boolean containsImo(String imo) { + ensureCacheLoaded(); + return imo != null && imoToMmsiMap.containsKey(imo); + } + + /** + * 캐시 전체 갱신 (DB에서 다시 로딩) + */ + public synchronized void refresh() { + log.info("Core20 캐시 갱신 시작 - 테이블: {}", properties.getFullTableName()); + + try { + String sql = properties.buildSelectSql(); + log.debug("Core20 조회 SQL: {}", sql); + + Map newMap = new ConcurrentHashMap<>(); + + jdbcTemplate.query(sql, rs -> { + String imo = rs.getString(1); + String mmsi = rs.getString(2); // nullable + + if (imo != null && !imo.isBlank()) { + // IMO는 trim하여 저장, MMSI는 빈 문자열로 대체 (ConcurrentHashMap은 null 불가) + String trimmedImo = imo.trim(); + String trimmedMmsi = (mmsi != null && !mmsi.isBlank()) ? mmsi.trim() : NO_MMSI; + newMap.put(trimmedImo, trimmedMmsi); + } + }); + + this.imoToMmsiMap = newMap; + this.lastRefreshTime = LocalDateTime.now(); + + // 통계 로깅 + long withMmsi = newMap.values().stream() + .filter(v -> !v.isEmpty()) + .count(); + + log.info("Core20 캐시 갱신 완료 - 총 {} 건 (MMSI 있음: {} 건, MMSI 없음: {} 건)", + newMap.size(), withMmsi, newMap.size() - withMmsi); + + } catch (Exception e) { + log.error("Core20 캐시 갱신 실패: {}", e.getMessage(), e); + // 기존 캐시 유지 (실패해도 서비스 중단 방지) + } + } + + /** + * 캐시가 비어있으면 자동 로딩 + */ + private void ensureCacheLoaded() { + if (imoToMmsiMap.isEmpty() && lastRefreshTime == null) { + log.warn("Core20 캐시 비어있음 - 자동 로딩 실행"); + refresh(); + } + } + + /** + * 지정된 시간대에 갱신이 필요한지 확인 + * - 기본: 04:00 ~ 04:01 사이 + * - 같은 날 이미 갱신했으면 스킵 + * + * @return 갱신 필요 여부 + */ + public boolean shouldRefresh() { + LocalDateTime now = LocalDateTime.now(); + int currentHour = now.getHour(); + int currentMinute = now.getMinute(); + + // 지정된 시간(예: 04:00~04:01) 체크 + if (currentHour != refreshHour || currentMinute > 0) { + return false; + } + + // 오늘 해당 시간에 이미 갱신했으면 스킵 + if (lastRefreshTime != null && + lastRefreshTime.toLocalDate().equals(now.toLocalDate()) && + lastRefreshTime.getHour() == refreshHour) { + return false; + } + + return true; + } + + /** + * 현재 캐시 크기 + */ + public int size() { + return imoToMmsiMap.size(); + } + + /** + * 마지막 갱신 시간 + */ + public LocalDateTime getLastRefreshTime() { + return lastRefreshTime; + } + + /** + * 캐시가 로드되었는지 확인 + */ + public boolean isLoaded() { + return lastRefreshTime != null && !imoToMmsiMap.isEmpty(); + } + + /** + * 캐시 통계 조회 (모니터링/디버깅용) + */ + public Map getStats() { + Map stats = new LinkedHashMap<>(); + stats.put("totalCount", imoToMmsiMap.size()); + stats.put("withMmsiCount", imoToMmsiMap.values().stream() + .filter(v -> !v.isEmpty()).count()); + stats.put("withoutMmsiCount", imoToMmsiMap.values().stream() + .filter(String::isEmpty).count()); + stats.put("lastRefreshTime", lastRefreshTime); + stats.put("refreshHour", refreshHour); + stats.put("tableName", properties.getFullTableName()); + stats.put("imoColumn", properties.getImoColumn()); + stats.put("mmsiColumn", properties.getMmsiColumn()); + return stats; + } +} diff --git a/src/main/java/com/snp/batch/jobs/aistarget/classifier/Core20Properties.java b/src/main/java/com/snp/batch/jobs/aistarget/classifier/Core20Properties.java new file mode 100644 index 0000000..1e1eb3f --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/aistarget/classifier/Core20Properties.java @@ -0,0 +1,71 @@ +package com.snp.batch.jobs.aistarget.classifier; + +import jakarta.annotation.PostConstruct; +import lombok.Getter; +import lombok.Setter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.boot.context.properties.ConfigurationProperties; + +/** + * Core20 테이블 설정 프로퍼티 + * + * 환경별(dev/qa/prod)로 테이블명, 컬럼명이 다를 수 있으므로 + * 프로파일별 설정 파일에서 지정할 수 있도록 구성 + * + * 사용 예: + * - dev: snp_data.core20 (ihslrorimoshipno, maritimemobileserviceidentitymmsinumber) + * - prod: new_snp.core20 (lrno, mmsi) + */ +@Slf4j +@Getter +@Setter +@ConfigurationProperties(prefix = "app.batch.core20") +public class Core20Properties { + + /** + * 스키마명 (예: snp_data, new_snp) + */ + private String schema = "snp_data"; + + /** + * 테이블명 (예: core20) + */ + private String table = "core20"; + + /** + * IMO/LRNO 컬럼명 (PK, NOT NULL) + */ + private String imoColumn = "ihslrorimoshipno"; + + /** + * MMSI 컬럼명 (NULLABLE) + */ + private String mmsiColumn = "maritimemobileserviceidentitymmsinumber"; + + /** + * 전체 테이블명 반환 (schema.table) + */ + public String getFullTableName() { + if (schema != null && !schema.isBlank()) { + return schema + "." + table; + } + return table; + } + + /** + * SELECT 쿼리 생성 + * IMO가 NOT NULL인 레코드만 조회 + */ + public String buildSelectSql() { + return String.format( + "SELECT %s, %s FROM %s WHERE %s IS NOT NULL", + imoColumn, mmsiColumn, getFullTableName(), imoColumn + ); + } + + @PostConstruct + public void logConfig() { + log.info("Core20 설정 로드 - 테이블: {}, IMO컬럼: {}, MMSI컬럼: {}", + getFullTableName(), imoColumn, mmsiColumn); + } +} diff --git a/src/main/java/com/snp/batch/jobs/aistarget/web/controller/AisTargetController.java b/src/main/java/com/snp/batch/jobs/aistarget/web/controller/AisTargetController.java index fea1864..609fc70 100644 --- a/src/main/java/com/snp/batch/jobs/aistarget/web/controller/AisTargetController.java +++ b/src/main/java/com/snp/batch/jobs/aistarget/web/controller/AisTargetController.java @@ -74,11 +74,23 @@ public class AisTargetController { @Operation( summary = "시간/공간 범위로 선박 검색", description = """ - 시간 범위 (필수) + 공간 범위 (옵션)로 선박을 검색합니다. + 시간 범위 (필수) + 공간 범위 (옵션) + 선박 클래스 타입 (옵션)으로 선박을 검색합니다. - minutes: 조회 범위 (분, 필수) - centerLon, centerLat: 중심 좌표 (옵션) - radiusMeters: 반경 (미터, 옵션) + - classType: 선박 클래스 타입 필터 (A/B, 옵션) + + --- + ## ClassType 설명 + - **A**: Core20에 등록된 선박 (Class A AIS 장착 의무 선박) + - **B**: Core20 미등록 선박 (Class B AIS 또는 미등록) + - 미지정: 전체 조회 + + --- + ## 응답 필드 설명 + - **classType**: 선박 클래스 타입 (A/B) + - **core20Mmsi**: Core20 테이블의 MMSI 값 (Class A인 경우에만 존재할 수 있음) 공간 범위가 지정되지 않으면 전체 선박의 최신 위치를 반환합니다. """ @@ -92,16 +104,19 @@ public class AisTargetController { @Parameter(description = "중심 위도", example = "35.0") @RequestParam(required = false) Double centerLat, @Parameter(description = "반경 (미터)", example = "50000") - @RequestParam(required = false) Double radiusMeters) { + @RequestParam(required = false) Double radiusMeters, + @Parameter(description = "선박 클래스 타입 필터 (A: Core20 등록, B: 미등록)", example = "A") + @RequestParam(required = false) String classType) { - log.info("선박 검색 요청 - minutes: {}, center: ({}, {}), radius: {}", - minutes, centerLon, centerLat, radiusMeters); + log.info("선박 검색 요청 - minutes: {}, center: ({}, {}), radius: {}, classType: {}", + minutes, centerLon, centerLat, radiusMeters, classType); AisTargetSearchRequest request = AisTargetSearchRequest.builder() .minutes(minutes) .centerLon(centerLon) .centerLat(centerLat) .radiusMeters(radiusMeters) + .classType(classType) .build(); List result = aisTargetService.search(request); @@ -113,13 +128,33 @@ public class AisTargetController { @Operation( summary = "시간/공간 범위로 선박 검색 (POST)", - description = "POST 방식으로 검색 조건을 전달합니다" + description = """ + POST 방식으로 검색 조건을 전달합니다. + + --- + ## 요청 예시 + ```json + { + "minutes": 5, + "centerLon": 129.0, + "centerLat": 35.0, + "radiusMeters": 50000, + "classType": "A" + } + ``` + + --- + ## ClassType 설명 + - **A**: Core20에 등록된 선박 (Class A AIS 장착 의무 선박) + - **B**: Core20 미등록 선박 (Class B AIS 또는 미등록) + - 미지정: 전체 조회 + """ ) @PostMapping("/search") public ResponseEntity>> searchPost( @Valid @RequestBody AisTargetSearchRequest request) { - log.info("선박 검색 요청 (POST) - minutes: {}, hasArea: {}", - request.getMinutes(), request.hasAreaFilter()); + log.info("선박 검색 요청 (POST) - minutes: {}, hasArea: {}, classType: {}", + request.getMinutes(), request.hasAreaFilter(), request.getClassType()); List result = aisTargetService.search(request); return ResponseEntity.ok(ApiResponse.success( diff --git a/src/main/java/com/snp/batch/jobs/aistarget/web/dto/AisTargetFilterRequest.java b/src/main/java/com/snp/batch/jobs/aistarget/web/dto/AisTargetFilterRequest.java index 34e172a..66637d8 100644 --- a/src/main/java/com/snp/batch/jobs/aistarget/web/dto/AisTargetFilterRequest.java +++ b/src/main/java/com/snp/batch/jobs/aistarget/web/dto/AisTargetFilterRequest.java @@ -121,6 +121,16 @@ public class AisTargetFilterRequest { example = "[\"Under way using engine\", \"Anchored\", \"Moored\"]") private List statusList; + // ==================== 선박 클래스 타입 (ClassType) 필터 ==================== + @Schema(description = """ + 선박 클래스 타입 필터 + - A: Core20에 등록된 선박 (Class A AIS 장착 의무 선박) + - B: Core20 미등록 선박 (Class B AIS 또는 미등록) + - 미지정: 전체 조회 + """, + example = "A", allowableValues = {"A", "B"}) + private String classType; + // ==================== 필터 존재 여부 확인 ==================== public boolean hasSogFilter() { @@ -143,8 +153,13 @@ public class AisTargetFilterRequest { return statusList != null && !statusList.isEmpty(); } + public boolean hasClassTypeFilter() { + return classType != null && + (classType.equalsIgnoreCase("A") || classType.equalsIgnoreCase("B")); + } + public boolean hasAnyFilter() { return hasSogFilter() || hasCogFilter() || hasHeadingFilter() - || hasDestinationFilter() || hasStatusFilter(); + || hasDestinationFilter() || hasStatusFilter() || hasClassTypeFilter(); } } diff --git a/src/main/java/com/snp/batch/jobs/aistarget/web/dto/AisTargetResponseDto.java b/src/main/java/com/snp/batch/jobs/aistarget/web/dto/AisTargetResponseDto.java index b7b0c74..aa8f3f4 100644 --- a/src/main/java/com/snp/batch/jobs/aistarget/web/dto/AisTargetResponseDto.java +++ b/src/main/java/com/snp/batch/jobs/aistarget/web/dto/AisTargetResponseDto.java @@ -2,6 +2,7 @@ package com.snp.batch.jobs.aistarget.web.dto; import com.fasterxml.jackson.annotation.JsonInclude; import com.snp.batch.jobs.aistarget.batch.entity.AisTargetEntity; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Data; @@ -17,6 +18,7 @@ import java.time.OffsetDateTime; @NoArgsConstructor @AllArgsConstructor @JsonInclude(JsonInclude.Include.NON_NULL) +@Schema(description = "AIS Target 응답") public class AisTargetResponseDto { // 선박 식별 정보 @@ -51,8 +53,26 @@ public class AisTargetResponseDto { private OffsetDateTime receivedDate; // 데이터 소스 (캐시/DB) + @Schema(description = "데이터 소스", example = "cache", allowableValues = {"cache", "db"}) private String source; + // ClassType 분류 정보 + @Schema(description = """ + 선박 클래스 타입 + - A: Core20에 등록된 선박 (Class A AIS 장착 의무 선박) + - B: Core20 미등록 선박 (Class B AIS 또는 미등록) + """, + example = "A", allowableValues = {"A", "B"}) + private String classType; + + @Schema(description = """ + Core20 테이블의 MMSI 값 + - Class A인 경우에만 값이 있을 수 있음 + - null: Class B 또는 Core20에 MMSI가 미등록된 경우 + """, + example = "440123456", nullable = true) + private String core20Mmsi; + /** * Entity -> DTO 변환 */ @@ -82,6 +102,8 @@ public class AisTargetResponseDto { .messageTimestamp(entity.getMessageTimestamp()) .receivedDate(entity.getReceivedDate()) .source(source) + .classType(entity.getClassType()) + .core20Mmsi(entity.getCore20Mmsi()) .build(); } } diff --git a/src/main/java/com/snp/batch/jobs/aistarget/web/dto/AisTargetSearchRequest.java b/src/main/java/com/snp/batch/jobs/aistarget/web/dto/AisTargetSearchRequest.java index 9e7a7a8..33d334f 100644 --- a/src/main/java/com/snp/batch/jobs/aistarget/web/dto/AisTargetSearchRequest.java +++ b/src/main/java/com/snp/batch/jobs/aistarget/web/dto/AisTargetSearchRequest.java @@ -39,10 +39,28 @@ public class AisTargetSearchRequest { @Schema(description = "반경 (미터, 옵션)", example = "50000") private Double radiusMeters; + @Schema(description = """ + 선박 클래스 타입 필터 + - A: Core20에 등록된 선박 (Class A AIS 장착 의무 선박) + - B: Core20 미등록 선박 (Class B AIS 또는 미등록) + - 미지정: 전체 조회 + """, + example = "A", allowableValues = {"A", "B"}) + private String classType; + /** * 공간 필터 사용 여부 */ public boolean hasAreaFilter() { return centerLon != null && centerLat != null && radiusMeters != null; } + + /** + * ClassType 필터 사용 여부 + * - "A" 또는 "B"인 경우에만 true + */ + public boolean hasClassTypeFilter() { + return classType != null && + (classType.equalsIgnoreCase("A") || classType.equalsIgnoreCase("B")); + } } diff --git a/src/main/java/com/snp/batch/jobs/aistarget/web/service/AisTargetService.java b/src/main/java/com/snp/batch/jobs/aistarget/web/service/AisTargetService.java index 6b1b79f..a7e7fe2 100644 --- a/src/main/java/com/snp/batch/jobs/aistarget/web/service/AisTargetService.java +++ b/src/main/java/com/snp/batch/jobs/aistarget/web/service/AisTargetService.java @@ -122,11 +122,12 @@ public class AisTargetService { * 전략: * 1. 캐시에서 시간 범위 내 데이터 조회 * 2. 공간 필터 있으면 JTS로 필터링 - * 3. 캐시 데이터가 없으면 DB Fallback + * 3. ClassType 필터 있으면 적용 + * 4. 캐시 데이터가 없으면 DB Fallback */ public List search(AisTargetSearchRequest request) { - log.debug("선박 검색 - minutes: {}, hasArea: {}", - request.getMinutes(), request.hasAreaFilter()); + log.debug("선박 검색 - minutes: {}, hasArea: {}, classType: {}", + request.getMinutes(), request.hasAreaFilter(), request.getClassType()); long startTime = System.currentTimeMillis(); @@ -154,6 +155,11 @@ public class AisTargetService { ); } + // 3. ClassType 필터 적용 + if (request.hasClassTypeFilter()) { + entities = filterUtil.filterByClassType(entities, request); + } + long elapsed = System.currentTimeMillis() - startTime; log.info("선박 검색 완료 - 소스: {}, 결과: {} 건, 소요: {}ms", source, entities.size(), elapsed); diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 33e7908..9bbaeb4 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -103,6 +103,17 @@ app: ttl-minutes: 120 # 캐시 TTL (분) - 2시간 max-size: 300000 # 최대 캐시 크기 - 30만 건 + # ClassType 분류 설정 + class-type: + refresh-hour: 4 # Core20 캐시 갱신 시간 (기본: 04시) + + # Core20 캐시 테이블 설정 (환경별로 테이블/컬럼명이 다를 수 있음) + core20: + schema: snp_data # 스키마명 + table: core20 # 테이블명 + imo-column: ihslrorimoshipno # IMO/LRNO 컬럼명 (PK, NOT NULL) + mmsi-column: maritimemobileserviceidentitymmsinumber # MMSI 컬럼명 (NULLABLE) + # 파티션 관리 설정 partition: # 일별 파티션 테이블 목록 (네이밍: {table}_YYMMDD) diff --git a/src/main/resources/templates/schedules.html b/src/main/resources/templates/schedules.html index 3d62f20..17965c4 100644 --- a/src/main/resources/templates/schedules.html +++ b/src/main/resources/templates/schedules.html @@ -410,8 +410,8 @@ if (!confirmUpdate) { return; } - method = 'PUT'; - url = contextPath + `api/batch/schedules/${jobName}`; + method = 'POST'; + url = contextPath + `api/batch/schedules/${jobName}/update`; } const response = await fetch(url, { @@ -455,7 +455,7 @@ try { const response = await fetch(contextPath + `api/batch/schedules/${jobName}/toggle`, { - method: 'PATCH', + method: 'POST', headers: { 'Content-Type': 'application/json' }, @@ -482,8 +482,8 @@ } try { - const response = await fetch(contextPath + `api/batch/schedules/${jobName}`, { - method: 'DELETE' + const response = await fetch(contextPath + `api/batch/schedules/${jobName}/delete`, { + method: 'POST' }); const result = await response.json();