feat(backend): /api/analysis stats + gear-detections 엔드포인트 추가

중국어선 감시 화면의 실데이터 연동을 위해 기존 /api/analysis 에 집계/
필터 기능을 보강한다.

- VesselAnalysisResult 엔티티에 violation_categories TEXT[] 매핑 추가
- VesselAnalysisResponse 에 violationCategories / bd09OffsetM /
  ucafScore / ucftScore / clusterId 5개 필드 노출
- /api/analysis/vessels 에 mmsiPrefix / minRiskScore / minFishingPct
  필터 파라미터 추가
- /api/analysis/stats: MMSI별 최신 row 기준 단일 쿼리 COUNT FILTER
  집계 (total/dark/spoofing/transship/risk별/zone별/fishing/avgRisk)
- /api/analysis/gear-detections: gear_code/judgment NOT NULL 인 row
  MMSI 중복 제거 목록. 어구/어망 판별 탭 '자동탐지 결과' 섹션 연동용
- deprecated 스텁 /api/vessel-analysis 는 프론트 호출 제거 후 다음
  릴리즈에서 삭제 예정 (이번 PR 에서는 유지)
This commit is contained in:
htlee 2026-04-16 14:31:02 +09:00
부모 14eb4c7ea3
커밋 820ed75585
7개의 변경된 파일240개의 추가작업 그리고 2개의 파일을 삭제

파일 보기

@ -0,0 +1,27 @@
package gc.mda.kcg.domain.analysis;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
/**
* vessel_analysis_results 집계 응답.
* MMSI별 최신 row 기준으로 단일 쿼리 COUNT FILTER 집계한다.
*/
public record AnalysisStatsResponse(
long total,
long darkCount,
long spoofingCount,
long transshipCount,
long criticalCount,
long highCount,
long mediumCount,
long lowCount,
long territorialCount,
long contiguousCount,
long eezCount,
long fishingCount,
BigDecimal avgRiskScore,
OffsetDateTime windowStart,
OffsetDateTime windowEnd
) {
}

파일 보기

@ -0,0 +1,38 @@
package gc.mda.kcg.domain.analysis;
import java.time.OffsetDateTime;
import java.util.List;
/**
* prediction 자동 어구 탐지 결과 응답 DTO.
* gear_code / gear_judgment NOT NULL row의 핵심 필드만 노출.
*/
public record GearDetectionResponse(
Long id,
String mmsi,
OffsetDateTime analyzedAt,
String vesselType,
String gearCode,
String gearJudgment,
String permitStatus,
String riskLevel,
Integer riskScore,
String zoneCode,
List<String> violationCategories
) {
public static GearDetectionResponse from(VesselAnalysisResult e) {
return new GearDetectionResponse(
e.getId(),
e.getMmsi(),
e.getAnalyzedAt(),
e.getVesselType(),
e.getGearCode(),
e.getGearJudgment(),
e.getPermitStatus(),
e.getRiskLevel(),
e.getRiskScore(),
e.getZoneCode(),
e.getViolationCategories()
);
}
}

파일 보기

@ -7,6 +7,7 @@ import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.web.bind.annotation.*;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
import java.util.List;
@ -33,17 +34,52 @@ public class VesselAnalysisController {
@RequestParam(required = false) String zoneCode,
@RequestParam(required = false) String riskLevel,
@RequestParam(required = false) Boolean isDark,
@RequestParam(required = false) String mmsiPrefix,
@RequestParam(required = false) Integer minRiskScore,
@RequestParam(required = false) BigDecimal minFishingPct,
@RequestParam(defaultValue = "1") int hours,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "50") int size
) {
OffsetDateTime after = OffsetDateTime.now().minusHours(hours);
return service.getAnalysisResults(
mmsi, zoneCode, riskLevel, isDark, after,
mmsi, zoneCode, riskLevel, isDark,
mmsiPrefix, minRiskScore, minFishingPct, after,
PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "analyzedAt"))
).map(VesselAnalysisResponse::from);
}
/**
* MMSI별 최신 row 기준 집계 (단일 쿼리 COUNT FILTER).
* - hours: 윈도우 (기본 1시간)
* - mmsiPrefix: '412' 같은 MMSI prefix 필터 (선택)
*/
@GetMapping("/stats")
@RequirePermission(resource = "detection:dark-vessel", operation = "READ")
public AnalysisStatsResponse getStats(
@RequestParam(defaultValue = "1") int hours,
@RequestParam(required = false) String mmsiPrefix
) {
return service.getStats(hours, mmsiPrefix);
}
/**
* prediction 자동 어구 탐지 결과 목록.
* gear_code/gear_judgment NOT NULL row만 MMSI 중복 제거 반환.
*/
@GetMapping("/gear-detections")
@RequirePermission(resource = "detection:dark-vessel", operation = "READ")
public Page<GearDetectionResponse> listGearDetections(
@RequestParam(defaultValue = "1") int hours,
@RequestParam(required = false) String mmsiPrefix,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "50") int size
) {
return service.getGearDetections(hours, mmsiPrefix,
PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "analyzedAt"))
).map(GearDetectionResponse::from);
}
/**
* 특정 선박 최신 분석 결과 (features 포함).
*/

