From 820ed75585ae75578203dd74a53dfb5c9ee527e0 Mon Sep 17 00:00:00 2001 From: htlee Date: Thu, 16 Apr 2026 14:31:02 +0900 Subject: [PATCH] =?UTF-8?q?feat(backend):=20/api/analysis=20stats=20+=20ge?= =?UTF-8?q?ar-detections=20=EC=97=94=EB=93=9C=ED=8F=AC=EC=9D=B8=ED=8A=B8?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 중국어선 감시 화면의 실데이터 연동을 위해 기존 /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 에서는 유지) --- .../analysis/AnalysisStatsResponse.java | 27 ++++++++ .../analysis/GearDetectionResponse.java | 38 +++++++++++ .../analysis/VesselAnalysisController.java | 38 ++++++++++- .../analysis/VesselAnalysisRepository.java | 58 +++++++++++++++++ .../analysis/VesselAnalysisResponse.java | 11 ++++ .../domain/analysis/VesselAnalysisResult.java | 5 ++ .../analysis/VesselAnalysisService.java | 65 ++++++++++++++++++- 7 files changed, 240 insertions(+), 2 deletions(-) create mode 100644 backend/src/main/java/gc/mda/kcg/domain/analysis/AnalysisStatsResponse.java create mode 100644 backend/src/main/java/gc/mda/kcg/domain/analysis/GearDetectionResponse.java diff --git a/backend/src/main/java/gc/mda/kcg/domain/analysis/AnalysisStatsResponse.java b/backend/src/main/java/gc/mda/kcg/domain/analysis/AnalysisStatsResponse.java new file mode 100644 index 0000000..d9dceec --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/analysis/AnalysisStatsResponse.java @@ -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 +) { +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/analysis/GearDetectionResponse.java b/backend/src/main/java/gc/mda/kcg/domain/analysis/GearDetectionResponse.java new file mode 100644 index 0000000..bc0da6a --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/analysis/GearDetectionResponse.java @@ -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 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() + ); + } +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisController.java b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisController.java index ae0318b..71e245f 100644 --- a/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisController.java +++ b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisController.java @@ -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 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 포함). */ diff --git a/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisRepository.java b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisRepository.java index 4b54e48..b6448c6 100644 --- a/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisRepository.java +++ b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisRepository.java @@ -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 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 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 aggregateStats( + @Param("after") OffsetDateTime after, + @Param("mmsiPrefix") String mmsiPrefix); } diff --git a/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisResponse.java b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisResponse.java index c7ec691..ce81ec7 100644 --- a/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisResponse.java +++ b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisResponse.java @@ -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 violationCategories, // features Map 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() ); } diff --git a/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisResult.java b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisResult.java index cedc132..c9db0b8 100644 --- a/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisResult.java +++ b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisResult.java @@ -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 violationCategories; + // features JSONB @JdbcTypeCode(SqlTypes.JSON) @Column(name = "features", columnDefinition = "jsonb") diff --git a/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisService.java b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisService.java index 79a6555..a02f264 100644 --- a/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisService.java +++ b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisService.java @@ -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 getAnalysisResults( String mmsi, String zoneCode, String riskLevel, Boolean isDark, + String mmsiPrefix, Integer minRiskScore, BigDecimal minFishingPct, OffsetDateTime after, Pageable pageable ) { - Specification spec = Specification.where(null); + Specification 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 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 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 row, String key) { + Object v = row.get(key); + if (v == null) return 0L; + return ((Number) v).longValue(); + } + + private static BigDecimal bigDecimalOf(Map 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()); + } + /** * 특정 선박 최신 분석 결과. */