refactor: SignalKindCode 매핑 규칙 개선 — shipName BUOY 검출 + 치환 1회화 + 응답 경로 최적화

- SignalKindCode 매핑 변경: aton→DEFAULT, tug/tender→DEFAULT,
  Vessel+towing/dredging/diving→DEFAULT, Vessel+leisure→DEFAULT
- shipName 기반 BUOY 검출: '.' '_' 문자 2개 이상 → BUOY
- 캐시 저장 시 1회 치환, API 응답 시 DB/캐시 값 직접 사용
- 응답 경로 6곳 resolve() 재계산 제거

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
htlee 2026-03-02 13:42:36 +09:00
부모 007af70166
커밋 5e035f0362
10개의 변경된 파일204개의 추가작업 그리고 75개의 파일을 삭제

파일 보기

@ -107,7 +107,7 @@ public class ChnPrmShipCacheWarmer implements ApplicationRunner {
entities.forEach(entity -> { entities.forEach(entity -> {
if (entity.getSignalKindCode() == null) { if (entity.getSignalKindCode() == null) {
SignalKindCode kindCode = SignalKindCode.resolve( SignalKindCode kindCode = SignalKindCode.resolve(
entity.getVesselType(), entity.getExtraInfo()); entity.getVesselType(), entity.getExtraInfo(), entity.getName());
entity.setSignalKindCode(kindCode.getCode()); entity.setSignalKindCode(kindCode.getCode());
} }
}); });

파일 보기

@ -35,9 +35,10 @@ public class AisTargetCacheWriter implements ItemWriter<AisTargetEntity> {
List<? extends AisTargetEntity> items = chunk.getItems(); List<? extends AisTargetEntity> items = chunk.getItems();
log.debug("AIS Target 캐시 업데이트 시작: {} 건", items.size()); log.debug("AIS Target 캐시 업데이트 시작: {} 건", items.size());
// 1. SignalKindCode 치환 // 1. SignalKindCode 치환 (vesselType + extraInfo + shipName 기반, 캐시 저장 1회만)
items.forEach(item -> { items.forEach(item -> {
SignalKindCode kindCode = SignalKindCode.resolve(item.getVesselType(), item.getExtraInfo()); SignalKindCode kindCode = SignalKindCode.resolve(
item.getVesselType(), item.getExtraInfo(), item.getName());
item.setSignalKindCode(kindCode.getCode()); item.setSignalKindCode(kindCode.getCode());
}); });

파일 보기

@ -5,7 +5,6 @@ import gc.mda.signal_batch.domain.vessel.dto.TrackResponse;
import gc.mda.signal_batch.domain.vessel.dto.VesselStatsResponse; 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.VesselTracksRequest;
import gc.mda.signal_batch.domain.vessel.dto.CompactVesselTrack; import gc.mda.signal_batch.domain.vessel.dto.CompactVesselTrack;
import gc.mda.signal_batch.global.util.SignalKindCode;
import gc.mda.signal_batch.global.util.TrackSimplificationUtils; import gc.mda.signal_batch.global.util.TrackSimplificationUtils;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier; import org.springframework.beans.factory.annotation.Qualifier;
@ -604,9 +603,11 @@ public class GisService {
Map<String, String> vesselInfo = getVesselInfo(mmsi); Map<String, String> vesselInfo = getVesselInfo(mmsi);
String shipName = vesselInfo.get("ship_name"); String shipName = vesselInfo.get("ship_name");
String shipType = vesselInfo.get("ship_type"); String shipType = vesselInfo.get("ship_type");
String signalKindCode = vesselInfo.get("signal_kind_code");
String nationalCode = (mmsi != null && mmsi.length() >= 3) ? mmsi.substring(0, 3) : null; String nationalCode = (mmsi != null && mmsi.length() >= 3) ? mmsi.substring(0, 3) : null;
String shipKindCode = SignalKindCode.resolve(shipType, null).getCode(); String shipKindCode = (signalKindCode != null && !signalKindCode.isEmpty())
? signalKindCode : "000027";
return CompactVesselTrack.builder() return CompactVesselTrack.builder()
.vesselId(mmsi) .vesselId(mmsi)
@ -628,7 +629,7 @@ public class GisService {
JdbcTemplate jdbcTemplate = new JdbcTemplate(queryDataSource); JdbcTemplate jdbcTemplate = new JdbcTemplate(queryDataSource);
try { try {
String sql = """ String sql = """
SELECT ship_nm as ship_name, vessel_type as ship_type SELECT ship_nm as ship_name, vessel_type as ship_type, signal_kind_code
FROM signal.t_ais_position FROM signal.t_ais_position
WHERE mmsi = ? WHERE mmsi = ?
LIMIT 1 LIMIT 1

파일 보기

@ -3,7 +3,6 @@ package gc.mda.signal_batch.domain.vessel.service;
import gc.mda.signal_batch.domain.ship.service.ShipImageService; import gc.mda.signal_batch.domain.ship.service.ShipImageService;
import gc.mda.signal_batch.domain.ship.service.ShipImageService.ShipImageSummary; import gc.mda.signal_batch.domain.ship.service.ShipImageService.ShipImageSummary;
import gc.mda.signal_batch.domain.vessel.dto.RecentVesselPositionDto; import gc.mda.signal_batch.domain.vessel.dto.RecentVesselPositionDto;
import gc.mda.signal_batch.global.util.SignalKindCode;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
@ -124,6 +123,7 @@ public class VesselPositionService {
cog, cog,
name as ship_nm, name as ship_nm,
vessel_type as ship_ty, vessel_type as ship_ty,
signal_kind_code,
last_update last_update
FROM signal.t_ais_position FROM signal.t_ais_position
WHERE last_update >= NOW() - INTERVAL '%d minutes' WHERE last_update >= NOW() - INTERVAL '%d minutes'
@ -145,8 +145,9 @@ public class VesselPositionService {
String mmsi = rs.getString("mmsi"); String mmsi = rs.getString("mmsi");
String shipTy = rs.getString("ship_ty"); String shipTy = rs.getString("ship_ty");
// shipKindCode 계산 (vesselType 기반, extraInfo 없음) // shipKindCode: DB에 저장된 치환값 사용
String shipKindCode = SignalKindCode.resolve(shipTy, null).getCode(); String signalKindCode = rs.getString("signal_kind_code");
String shipKindCode = signalKindCode != null ? signalKindCode : "000027";
// nationalCode 계산 (MMSI 3자리 = MID) // nationalCode 계산 (MMSI 3자리 = MID)
String nationalCode = mmsi != null && mmsi.length() >= 3 String nationalCode = mmsi != null && mmsi.length() >= 3

파일 보기

@ -6,10 +6,11 @@ import lombok.RequiredArgsConstructor;
/** /**
* MDA 선종 범례코드 * MDA 선종 범례코드
* *
* S&P Global AIS API의 vesselType + extraInfo 기반으로 * S&P Global AIS API의 vesselType + extraInfo + shipName을 기반으로
* MDA 범례코드(signalKindCode) 치환한다. * MDA 범례코드(signalKindCode) 치환한다.
* *
* ShipKindCodeConverter를 대체하며, SNP-Batch-1의 치환 로직을 이식. * 치환은 캐시 저장 (AisTargetCacheWriter) 1회만 수행하며,
* API 응답 시에는 캐시 또는 DB의 signal_kind_code를 직접 사용한다.
*/ */
@Getter @Getter
@RequiredArgsConstructor @RequiredArgsConstructor
@ -28,18 +29,32 @@ public enum SignalKindCode {
private final String koreanName; private final String koreanName;
/** /**
* vesselType + extraInfo MDA 범례코드 치환 * vesselType + extraInfo MDA 범례코드 치환 (하위 호환용)
* * shipName 기반 BUOY 검출 불가 캐시 저장 시에는 3-파라미터 버전 사용 권장.
* 치환 우선순위:
* 1. vesselType 단독 매칭 (Cargo, Tanker, Passenger, AtoN )
* 2. vesselType + extraInfo 조합 매칭 (Vessel + Fishing )
* 3. fallback DEFAULT (000027)
*/ */
public static SignalKindCode resolve(String vesselType, String extraInfo) { public static SignalKindCode resolve(String vesselType, String extraInfo) {
return resolve(vesselType, extraInfo, null);
}
/**
* vesselType + extraInfo + shipName MDA 범례코드 치환
*
* 치환 우선순위:
* 1. shipName 기반 BUOY 검출 ('.' '_' 문자가 2개 이상 부이/항로표지)
* 2. vesselType 단독 매칭 (Cargo, Tanker, Passenger )
* 3. vesselType + extraInfo 조합 매칭 (Vessel + Fishing )
* 4. fallback DEFAULT (000027)
*/
public static SignalKindCode resolve(String vesselType, String extraInfo, String shipName) {
// 1. shipName 기반 BUOY 검출: '.' 또는 '_' 문자가 2개 이상
if (hasBuoyNamePattern(shipName)) {
return BUOY;
}
String vt = normalizeOrEmpty(vesselType); String vt = normalizeOrEmpty(vesselType);
String ei = normalizeOrEmpty(extraInfo); String ei = normalizeOrEmpty(extraInfo);
// 1. vesselType 단독 매칭 // 2. vesselType 단독 매칭
switch (vt) { switch (vt) {
case "cargo": case "cargo":
return CARGO; return CARGO;
@ -48,7 +63,7 @@ public enum SignalKindCode {
case "passenger": case "passenger":
return FERRY; return FERRY;
case "aton": case "aton":
return BUOY; return DEFAULT;
case "law enforcement": case "law enforcement":
return GOV; return GOV;
case "search and rescue": case "search and rescue":
@ -60,19 +75,19 @@ public enum SignalKindCode {
} }
// vesselType 그룹 매칭 // vesselType 그룹 매칭
if (matchesAny(vt, "tug", "pilot boat", "tender", "anti pollution", "medical transport")) { if (matchesAny(vt, "pilot boat", "anti pollution", "medical transport")) {
return GOV; return GOV;
} }
if (matchesAny(vt, "high speed craft", "wing in ground-effect")) { if (matchesAny(vt, "high speed craft", "wing in ground-effect")) {
return FERRY; return FERRY;
} }
// 2. "Vessel" + extraInfo 조합 // 3. "Vessel" + extraInfo 조합
if ("vessel".equals(vt)) { if ("vessel".equals(vt)) {
return resolveVesselExtraInfo(ei); return resolveVesselExtraInfo(ei);
} }
// 3. "N/A" + extraInfo 조합 // 4. "N/A" + extraInfo 조합
if ("n/a".equals(vt)) { if ("n/a".equals(vt)) {
if (ei.startsWith("hazardous cat")) { if (ei.startsWith("hazardous cat")) {
return CARGO; return CARGO;
@ -80,7 +95,7 @@ public enum SignalKindCode {
return DEFAULT; return DEFAULT;
} }
// 4. fallback // 5. fallback
return DEFAULT; return DEFAULT;
} }
@ -91,18 +106,32 @@ public enum SignalKindCode {
if ("military operations".equals(extraInfo)) { if ("military operations".equals(extraInfo)) {
return GOV; 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")) { if (extraInfo.startsWith("hazardous cat")) {
return CARGO; return CARGO;
} }
return DEFAULT; return DEFAULT;
} }
/**
* shipName에 '.' 또는 '_' 문자가 2개 이상 포함되면 부이/항로표지로 판정
*/
static boolean hasBuoyNamePattern(String shipName) {
if (shipName == null || shipName.isBlank()) {
return false;
}
int count = 0;
for (int i = 0; i < shipName.length(); i++) {
char c = shipName.charAt(i);
if (c == '.' || c == '_') {
count++;
if (count >= 2) {
return true;
}
}
}
return false;
}
private static boolean matchesAny(String value, String... candidates) { private static boolean matchesAny(String value, String... candidates) {
for (String candidate : candidates) { for (String candidate : candidates) {
if (candidate.equals(value)) { if (candidate.equals(value)) {

파일 보기

@ -122,16 +122,18 @@ public class VesselTrackToCompactConverter {
int pointCount = geometry.size(); int pointCount = geometry.size();
double avgSpeed = pointCount > 0 ? totalDistance / Math.max(1, pointCount) * 60 : 0; double avgSpeed = pointCount > 0 ? totalDistance / Math.max(1, pointCount) * 60 : 0;
// 선박 정보 설정 // 선박 정보 설정 (캐시에 이미 치환된 signalKindCode 사용)
String shipName = null; String shipName = null;
String shipType = null; String shipType = null;
String shipKindCode = null; String shipKindCode = null;
if (vesselInfo != null) { if (vesselInfo != null) {
shipName = vesselInfo.getName(); shipName = vesselInfo.getName();
shipType = vesselInfo.getVesselType(); shipType = vesselInfo.getVesselType();
shipKindCode = SignalKindCode.resolve(vesselInfo.getVesselType(), vesselInfo.getExtraInfo()).getCode(); shipKindCode = vesselInfo.getSignalKindCode() != null
? vesselInfo.getSignalKindCode()
: SignalKindCode.DEFAULT.getCode();
} else { } else {
shipKindCode = SignalKindCode.resolve(null, null).getCode(); shipKindCode = SignalKindCode.DEFAULT.getCode();
} }
String nationalCode = mmsi.length() >= 3 ? mmsi.substring(0, 3) : mmsi; String nationalCode = mmsi.length() >= 3 ? mmsi.substring(0, 3) : mmsi;

파일 보기

@ -1,6 +1,5 @@
package gc.mda.signal_batch.global.websocket.service; package gc.mda.signal_batch.global.websocket.service;
import gc.mda.signal_batch.global.util.SignalKindCode;
import gc.mda.signal_batch.global.exception.MemoryBudgetExceededException; import gc.mda.signal_batch.global.exception.MemoryBudgetExceededException;
import gc.mda.signal_batch.global.util.TrackMemoryEstimator; import gc.mda.signal_batch.global.util.TrackMemoryEstimator;
import gc.mda.signal_batch.global.websocket.dto.TrackChunkResponse; import gc.mda.signal_batch.global.websocket.dto.TrackChunkResponse;
@ -117,11 +116,13 @@ public class ChunkedTrackStreamingService {
private static class VesselInfo { private static class VesselInfo {
String shipName; String shipName;
String shipType; String shipType;
String signalKindCode;
long cacheTime; long cacheTime;
VesselInfo(String shipName, String shipType) { VesselInfo(String shipName, String shipType, String signalKindCode) {
this.shipName = shipName != null ? shipName : "-"; this.shipName = shipName != null ? shipName : "-";
this.shipType = shipType != null ? shipType : "-"; this.shipType = shipType != null ? shipType : "-";
this.signalKindCode = signalKindCode != null ? signalKindCode : "000027";
this.cacheTime = System.currentTimeMillis(); this.cacheTime = System.currentTimeMillis();
} }
@ -229,15 +230,16 @@ public class ChunkedTrackStreamingService {
return cached; return cached;
} }
// DB에서 조회 // DB에서 조회 (signal_kind_code는 캐시 저장 치환된 )
try { try {
String sql = "SELECT ship_nm, vessel_type FROM signal.t_ais_position " + String sql = "SELECT ship_nm, vessel_type, signal_kind_code FROM signal.t_ais_position " +
"WHERE mmsi = ? LIMIT 1"; "WHERE mmsi = ? LIMIT 1";
VesselInfo info = queryJdbcTemplate.queryForObject(sql, VesselInfo info = queryJdbcTemplate.queryForObject(sql,
(rs, rowNum) -> new VesselInfo( (rs, rowNum) -> new VesselInfo(
rs.getString("ship_nm"), rs.getString("ship_nm"),
rs.getString("vessel_type") rs.getString("vessel_type"),
rs.getString("signal_kind_code")
), ),
mmsi mmsi
); );
@ -249,7 +251,7 @@ public class ChunkedTrackStreamingService {
} catch (Exception e) { } catch (Exception e) {
log.debug("No vessel info found for {}, using defaults", mmsi); log.debug("No vessel info found for {}, using defaults", mmsi);
VesselInfo defaultInfo = new VesselInfo(null, null); VesselInfo defaultInfo = new VesselInfo(null, null, null);
vesselInfoCache.put(mmsi, defaultInfo); vesselInfoCache.put(mmsi, defaultInfo);
return defaultInfo; return defaultInfo;
} }
@ -287,7 +289,7 @@ public class ChunkedTrackStreamingService {
// 캐시에 없는 것들은 DB에서 배치 조회 // 캐시에 없는 것들은 DB에서 배치 조회
if (!uncachedIds.isEmpty()) { if (!uncachedIds.isEmpty()) {
try { try {
String sql = "SELECT mmsi, ship_nm, vessel_type " + String sql = "SELECT mmsi, ship_nm, vessel_type, signal_kind_code " +
"FROM signal.t_ais_position " + "FROM signal.t_ais_position " +
"WHERE mmsi IN (" + "WHERE mmsi IN (" +
String.join(",", Collections.nCopies(uncachedIds.size(), "?")) + ")"; String.join(",", Collections.nCopies(uncachedIds.size(), "?")) + ")";
@ -296,7 +298,8 @@ public class ChunkedTrackStreamingService {
String vesselId = rs.getString("mmsi"); String vesselId = rs.getString("mmsi");
VesselInfo info = new VesselInfo( VesselInfo info = new VesselInfo(
rs.getString("ship_nm"), rs.getString("ship_nm"),
rs.getString("vessel_type") rs.getString("vessel_type"),
rs.getString("signal_kind_code")
); );
result.put(vesselId, info); result.put(vesselId, info);
vesselInfoCache.put(vesselId, info); vesselInfoCache.put(vesselId, info);
@ -308,7 +311,7 @@ public class ChunkedTrackStreamingService {
// 기본값 설정 // 기본값 설정
for (String vesselId : uncachedIds) { for (String vesselId : uncachedIds) {
if (!result.containsKey(vesselId)) { if (!result.containsKey(vesselId)) {
VesselInfo defaultInfo = new VesselInfo(null, null); VesselInfo defaultInfo = new VesselInfo(null, null, null);
result.put(vesselId, defaultInfo); result.put(vesselId, defaultInfo);
vesselInfoCache.put(vesselId, defaultInfo); vesselInfoCache.put(vesselId, defaultInfo);
} }
@ -365,7 +368,7 @@ public class ChunkedTrackStreamingService {
.map(id -> "?") .map(id -> "?")
.collect(Collectors.joining(",")); .collect(Collectors.joining(","));
String sql = "SELECT mmsi, ship_nm, vessel_type " + String sql = "SELECT mmsi, ship_nm, vessel_type, signal_kind_code " +
"FROM signal.t_ais_position " + "FROM signal.t_ais_position " +
"WHERE mmsi IN (" + placeholders + ")"; "WHERE mmsi IN (" + placeholders + ")";
@ -375,7 +378,8 @@ public class ChunkedTrackStreamingService {
String visselId = rs.getString("mmsi"); String visselId = rs.getString("mmsi");
VesselInfo info = new VesselInfo( VesselInfo info = new VesselInfo(
rs.getString("ship_nm"), rs.getString("ship_nm"),
rs.getString("vessel_type") rs.getString("vessel_type"),
rs.getString("signal_kind_code")
); );
// 세션 캐시와 전역 캐시 모두에 저장 // 세션 캐시와 전역 캐시 모두에 저장
sessionCache.put(visselId, info); sessionCache.put(visselId, info);
@ -387,7 +391,7 @@ public class ChunkedTrackStreamingService {
// DB에 없는 선박은 기본값으로 세션 캐시에 저장 (전역 캐시에는 저장 안함) // DB에 없는 선박은 기본값으로 세션 캐시에 저장 (전역 캐시에는 저장 안함)
for (String visselId : batch) { for (String visselId : batch) {
if (!foundIds.contains(visselId)) { if (!foundIds.contains(visselId)) {
sessionCache.put(visselId, new VesselInfo(null, null)); sessionCache.put(visselId, new VesselInfo(null, null, null));
} }
} }
@ -395,7 +399,7 @@ public class ChunkedTrackStreamingService {
log.warn("Failed to batch load vessel info: {}", e.getMessage()); log.warn("Failed to batch load vessel info: {}", e.getMessage());
for (String visselId : batch) { for (String visselId : batch) {
if (!sessionCache.containsKey(visselId)) { if (!sessionCache.containsKey(visselId)) {
sessionCache.put(visselId, new VesselInfo(null, null)); sessionCache.put(visselId, new VesselInfo(null, null, null));
} }
} }
} }
@ -433,7 +437,7 @@ public class ChunkedTrackStreamingService {
.map(id -> "?") .map(id -> "?")
.collect(Collectors.joining(",")); .collect(Collectors.joining(","));
String sql = "SELECT mmsi, ship_nm, vessel_type " + String sql = "SELECT mmsi, ship_nm, vessel_type, signal_kind_code " +
"FROM signal.t_ais_position " + "FROM signal.t_ais_position " +
"WHERE mmsi IN (" + placeholders + ")"; "WHERE mmsi IN (" + placeholders + ")";
@ -443,7 +447,8 @@ public class ChunkedTrackStreamingService {
String vesselId = rs.getString("mmsi"); String vesselId = rs.getString("mmsi");
VesselInfo info = new VesselInfo( VesselInfo info = new VesselInfo(
rs.getString("ship_nm"), rs.getString("ship_nm"),
rs.getString("vessel_type") rs.getString("vessel_type"),
rs.getString("signal_kind_code")
); );
vesselInfoCache.put(vesselId, info); vesselInfoCache.put(vesselId, info);
foundIds.add(vesselId); foundIds.add(vesselId);
@ -452,7 +457,7 @@ public class ChunkedTrackStreamingService {
// DB에 없는 선박은 기본값으로 캐시 // DB에 없는 선박은 기본값으로 캐시
for (String vesselId : batch) { for (String vesselId : batch) {
if (!foundIds.contains(vesselId)) { if (!foundIds.contains(vesselId)) {
vesselInfoCache.put(vesselId, new VesselInfo(null, null)); vesselInfoCache.put(vesselId, new VesselInfo(null, null, null));
} }
} }
@ -461,7 +466,7 @@ public class ChunkedTrackStreamingService {
// 실패 기본값으로 캐시 // 실패 기본값으로 캐시
for (String vesselId : batch) { for (String vesselId : batch) {
if (!vesselInfoCache.containsKey(vesselId)) { if (!vesselInfoCache.containsKey(vesselId)) {
vesselInfoCache.put(vesselId, new VesselInfo(null, null)); vesselInfoCache.put(vesselId, new VesselInfo(null, null, null));
} }
} }
} }
@ -635,7 +640,7 @@ public class ChunkedTrackStreamingService {
VesselInfo vesselInfo = getVesselInfo(mmsi); VesselInfo vesselInfo = getVesselInfo(mmsi);
accumulator.shipName = vesselInfo.shipName; accumulator.shipName = vesselInfo.shipName;
accumulator.shipType = vesselInfo.shipType; accumulator.shipType = vesselInfo.shipType;
accumulator.shipKindCode = SignalKindCode.resolve(vesselInfo.shipType, null).getCode(); accumulator.shipKindCode = vesselInfo.signalKindCode;
vesselMap.put(vesselId, accumulator); vesselMap.put(vesselId, accumulator);
} }
@ -1101,7 +1106,7 @@ public class ChunkedTrackStreamingService {
VesselInfo vesselInfo = getVesselInfo(track.getVesselId()); VesselInfo vesselInfo = getVesselInfo(track.getVesselId());
accumulator.shipName = vesselInfo.shipName; accumulator.shipName = vesselInfo.shipName;
accumulator.shipType = vesselInfo.shipType; accumulator.shipType = vesselInfo.shipType;
accumulator.shipKindCode = SignalKindCode.resolve(vesselInfo.shipType, null).getCode(); accumulator.shipKindCode = vesselInfo.signalKindCode;
mergedMap.put(vesselId, accumulator); mergedMap.put(vesselId, accumulator);
} }
@ -1985,7 +1990,7 @@ public class ChunkedTrackStreamingService {
acc.mmsi = finalMmsi; acc.mmsi = finalMmsi;
acc.shipName = info.shipName; acc.shipName = info.shipName;
acc.shipType = info.shipType; acc.shipType = info.shipType;
acc.shipKindCode = SignalKindCode.resolve(info.shipType, null).getCode(); acc.shipKindCode = info.signalKindCode;
return acc; return acc;
}); });
@ -2742,13 +2747,13 @@ public class ChunkedTrackStreamingService {
// 세션 캐시에서 조회 (이미 preload됨) // 세션 캐시에서 조회 (이미 preload됨)
VesselInfo info = sessionVesselCache.get(finalMmsi3); VesselInfo info = sessionVesselCache.get(finalMmsi3);
if (info == null) { if (info == null) {
info = new VesselInfo(null, null); info = new VesselInfo(null, null, null);
} }
VesselAccumulator acc = new VesselAccumulator(); VesselAccumulator acc = new VesselAccumulator();
acc.mmsi = finalMmsi3; acc.mmsi = finalMmsi3;
acc.shipName = info.shipName; acc.shipName = info.shipName;
acc.shipType = info.shipType; acc.shipType = info.shipType;
acc.shipKindCode = SignalKindCode.resolve(info.shipType, null).getCode(); acc.shipKindCode = info.signalKindCode;
return acc; return acc;
}); });

파일 보기

@ -310,8 +310,9 @@ public class DailyTrackCacheManager {
double avgSpeed = acc.pointCount > 0 ? acc.totalDistance / Math.max(1, acc.pointCount) * 60 : 0; double avgSpeed = acc.pointCount > 0 ? acc.totalDistance / Math.max(1, acc.pointCount) * 60 : 0;
// shipKindCode 계산 // shipKindCode: 캐시 저장 치환된 사용 (DB fallback 포함)
String shipKindCode = SignalKindCode.resolve(acc.shipType, null).getCode(); String shipKindCode = acc.signalKindCode != null
? acc.signalKindCode : SignalKindCode.DEFAULT.getCode();
// nationalCode 계산 (MMSI 3자리 = MID) // nationalCode 계산 (MMSI 3자리 = MID)
String nationalCode = acc.mmsi.length() >= 3 ? acc.mmsi.substring(0, 3) : acc.mmsi; String nationalCode = acc.mmsi.length() >= 3 ? acc.mmsi.substring(0, 3) : acc.mmsi;
@ -723,7 +724,7 @@ public class DailyTrackCacheManager {
try (Connection conn = queryDataSource.getConnection()) { try (Connection conn = queryDataSource.getConnection()) {
String placeholders = batch.stream().map(id -> "?").collect(Collectors.joining(",")); String placeholders = batch.stream().map(id -> "?").collect(Collectors.joining(","));
String sql = "SELECT mmsi, name as ship_nm, vessel_type as ship_ty " + String sql = "SELECT mmsi, name as ship_nm, vessel_type as ship_ty, signal_kind_code " +
"FROM signal.t_ais_position " + "FROM signal.t_ais_position " +
"WHERE mmsi IN (" + placeholders + ")"; "WHERE mmsi IN (" + placeholders + ")";
@ -739,6 +740,7 @@ public class DailyTrackCacheManager {
if (acc != null) { if (acc != null) {
acc.shipName = rs.getString("ship_nm"); acc.shipName = rs.getString("ship_nm");
acc.shipType = rs.getString("ship_ty"); acc.shipType = rs.getString("ship_ty");
acc.signalKindCode = rs.getString("signal_kind_code");
enriched++; enriched++;
} }
} }
@ -782,6 +784,7 @@ public class DailyTrackCacheManager {
String mmsi; String mmsi;
String shipName; String shipName;
String shipType; String shipType;
String signalKindCode;
List<double[]> geometry = new ArrayList<>(500); List<double[]> geometry = new ArrayList<>(500);
List<String> timestamps = new ArrayList<>(500); List<String> timestamps = new ArrayList<>(500);
List<Double> speeds = new ArrayList<>(500); List<Double> speeds = new ArrayList<>(500);

파일 보기

@ -12,6 +12,25 @@ import static org.junit.jupiter.api.Assertions.*;
class SignalKindCodeTest { class SignalKindCodeTest {
@Nested
@DisplayName("shipName 기반 BUOY 검출")
class ShipNameBuoy {
@Test
@DisplayName("'.' 또는 '_' 2개 이상 → BUOY (vesselType 무시)")
void resolve_buoyByName() {
assertEquals("000028", SignalKindCode.resolve("Cargo", null, "BUOY_01_23").getCode());
assertEquals("000028", SignalKindCode.resolve("Tanker", null, "AIS.BUOY.01").getCode());
}
@Test
@DisplayName("'.' 또는 '_' 1개 이하 → vesselType 기준")
void resolve_notBuoyByName() {
assertEquals("000023", SignalKindCode.resolve("Cargo", null, "M.V CARGO").getCode());
assertEquals("000024", SignalKindCode.resolve("Tanker", null, "OIL_TANKER").getCode());
}
}
@Nested @Nested
@DisplayName("vesselType 단독 매칭") @DisplayName("vesselType 단독 매칭")
class VesselTypeDirect { class VesselTypeDirect {
@ -21,7 +40,7 @@ class SignalKindCodeTest {
"Cargo, 000023", "Cargo, 000023",
"Tanker, 000024", "Tanker, 000024",
"Passenger, 000022", "Passenger, 000022",
"AtoN, 000028", "AtoN, 000027",
"Law Enforcement, 000025", "Law Enforcement, 000025",
"Search and Rescue, 000021", "Search and Rescue, 000021",
"Local Vessel, 000020" "Local Vessel, 000020"
@ -38,11 +57,11 @@ class SignalKindCodeTest {
@ParameterizedTest @ParameterizedTest
@CsvSource({ @CsvSource({
"Tug, 000025",
"Pilot Boat, 000025", "Pilot Boat, 000025",
"Tender, 000025",
"Anti Pollution, 000025", "Anti Pollution, 000025",
"Medical Transport, 000025", "Medical Transport, 000025",
"Tug, 000027",
"Tender, 000027",
"High Speed Craft, 000022", "High Speed Craft, 000022",
"Wing in Ground-effect, 000022" "Wing in Ground-effect, 000022"
}) })
@ -60,13 +79,13 @@ class SignalKindCodeTest {
@CsvSource({ @CsvSource({
"Vessel, Fishing, 000020", "Vessel, Fishing, 000020",
"Vessel, Military Operations, 000025", "Vessel, Military Operations, 000025",
"Vessel, Towing, 000025", "Vessel, Towing, 000027",
"Vessel, Towing (Large), 000025", "Vessel, Towing (Large), 000027",
"Vessel, Dredging/Underwater Ops, 000025", "Vessel, Dredging/Underwater Ops, 000027",
"Vessel, Diving Operations, 000025", "Vessel, Diving Operations, 000027",
"Vessel, Pleasure Craft, 000020", "Vessel, Pleasure Craft, 000027",
"Vessel, Sailing, 000020", "Vessel, Sailing, 000027",
"Vessel, N/A, 000020", "Vessel, N/A, 000027",
"Vessel, Hazardous Cat A, 000023", "Vessel, Hazardous Cat A, 000023",
"Vessel, Hazardous Cat B, 000023", "Vessel, Hazardous Cat B, 000023",
"Vessel, Unknown, 000027" "Vessel, Unknown, 000027"

파일 보기

@ -14,6 +14,34 @@ import static org.assertj.core.api.Assertions.assertThat;
@DisplayName("SignalKindCode - MDA 선종 범례코드 치환") @DisplayName("SignalKindCode - MDA 선종 범례코드 치환")
class SignalKindCodeTest { class SignalKindCodeTest {
@Nested
@DisplayName("shipName 기반 BUOY 검출 (최우선)")
class ShipNameBuoy {
@ParameterizedTest(name = "shipName={0} → BUOY")
@ValueSource(strings = {"BUOY_01_23", "AIS.BUOY.01", "LIGHT__HOUSE", "A.B.C"})
@DisplayName("'.' 또는 '_' 2개 이상 → BUOY")
void resolve_buoyByName(String shipName) {
assertThat(SignalKindCode.resolve("Cargo", null, shipName))
.isEqualTo(SignalKindCode.BUOY);
}
@ParameterizedTest(name = "shipName={0} → vesselType 기준")
@ValueSource(strings = {"M.V CARGO", "SHIP_ONE", "NORMAL SHIP", "ABC"})
@DisplayName("'.' 또는 '_' 1개 이하 → shipName 무시, vesselType 기준")
void resolve_notBuoyByName(String shipName) {
assertThat(SignalKindCode.resolve("Cargo", null, shipName))
.isEqualTo(SignalKindCode.CARGO);
}
@Test
@DisplayName("shipName null → vesselType 기준")
void resolve_nullShipName() {
assertThat(SignalKindCode.resolve("Cargo", null, null))
.isEqualTo(SignalKindCode.CARGO);
}
}
@Nested @Nested
@DisplayName("vesselType 단독 매칭") @DisplayName("vesselType 단독 매칭")
class VesselTypeDirect { class VesselTypeDirect {
@ -23,7 +51,6 @@ class SignalKindCodeTest {
"cargo, CARGO", "cargo, CARGO",
"tanker, TANKER", "tanker, TANKER",
"passenger, FERRY", "passenger, FERRY",
"aton, BUOY",
"law enforcement, GOV", "law enforcement, GOV",
"search and rescue, KCGV", "search and rescue, KCGV",
"local vessel, FISHING" "local vessel, FISHING"
@ -33,6 +60,12 @@ class SignalKindCodeTest {
SignalKindCode result = SignalKindCode.resolve(vesselType, null); SignalKindCode result = SignalKindCode.resolve(vesselType, null);
assertThat(result.name()).isEqualTo(expectedName); assertThat(result.name()).isEqualTo(expectedName);
} }
@Test
@DisplayName("aton → DEFAULT (부이가 아닌 일반 장비)")
void resolve_aton() {
assertThat(SignalKindCode.resolve("aton", null)).isEqualTo(SignalKindCode.DEFAULT);
}
} }
@Nested @Nested
@ -40,12 +73,19 @@ class SignalKindCodeTest {
class VesselTypeGroup { class VesselTypeGroup {
@ParameterizedTest(name = "vesselType={0} → GOV") @ParameterizedTest(name = "vesselType={0} → GOV")
@ValueSource(strings = {"tug", "pilot boat", "tender", "anti pollution", "medical transport"}) @ValueSource(strings = {"pilot boat", "anti pollution", "medical transport"})
@DisplayName("GOV 그룹 매칭") @DisplayName("GOV 그룹 매칭")
void resolve_govGroup(String vesselType) { void resolve_govGroup(String vesselType) {
assertThat(SignalKindCode.resolve(vesselType, null)).isEqualTo(SignalKindCode.GOV); assertThat(SignalKindCode.resolve(vesselType, null)).isEqualTo(SignalKindCode.GOV);
} }
@ParameterizedTest(name = "vesselType={0} → DEFAULT")
@ValueSource(strings = {"tug", "tender"})
@DisplayName("tug, tender → DEFAULT")
void resolve_tugTenderDefault(String vesselType) {
assertThat(SignalKindCode.resolve(vesselType, null)).isEqualTo(SignalKindCode.DEFAULT);
}
@ParameterizedTest(name = "vesselType={0} → FERRY") @ParameterizedTest(name = "vesselType={0} → FERRY")
@ValueSource(strings = {"high speed craft", "wing in ground-effect"}) @ValueSource(strings = {"high speed craft", "wing in ground-effect"})
@DisplayName("FERRY 그룹 매칭") @DisplayName("FERRY 그룹 매칭")
@ -70,18 +110,18 @@ class SignalKindCodeTest {
assertThat(SignalKindCode.resolve("Vessel", "Military Operations")).isEqualTo(SignalKindCode.GOV); assertThat(SignalKindCode.resolve("Vessel", "Military Operations")).isEqualTo(SignalKindCode.GOV);
} }
@ParameterizedTest(name = "Vessel + {0} → GOV") @ParameterizedTest(name = "Vessel + {0} → DEFAULT")
@ValueSource(strings = {"towing", "towing (large)", "dredging/underwater ops", "diving operations"}) @ValueSource(strings = {"towing", "towing (large)", "dredging/underwater ops", "diving operations"})
@DisplayName("Vessel + 해양작업 → GOV") @DisplayName("Vessel + 해양작업 → DEFAULT")
void resolve_vesselMarineOps(String extraInfo) { void resolve_vesselMarineOps(String extraInfo) {
assertThat(SignalKindCode.resolve("Vessel", extraInfo)).isEqualTo(SignalKindCode.GOV); assertThat(SignalKindCode.resolve("Vessel", extraInfo)).isEqualTo(SignalKindCode.DEFAULT);
} }
@ParameterizedTest(name = "Vessel + {0} → FISHING") @ParameterizedTest(name = "Vessel + {0} → DEFAULT")
@ValueSource(strings = {"pleasure craft", "sailing", "n/a"}) @ValueSource(strings = {"pleasure craft", "sailing", "n/a"})
@DisplayName("Vessel + 레저/기타 → FISHING") @DisplayName("Vessel + 레저/기타 → DEFAULT")
void resolve_vesselLeisure(String extraInfo) { void resolve_vesselLeisure(String extraInfo) {
assertThat(SignalKindCode.resolve("Vessel", extraInfo)).isEqualTo(SignalKindCode.FISHING); assertThat(SignalKindCode.resolve("Vessel", extraInfo)).isEqualTo(SignalKindCode.DEFAULT);
} }
@Test @Test
@ -164,4 +204,32 @@ class SignalKindCodeTest {
assertThat(SignalKindCode.BUOY.getCode()).isEqualTo("000028"); assertThat(SignalKindCode.BUOY.getCode()).isEqualTo("000028");
} }
} }
@Nested
@DisplayName("shipName BUOY 판정 (resolve 3-param 통합 검증)")
class BuoyNamePattern {
@ParameterizedTest(name = "{0} → BUOY")
@ValueSource(strings = {"A.B.C", "BUOY_01_02", "._", "A.B_C"})
@DisplayName("2개 이상 특수문자 → BUOY")
void resolve_buoyPattern(String name) {
// vesselType과 무관하게 BUOY로 치환
assertThat(SignalKindCode.resolve(null, null, name)).isEqualTo(SignalKindCode.BUOY);
}
@ParameterizedTest(name = "{0} → not BUOY")
@ValueSource(strings = {"ABC", "A.B", "A_B", "NORMAL"})
@DisplayName("1개 이하 특수문자 → shipName 무시")
void resolve_notBuoyPattern(String name) {
assertThat(SignalKindCode.resolve(null, null, name)).isEqualTo(SignalKindCode.DEFAULT);
}
@Test
@DisplayName("null/blank shipName → vesselType 기준")
void resolve_nullBlankName() {
assertThat(SignalKindCode.resolve("Cargo", null, null)).isEqualTo(SignalKindCode.CARGO);
assertThat(SignalKindCode.resolve("Cargo", null, "")).isEqualTo(SignalKindCode.CARGO);
assertThat(SignalKindCode.resolve("Cargo", null, " ")).isEqualTo(SignalKindCode.CARGO);
}
}
} }