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:
부모
14eb4c7ea3
커밋
820ed75585
@ -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());
|
||||
}
|
||||
|
||||
/**
|
||||
* 특정 선박 최신 분석 결과.
|
||||
*/
|
||||
|
||||
불러오는 중...
Reference in New Issue
Block a user