Merge pull request 'release: 2026-03-02.2 (105건 커밋)' (#90) from develop into main
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 2m29s
All checks were successful
Build & Deploy / build-and-deploy (push) Successful in 2m29s
This commit is contained in:
커밋
7a21d5b8b0
@ -4,6 +4,13 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [2026-03-02.2]
|
||||||
|
|
||||||
|
### 변경
|
||||||
|
- SignalKindCode 매핑 규칙 개선 — aton/tug/tender→DEFAULT, shipName BUOY 검출 추가
|
||||||
|
- 응답 경로 signal_kind_code 치환 1회화 — 캐시 저장 시 치환, 응답 시 DB/캐시 값 직접 사용
|
||||||
|
- ChunkedTrackStreamingService 전수 최적화 — isQueryCancelled 버그수정, QueryContext 스레드 안전성, 쿼리 메트릭 DB 저장, 데드코드 400줄 삭제, VesselInfo N+1 해소
|
||||||
|
|
||||||
## [2026-03-02]
|
## [2026-03-02]
|
||||||
|
|
||||||
### 추가
|
### 추가
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -316,4 +316,11 @@ public class ActiveQueryManager {
|
|||||||
public int getMaxConcurrentGlobal() {
|
public int getMaxConcurrentGlobal() {
|
||||||
return maxConcurrentGlobal;
|
return maxConcurrentGlobal;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 대기열 타임아웃 (초)
|
||||||
|
*/
|
||||||
|
public int getQueueTimeoutSeconds() {
|
||||||
|
return queueTimeoutSeconds;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
Load Diff
@ -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);
|
||||||
|
|||||||
@ -0,0 +1,40 @@
|
|||||||
|
package gc.mda.signal_batch.monitoring.controller;
|
||||||
|
|
||||||
|
import gc.mda.signal_batch.monitoring.service.QueryMetricsService;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 쿼리 메트릭 조회 API
|
||||||
|
*
|
||||||
|
* WebSocket/REST 쿼리 실행 이력 및 성능 통계를 제공한다.
|
||||||
|
* ApiMetrics 프론트엔드 페이지의 데이터 소스.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/monitoring/query-metrics")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Tag(name = "Query Metrics", description = "쿼리 실행 메트릭 조회 API")
|
||||||
|
public class QueryMetricsController {
|
||||||
|
|
||||||
|
private final QueryMetricsService queryMetricsService;
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
@Operation(summary = "최근 쿼리 메트릭 조회", description = "최근 N건의 쿼리 실행 메트릭을 조회합니다")
|
||||||
|
public ResponseEntity<List<Map<String, Object>>> getRecentMetrics(
|
||||||
|
@RequestParam(defaultValue = "50") int limit) {
|
||||||
|
return ResponseEntity.ok(queryMetricsService.getRecentMetrics(Math.min(limit, 200)));
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/stats")
|
||||||
|
@Operation(summary = "쿼리 메트릭 통계", description = "기간별 쿼리 성능 통계 (평균 응답시간, 캐시 비율, 느린 쿼리 등)")
|
||||||
|
public ResponseEntity<Map<String, Object>> getStats(
|
||||||
|
@RequestParam(defaultValue = "7") int days) {
|
||||||
|
return ResponseEntity.ok(queryMetricsService.getStats(Math.min(days, 90)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,180 @@
|
|||||||
|
package gc.mda.signal_batch.monitoring.service;
|
||||||
|
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.beans.factory.annotation.Qualifier;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.scheduling.annotation.Async;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.sql.Timestamp;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 쿼리 실행 메트릭 DB 저장/조회 서비스
|
||||||
|
*
|
||||||
|
* WebSocket 리플레이 및 REST API 쿼리의 성능 메트릭을 signal.t_query_metrics에 저장.
|
||||||
|
* streamChunkedTracks() finally 블록에서 비동기 INSERT 호출하여 응답 지연 없이 기록.
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
public class QueryMetricsService {
|
||||||
|
|
||||||
|
private final JdbcTemplate queryJdbcTemplate;
|
||||||
|
|
||||||
|
private static final String INSERT_SQL = """
|
||||||
|
INSERT INTO signal.t_query_metrics (
|
||||||
|
query_id, session_id, query_type, created_at,
|
||||||
|
start_time, end_time, zoom_level, viewport_bounds, requested_mmsi,
|
||||||
|
data_path, cache_hit_days, db_query_days, db_conn_total,
|
||||||
|
unique_vessels, total_tracks, total_points, points_after_simplify,
|
||||||
|
total_chunks, response_bytes,
|
||||||
|
elapsed_ms, db_query_ms, simplify_ms, backpressure_events,
|
||||||
|
status
|
||||||
|
) VALUES (
|
||||||
|
?, ?, ?, now(),
|
||||||
|
?, ?, ?, ?, ?,
|
||||||
|
?, ?, ?, ?,
|
||||||
|
?, ?, ?, ?,
|
||||||
|
?, ?,
|
||||||
|
?, ?, ?, ?,
|
||||||
|
?
|
||||||
|
)
|
||||||
|
""";
|
||||||
|
|
||||||
|
public QueryMetricsService(@Qualifier("queryJdbcTemplate") JdbcTemplate queryJdbcTemplate) {
|
||||||
|
this.queryJdbcTemplate = queryJdbcTemplate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 쿼리 메트릭 비동기 저장 — 쿼리 응답에 영향 없음
|
||||||
|
*/
|
||||||
|
@Async("trackStreamingExecutor")
|
||||||
|
public void saveAsync(QueryMetric metric) {
|
||||||
|
try {
|
||||||
|
queryJdbcTemplate.update(INSERT_SQL,
|
||||||
|
metric.queryId, metric.sessionId, metric.queryType,
|
||||||
|
metric.startTime != null ? Timestamp.valueOf(metric.startTime) : null,
|
||||||
|
metric.endTime != null ? Timestamp.valueOf(metric.endTime) : null,
|
||||||
|
metric.zoomLevel, metric.viewportBounds, metric.requestedMmsi,
|
||||||
|
metric.dataPath, metric.cacheHitDays, metric.dbQueryDays, metric.dbConnTotal,
|
||||||
|
metric.uniqueVessels, metric.totalTracks, metric.totalPoints, metric.pointsAfterSimplify,
|
||||||
|
metric.totalChunks, metric.responseBytes,
|
||||||
|
metric.elapsedMs, metric.dbQueryMs, metric.simplifyMs, metric.backpressureEvents,
|
||||||
|
metric.status
|
||||||
|
);
|
||||||
|
log.debug("Query metric saved: queryId={}, elapsed={}ms, status={}",
|
||||||
|
metric.queryId, metric.elapsedMs, metric.status);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Failed to save query metric: queryId={}, error={}", metric.queryId, e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 최근 쿼리 메트릭 조회
|
||||||
|
*/
|
||||||
|
public List<Map<String, Object>> getRecentMetrics(int limit) {
|
||||||
|
return queryJdbcTemplate.queryForList("""
|
||||||
|
SELECT query_id, session_id, query_type, created_at,
|
||||||
|
start_time, end_time, zoom_level, viewport_bounds,
|
||||||
|
data_path, cache_hit_days, db_query_days, db_conn_total,
|
||||||
|
unique_vessels, total_tracks, total_points, points_after_simplify,
|
||||||
|
total_chunks, response_bytes,
|
||||||
|
elapsed_ms, db_query_ms, simplify_ms, backpressure_events, status
|
||||||
|
FROM signal.t_query_metrics
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT ?
|
||||||
|
""", limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 기간별 쿼리 메트릭 통계
|
||||||
|
*/
|
||||||
|
public Map<String, Object> getStats(int days) {
|
||||||
|
Map<String, Object> stats = new LinkedHashMap<>();
|
||||||
|
|
||||||
|
// 전체 통계
|
||||||
|
Map<String, Object> summary = queryJdbcTemplate.queryForMap("""
|
||||||
|
SELECT
|
||||||
|
COUNT(*) AS total_queries,
|
||||||
|
ROUND(AVG(elapsed_ms)) AS avg_elapsed_ms,
|
||||||
|
MAX(elapsed_ms) AS max_elapsed_ms,
|
||||||
|
ROUND(AVG(unique_vessels)) AS avg_vessels,
|
||||||
|
ROUND(AVG(total_points)) AS avg_points,
|
||||||
|
SUM(CASE WHEN data_path = 'CACHE' THEN 1 ELSE 0 END) AS cache_only,
|
||||||
|
SUM(CASE WHEN data_path = 'HYBRID' THEN 1 ELSE 0 END) AS hybrid,
|
||||||
|
SUM(CASE WHEN data_path = 'DB' THEN 1 ELSE 0 END) AS db_only,
|
||||||
|
SUM(CASE WHEN status = 'COMPLETED' THEN 1 ELSE 0 END) AS completed,
|
||||||
|
SUM(CASE WHEN status = 'CANCELLED' THEN 1 ELSE 0 END) AS cancelled,
|
||||||
|
SUM(CASE WHEN status = 'ERROR' THEN 1 ELSE 0 END) AS errors,
|
||||||
|
SUM(CASE WHEN status = 'TIMEOUT' THEN 1 ELSE 0 END) AS timeouts
|
||||||
|
FROM signal.t_query_metrics
|
||||||
|
WHERE created_at >= now() - INTERVAL '%d days'
|
||||||
|
""".formatted(days));
|
||||||
|
stats.put("summary", summary);
|
||||||
|
|
||||||
|
// 일별 추이
|
||||||
|
List<Map<String, Object>> daily = queryJdbcTemplate.queryForList("""
|
||||||
|
SELECT
|
||||||
|
DATE(created_at) AS date,
|
||||||
|
COUNT(*) AS query_count,
|
||||||
|
ROUND(AVG(elapsed_ms)) AS avg_elapsed_ms,
|
||||||
|
ROUND(AVG(unique_vessels)) AS avg_vessels,
|
||||||
|
SUM(CASE WHEN status = 'COMPLETED' THEN 1 ELSE 0 END) AS completed,
|
||||||
|
SUM(CASE WHEN status != 'COMPLETED' THEN 1 ELSE 0 END) AS failed
|
||||||
|
FROM signal.t_query_metrics
|
||||||
|
WHERE created_at >= now() - INTERVAL '%d days'
|
||||||
|
GROUP BY DATE(created_at)
|
||||||
|
ORDER BY date DESC
|
||||||
|
""".formatted(days));
|
||||||
|
stats.put("dailyTrend", daily);
|
||||||
|
|
||||||
|
// 느린 쿼리 TOP 10
|
||||||
|
List<Map<String, Object>> slowQueries = queryJdbcTemplate.queryForList("""
|
||||||
|
SELECT query_id, created_at, elapsed_ms, unique_vessels, total_points,
|
||||||
|
data_path, db_conn_total, zoom_level, status
|
||||||
|
FROM signal.t_query_metrics
|
||||||
|
WHERE created_at >= now() - INTERVAL '%d days'
|
||||||
|
ORDER BY elapsed_ms DESC
|
||||||
|
LIMIT 10
|
||||||
|
""".formatted(days));
|
||||||
|
stats.put("slowQueries", slowQueries);
|
||||||
|
|
||||||
|
return stats;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 쿼리 메트릭 데이터 클래스
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@Builder
|
||||||
|
public static class QueryMetric {
|
||||||
|
private final String queryId;
|
||||||
|
private final String sessionId;
|
||||||
|
private final String queryType;
|
||||||
|
private final LocalDateTime startTime;
|
||||||
|
private final LocalDateTime endTime;
|
||||||
|
private final Integer zoomLevel;
|
||||||
|
private final String viewportBounds;
|
||||||
|
private final int requestedMmsi;
|
||||||
|
private final String dataPath;
|
||||||
|
private final int cacheHitDays;
|
||||||
|
private final int dbQueryDays;
|
||||||
|
private final int dbConnTotal;
|
||||||
|
private final int uniqueVessels;
|
||||||
|
private final int totalTracks;
|
||||||
|
private final int totalPoints;
|
||||||
|
private final int pointsAfterSimplify;
|
||||||
|
private final int totalChunks;
|
||||||
|
private final long responseBytes;
|
||||||
|
private final long elapsedMs;
|
||||||
|
private final long dbQueryMs;
|
||||||
|
private final long simplifyMs;
|
||||||
|
private final int backpressureEvents;
|
||||||
|
private final String status;
|
||||||
|
}
|
||||||
|
}
|
||||||
42
src/main/resources/sql/create_query_metrics_table.sql
Normal file
42
src/main/resources/sql/create_query_metrics_table.sql
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
-- 쿼리 실행 메트릭 테이블
|
||||||
|
-- WebSocket/REST 쿼리의 성능 지표를 기록하여 ApiMetrics 페이지에서 조회
|
||||||
|
CREATE TABLE IF NOT EXISTS signal.t_query_metrics (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
query_id VARCHAR(64) NOT NULL,
|
||||||
|
session_id VARCHAR(64),
|
||||||
|
query_type VARCHAR(20) NOT NULL, -- 'WEBSOCKET' | 'REST_V1' | 'REST_V2'
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT now(),
|
||||||
|
|
||||||
|
-- 요청 파라미터
|
||||||
|
start_time TIMESTAMP,
|
||||||
|
end_time TIMESTAMP,
|
||||||
|
zoom_level INTEGER,
|
||||||
|
viewport_bounds VARCHAR(200), -- "minLon,minLat,maxLon,maxLat"
|
||||||
|
requested_mmsi INTEGER DEFAULT 0,
|
||||||
|
|
||||||
|
-- 처리 경로
|
||||||
|
data_path VARCHAR(10), -- 'CACHE' | 'DB' | 'HYBRID'
|
||||||
|
cache_hit_days INTEGER DEFAULT 0,
|
||||||
|
db_query_days INTEGER DEFAULT 0,
|
||||||
|
db_conn_total INTEGER DEFAULT 0,
|
||||||
|
|
||||||
|
-- 결과 통계
|
||||||
|
unique_vessels INTEGER DEFAULT 0,
|
||||||
|
total_tracks INTEGER DEFAULT 0,
|
||||||
|
total_points INTEGER DEFAULT 0,
|
||||||
|
points_after_simplify INTEGER DEFAULT 0,
|
||||||
|
total_chunks INTEGER DEFAULT 0,
|
||||||
|
response_bytes BIGINT DEFAULT 0,
|
||||||
|
|
||||||
|
-- 성능
|
||||||
|
elapsed_ms BIGINT DEFAULT 0,
|
||||||
|
db_query_ms BIGINT DEFAULT 0,
|
||||||
|
simplify_ms BIGINT DEFAULT 0,
|
||||||
|
backpressure_events INTEGER DEFAULT 0,
|
||||||
|
|
||||||
|
-- 결과 상태
|
||||||
|
status VARCHAR(20) DEFAULT 'COMPLETED' -- 'COMPLETED' | 'CANCELLED' | 'ERROR' | 'TIMEOUT'
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_query_metrics_created ON signal.t_query_metrics(created_at);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_query_metrics_type ON signal.t_query_metrics(query_type, created_at);
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user