파일 보기

@ -9,6 +9,7 @@ import org.springframework.data.repository.query.Param;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/**
@ -57,4 +58,61 @@ public interface VesselAnalysisRepository
""")
Page<VesselAnalysisResult> findLatestTransshipSuspects(
@Param("after") OffsetDateTime after, Pageable pageable);
/**
* 어구 탐지 결과 목록 (gear_code/judgment NOT NULL, MMSI 중복 제거).
* mmsiPrefix null 이면 전체, 아니면 LIKE ':prefix%'.
*/
@Query("""
SELECT v FROM VesselAnalysisResult v
WHERE v.gearCode IS NOT NULL
AND v.gearJudgment IS NOT NULL
AND v.analyzedAt > :after
AND (:mmsiPrefix IS NULL OR v.mmsi LIKE CONCAT(:mmsiPrefix, '%'))
AND v.analyzedAt = (
SELECT MAX(v2.analyzedAt) FROM VesselAnalysisResult v2
WHERE v2.mmsi = v.mmsi AND v2.analyzedAt > :after
)
ORDER BY v.analyzedAt DESC
""")
Page<VesselAnalysisResult> findLatestGearDetections(
@Param("after") OffsetDateTime after,
@Param("mmsiPrefix") String mmsiPrefix,
Pageable pageable);
/**
* MMSI별 최신 row 기준 집계 (단일 쿼리 COUNT FILTER).
* mmsiPrefix null 이면 전체.
* 반환 Map : total, dark_count, spoofing_count, transship_count,
* critical_count, high_count, medium_count, low_count,
* territorial_count, contiguous_count, eez_count,
* fishing_count, avg_risk_score
*/
@Query(value = """
WITH latest AS (
SELECT DISTINCT ON (mmsi) *
FROM kcg.vessel_analysis_results
WHERE analyzed_at > :after
AND (:mmsiPrefix IS NULL OR mmsi LIKE :mmsiPrefix || '%')
ORDER BY mmsi, analyzed_at DESC
)
SELECT
COUNT(*) AS total,
COUNT(*) FILTER (WHERE is_dark = TRUE) AS dark_count,
COUNT(*) FILTER (WHERE spoofing_score >= 0.3) AS spoofing_count,
COUNT(*) FILTER (WHERE transship_suspect = TRUE) AS transship_count,
COUNT(*) FILTER (WHERE risk_level = 'CRITICAL') AS critical_count,
COUNT(*) FILTER (WHERE risk_level = 'HIGH') AS high_count,
COUNT(*) FILTER (WHERE risk_level = 'MEDIUM') AS medium_count,
COUNT(*) FILTER (WHERE risk_level = 'LOW') AS low_count,
COUNT(*) FILTER (WHERE zone_code = 'TERRITORIAL_SEA') AS territorial_count,
COUNT(*) FILTER (WHERE zone_code = 'CONTIGUOUS_ZONE') AS contiguous_count,
COUNT(*) FILTER (WHERE zone_code = 'EEZ_OR_BEYOND') AS eez_count,
COUNT(*) FILTER (WHERE fishing_pct > 0.5) AS fishing_count,
COALESCE(AVG(risk_score), 0)::NUMERIC(5,2) AS avg_risk_score
FROM latest
""", nativeQuery = true)
Map<String, Object> aggregateStats(
@Param("after") OffsetDateTime after,
@Param("mmsiPrefix") String mmsiPrefix);
}

파일 보기

