feat: AIS Target signalKindCode(MDA 범례코드) 치환 로직 추가

- SignalKindCode enum: vesselType + extraInfo → MDA 범례코드 치환 규칙 구현
- AisTargetEntity에 signalKindCode 필드 추가
- AisTargetDataWriter에서 캐시 저장 전 치환 수행
- AisTargetResponseDto에 signalKindCode 필드 및 @Schema 추가

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
htlee 2026-02-16 09:51:15 +09:00
부모 cfc80bbb0d
커밋 b77df66b78
4개의 변경된 파일154개의 추가작업 그리고 4개의 파일을 삭제

파일 보기

@ -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 분류 정보 ==========
/**
* 선박 클래스 타입

파일 보기

@ -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<AisTargetEntity> {
// - 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;

파일 보기

@ -0,0 +1,118 @@
package com.snp.batch.jobs.aistarget.classifier;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
/**
* MDA 선종 범례코드
*
* GlobalAIS 원본 데이터의 vesselType + extraInfo를 기반으로
* MDA 범례코드(signalKindCode) 치환한다.
*
* @see <a href="GLOBALAIS - MDA 선종 범례 치환표.pdf">치환 규칙표</a>
*/
@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();
}
}

파일 보기

@ -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();