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 1a83e4c..27aa694 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 @@ -84,6 +84,14 @@ public class AisTargetEntity extends BaseEntity { private OffsetDateTime receivedDate; private OffsetDateTime collectedAt; // 배치 수집 시점 + // ========== 선종 분류 정보 ========== + /** + * MDA 범례코드 (signalKindCode) + * - vesselType + extraInfo 기반으로 치환 + * - 예: "000020"(어선), "000023"(카고), "000027"(일반/기타) + */ + private String signalKindCode; + // ========== ClassType 분류 정보 ========== /** * 선박 클래스 타입 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 2c6a0da..fcdc654 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.cache.AisTargetCacheManager; import com.snp.batch.jobs.aistarget.classifier.AisClassTypeClassifier; +import com.snp.batch.jobs.aistarget.classifier.SignalKindCode; import com.snp.batch.jobs.aistarget.kafka.AisTargetKafkaProducer; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; @@ -15,8 +16,9 @@ import java.util.List; * * 동작: * 1. ClassType 분류 (Core20 캐시 기반 A/B 분류) - * 2. 캐시에 최신 위치 정보 업데이트 (classType, core20Mmsi 포함) - * 3. Kafka 토픽으로 AIS Target 정보 전송 (서브청크 분할) + * 2. SignalKindCode 치환 (vesselType + extraInfo → MDA 범례코드) + * 3. 캐시에 최신 위치 정보 업데이트 (classType, core20Mmsi, signalKindCode 포함) + * 4. Kafka 토픽으로 AIS Target 정보 전송 (서브청크 분할) * * 참고: * - DB 저장은 별도 Job(aisTargetDbSyncJob)에서 15분 주기로 수행 @@ -48,13 +50,19 @@ public class AisTargetDataWriter extends BaseWriter { // - Core20 캐시의 IMO와 매칭하여 classType(A/B), core20Mmsi 설정 classTypeClassifier.classifyAll(items); - // 2. 캐시 업데이트 (classType, core20Mmsi 포함) + // 2. SignalKindCode 치환 (vesselType + extraInfo → MDA 범례코드) + items.forEach(item -> { + SignalKindCode kindCode = SignalKindCode.resolve(item.getVesselType(), item.getExtraInfo()); + item.setSignalKindCode(kindCode.getCode()); + }); + + // 3. 캐시 업데이트 (classType, core20Mmsi, signalKindCode 포함) cacheManager.putAll(items); log.debug("AIS Target 캐시 업데이트 완료: {} 건 (캐시 크기: {})", items.size(), cacheManager.size()); - // 3. Kafka 전송 (설정 enabled=true 인 경우) + // 4. Kafka 전송 (설정 enabled=true 인 경우) if (!kafkaProducer.isEnabled()) { log.debug("AIS Kafka 전송 비활성화 - topic 전송 스킵"); return; diff --git a/src/main/java/com/snp/batch/jobs/aistarget/classifier/SignalKindCode.java b/src/main/java/com/snp/batch/jobs/aistarget/classifier/SignalKindCode.java new file mode 100644 index 0000000..de6b375 --- /dev/null +++ b/src/main/java/com/snp/batch/jobs/aistarget/classifier/SignalKindCode.java @@ -0,0 +1,118 @@ +package com.snp.batch.jobs.aistarget.classifier; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +/** + * MDA 선종 범례코드 + * + * GlobalAIS 원본 데이터의 vesselType + extraInfo를 기반으로 + * MDA 범례코드(signalKindCode)로 치환한다. + * + * @see 치환 규칙표 + */ +@Getter +@RequiredArgsConstructor +public enum SignalKindCode { + + FISHING("000020", "어선"), + KCGV("000021", "함정"), + FERRY("000022", "여객선"), + CARGO("000023", "카고"), + TANKER("000024", "탱커"), + GOV("000025", "관공선"), + DEFAULT("000027", "일반/기타선박"), + BUOY("000028", "부이/항로표지"); + + private final String code; + private final String koreanName; + + /** + * GlobalAIS vesselType + extraInfo → MDA 범례코드 치환 + * + * 치환 우선순위: + * 1. vesselType 단독 매칭 (Cargo, Tanker, Passenger, AtoN 등) + * 2. vesselType + extraInfo 조합 매칭 (Vessel + Fishing 등) + * 3. fallback → DEFAULT (000027) + */ + public static SignalKindCode resolve(String vesselType, String extraInfo) { + String vt = normalizeOrEmpty(vesselType); + String ei = normalizeOrEmpty(extraInfo); + + // 1. vesselType 단독 매칭 (extraInfo 무관) + switch (vt) { + case "cargo": + return CARGO; + case "tanker": + return TANKER; + case "passenger": + return FERRY; + case "aton": + return BUOY; + case "law enforcement": + return GOV; + case "search and rescue": + return KCGV; + case "local vessel": + return FISHING; + default: + break; + } + + // vesselType 그룹 매칭 (복합 선종명) + if (matchesAny(vt, "tug", "pilot boat", "tender", "anti pollution", "medical transport")) { + return GOV; + } + if (matchesAny(vt, "high speed craft", "wing in ground-effect")) { + return FERRY; + } + + // 2. "Vessel" + extraInfo 조합 + if ("vessel".equals(vt)) { + return resolveVesselExtraInfo(ei); + } + + // 3. "N/A" + extraInfo 조합 + if ("n/a".equals(vt)) { + if (ei.startsWith("hazardous cat")) { + return CARGO; + } + return DEFAULT; + } + + // 4. fallback + return DEFAULT; + } + + private static SignalKindCode resolveVesselExtraInfo(String extraInfo) { + if ("fishing".equals(extraInfo)) { + return FISHING; + } + if ("military operations".equals(extraInfo)) { + return GOV; + } + if (matchesAny(extraInfo, "towing", "towing (large)", "dredging/underwater ops", "diving operations")) { + return GOV; + } + if (matchesAny(extraInfo, "pleasure craft", "sailing", "n/a")) { + return FISHING; + } + if (extraInfo.startsWith("hazardous cat")) { + return CARGO; + } + return DEFAULT; + } + + private static boolean matchesAny(String value, String... candidates) { + for (String candidate : candidates) { + if (candidate.equals(value)) { + return true; + } + } + return false; + } + + private static String normalizeOrEmpty(String value) { + return (value == null || value.isBlank()) ? "" : value.strip().toLowerCase(); + } +} 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 aa8f3f4..38b6db0 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 @@ -56,6 +56,21 @@ public class AisTargetResponseDto { @Schema(description = "데이터 소스", example = "cache", allowableValues = {"cache", "db"}) private String source; + // 선종 분류 정보 + @Schema(description = """ + MDA 범례코드 (선종 분류) + - 000020: 어선 (FISHING) + - 000021: 함정 (KCGV) + - 000022: 여객선 (FERRY) + - 000023: 카고 (CARGO) + - 000024: 탱커 (TANKER) + - 000025: 관공선 (GOV) + - 000027: 일반/기타선박 (DEFAULT) + - 000028: 부이/항로표지 (BUOY) + """, + example = "000023") + private String signalKindCode; + // ClassType 분류 정보 @Schema(description = """ 선박 클래스 타입 @@ -102,6 +117,7 @@ public class AisTargetResponseDto { .messageTimestamp(entity.getMessageTimestamp()) .receivedDate(entity.getReceivedDate()) .source(source) + .signalKindCode(entity.getSignalKindCode()) .classType(entity.getClassType()) .core20Mmsi(entity.getCore20Mmsi()) .build();