@ -2,6 +2,7 @@ package gc.mda.kcg.domain.analysis;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Map;
/**
@ -16,6 +17,7 @@ public record VesselAnalysisResponse(
String vesselType,
BigDecimal confidence,
BigDecimal fishingPct,
Integer clusterId,
String season,
// 위치
Double lat,
@ -24,11 +26,14 @@ public record VesselAnalysisResponse(
BigDecimal distToBaselineNm,
// 행동
String activityState,
BigDecimal ucafScore,
BigDecimal ucftScore,
// 위협
Boolean isDark,
Integer gapDurationMin,
String darkPattern,
BigDecimal spoofingScore,
BigDecimal bd09OffsetM,
Integer speedJumpCount,
// 환적
Boolean transshipSuspect,
@ -45,6 +50,7 @@ public record VesselAnalysisResponse(
String gearCode,
String gearJudgment,
String permitStatus,
List<String> violationCategories,
// features
Map<String, Object> features
) {
@ -56,16 +62,20 @@ public record VesselAnalysisResponse(
e.getVesselType(),
e.getConfidence(),
e.getFishingPct(),
e.getClusterId(),
e.getSeason(),
e.getLat(),
e.getLon(),
e.getZoneCode(),
e.getDistToBaselineNm(),
e.getActivityState(),
e.getUcafScore(),
e.getUcftScore(),
e.getIsDark(),
e.getGapDurationMin(),
e.getDarkPattern(),
e.getSpoofingScore(),
e.getBd09OffsetM(),
e.getSpeedJumpCount(),
e.getTransshipSuspect(),
e.getTransshipPairMmsi(),
@ -78,6 +88,7 @@ public record VesselAnalysisResponse(
e.getGearCode(),
e.getGearJudgment(),
e.getPermitStatus(),
e.getViolationCategories(),
e.getFeatures()
);
}

파일 보기

@ -7,6 +7,7 @@ import org.hibernate.type.SqlTypes;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Map;
/**
@ -125,6 +126,10 @@ public class VesselAnalysisResult {
@Column(name = "permit_status", length = 20)
private String permitStatus;
@JdbcTypeCode(SqlTypes.ARRAY)
@Column(name = "violation_categories", columnDefinition = "text[]")
private List<String> violationCategories;
// features JSONB
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "features", columnDefinition = "jsonb")

파일 보기

@ -7,8 +7,10 @@ import org.springframework.data.jpa.domain.Specification;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Map;
/**
* vessel_analysis_results 직접 조회 서비스.
@ -26,9 +28,10 @@ public class VesselAnalysisService {
*/
public Page<VesselAnalysisResult> getAnalysisResults(
String mmsi, String zoneCode, String riskLevel, Boolean isDark,
String mmsiPrefix, Integer minRiskScore, BigDecimal minFishingPct,
OffsetDateTime after, Pageable pageable
) {
Specification<VesselAnalysisResult> spec = Specification.where(null);
Specification<VesselAnalysisResult> spec = (root, query, cb) -> cb.conjunction();
if (after != null) {
spec = spec.and((root, query, cb) -> cb.greaterThan(root.get("analyzedAt"), after));
@ -36,6 +39,10 @@ public class VesselAnalysisService {
if (mmsi != null && !mmsi.isBlank()) {
spec = spec.and((root, query, cb) -> cb.equal(root.get("mmsi"), mmsi));
}
if (mmsiPrefix != null && !mmsiPrefix.isBlank()) {
final String prefix = mmsiPrefix;
spec = spec.and((root, query, cb) -> cb.like(root.get("mmsi"), prefix + "%"));
}
if (zoneCode != null && !zoneCode.isBlank()) {
spec = spec.and((root, query, cb) -> cb.equal(root.get("zoneCode"), zoneCode));
}
@ -45,10 +52,66 @@ public class VesselAnalysisService {
if (isDark != null && isDark) {
spec = spec.and((root, query, cb) -> cb.isTrue(root.get("isDark")));
}
if (minRiskScore != null) {
spec = spec.and((root, query, cb) -> cb.greaterThanOrEqualTo(root.get("riskScore"), minRiskScore));
}
if (minFishingPct != null) {
spec = spec.and((root, query, cb) -> cb.greaterThan(root.get("fishingPct"), minFishingPct));
}
return repository.findAll(spec, pageable);
}
/**
* MMSI별 최신 row 기준 집계 (단일 쿼리).
*/
public AnalysisStatsResponse getStats(int hours, String mmsiPrefix) {
OffsetDateTime windowEnd = OffsetDateTime.now();
OffsetDateTime windowStart = windowEnd.minusHours(hours);
String prefix = (mmsiPrefix != null && !mmsiPrefix.isBlank()) ? mmsiPrefix : null;
Map<String, Object> row = repository.aggregateStats(windowStart, prefix);
return new AnalysisStatsResponse(
longOf(row, "total"),
longOf(row, "dark_count"),
longOf(row, "spoofing_count"),
longOf(row, "transship_count"),
longOf(row, "critical_count"),
longOf(row, "high_count"),
longOf(row, "medium_count"),
longOf(row, "low_count"),
longOf(row, "territorial_count"),
longOf(row, "contiguous_count"),
longOf(row, "eez_count"),
longOf(row, "fishing_count"),
bigDecimalOf(row, "avg_risk_score"),
windowStart,
windowEnd
);
}
/**
* prediction 자동 어구 탐지 결과 목록.
*/
public Page<VesselAnalysisResult> getGearDetections(int hours, String mmsiPrefix, Pageable pageable) {
OffsetDateTime after = OffsetDateTime.now().minusHours(hours);
String prefix = (mmsiPrefix != null && !mmsiPrefix.isBlank()) ? mmsiPrefix : null;
return repository.findLatestGearDetections(after, prefix, pageable);
}
private static long longOf(Map<String, Object> row, String key) {
Object v = row.get(key);
if (v == null) return 0L;
return ((Number) v).longValue();
}
private static BigDecimal bigDecimalOf(Map<String, Object> row, String key) {
Object v = row.get(key);
if (v == null) return BigDecimal.ZERO;
if (v instanceof BigDecimal bd) return bd;
return new BigDecimal(v.toString());
}
/**
* 특정 선박 최신 분석 결과.
*/