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 new file mode 100644 index 0000000..a9f0c79 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisController.java @@ -0,0 +1,99 @@ +package gc.mda.kcg.domain.analysis; + +import gc.mda.kcg.permission.annotation.RequirePermission; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.web.bind.annotation.*; + +import java.time.OffsetDateTime; +import java.util.List; + +/** + * vessel_analysis_results 직접 조회 API. + * prediction이 kcgaidb에 저장한 분석 결과를 프론트엔드에 직접 제공. + * 기존 iran proxy와 별도 경로 (/api/analysis/*). + */ +@RestController +@RequestMapping("/api/analysis") +@RequiredArgsConstructor +public class VesselAnalysisController { + + private final VesselAnalysisService service; + + /** + * 분석 결과 목록 조회 (필터 + 페이징). + * 기본: 최근 1시간 내 결과. + */ + @GetMapping("/vessels") + @RequirePermission(resource = "detection", operation = "READ") + public Page listVessels( + @RequestParam(required = false) String mmsi, + @RequestParam(required = false) String zoneCode, + @RequestParam(required = false) String riskLevel, + @RequestParam(required = false) Boolean isDark, + @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, + PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "analyzedAt")) + ).map(VesselAnalysisResponse::from); + } + + /** + * 특정 선박 최신 분석 결과 (features 포함). + */ + @GetMapping("/vessels/{mmsi}") + @RequirePermission(resource = "detection", operation = "READ") + public VesselAnalysisResponse getLatest(@PathVariable String mmsi) { + return VesselAnalysisResponse.from(service.getLatestByMmsi(mmsi)); + } + + /** + * 특정 선박 분석 이력 (기본 24시간). + */ + @GetMapping("/vessels/{mmsi}/history") + @RequirePermission(resource = "detection", operation = "READ") + public List getHistory( + @PathVariable String mmsi, + @RequestParam(defaultValue = "24") int hours + ) { + return service.getHistory(mmsi, hours).stream() + .map(VesselAnalysisResponse::from) + .toList(); + } + + /** + * 다크 베셀 목록 (최신 분석, MMSI 중복 제거). + */ + @GetMapping("/dark") + @RequirePermission(resource = "detection:dark-vessel", operation = "READ") + public Page listDarkVessels( + @RequestParam(defaultValue = "1") int hours, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "50") int size + ) { + return service.getDarkVessels(hours, + PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "riskScore")) + ).map(VesselAnalysisResponse::from); + } + + /** + * 환적 의심 목록 (최신 분석, MMSI 중복 제거). + */ + @GetMapping("/transship") + @RequirePermission(resource = "detection", operation = "READ") + public Page listTransshipSuspects( + @RequestParam(defaultValue = "1") int hours, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "50") int size + ) { + return service.getTransshipSuspects(hours, + PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "riskScore")) + ).map(VesselAnalysisResponse::from); + } +} 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 new file mode 100644 index 0000000..4b54e48 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisRepository.java @@ -0,0 +1,60 @@ +package gc.mda.kcg.domain.analysis; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.OffsetDateTime; +import java.util.List; +import java.util.Optional; + +/** + * vessel_analysis_results 읽기 전용 Repository. + */ +public interface VesselAnalysisRepository + extends JpaRepository, JpaSpecificationExecutor { + + /** + * 특정 선박의 최신 분석 결과. + */ + Optional findTopByMmsiOrderByAnalyzedAtDesc(String mmsi); + + /** + * 특정 선박의 분석 이력 (시간 범위). + */ + List findByMmsiAndAnalyzedAtAfterOrderByAnalyzedAtDesc( + String mmsi, OffsetDateTime after); + + /** + * 다크 베셀 목록 (최근 분석 결과, MMSI 중복 제거). + */ + @Query(""" + SELECT v FROM VesselAnalysisResult v + WHERE v.isDark = true AND v.analyzedAt > :after + AND v.analyzedAt = ( + SELECT MAX(v2.analyzedAt) FROM VesselAnalysisResult v2 + WHERE v2.mmsi = v.mmsi AND v2.analyzedAt > :after + ) + ORDER BY v.riskScore DESC + """) + Page findLatestDarkVessels( + @Param("after") OffsetDateTime after, Pageable pageable); + + /** + * 환적 의심 목록 (최근 분석 결과, MMSI 중복 제거). + */ + @Query(""" + SELECT v FROM VesselAnalysisResult v + WHERE v.transshipSuspect = true AND v.analyzedAt > :after + AND v.analyzedAt = ( + SELECT MAX(v2.analyzedAt) FROM VesselAnalysisResult v2 + WHERE v2.mmsi = v.mmsi AND v2.analyzedAt > :after + ) + ORDER BY v.riskScore DESC + """) + Page findLatestTransshipSuspects( + @Param("after") OffsetDateTime after, Pageable pageable); +} 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 new file mode 100644 index 0000000..c7ec691 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisResponse.java @@ -0,0 +1,84 @@ +package gc.mda.kcg.domain.analysis; + +import java.math.BigDecimal; +import java.time.OffsetDateTime; +import java.util.Map; + +/** + * vessel_analysis_results 응답 DTO. + * 프론트엔드에서 필요한 핵심 필드만 포함. + */ +public record VesselAnalysisResponse( + Long id, + String mmsi, + OffsetDateTime analyzedAt, + // 분류 + String vesselType, + BigDecimal confidence, + BigDecimal fishingPct, + String season, + // 위치 + Double lat, + Double lon, + String zoneCode, + BigDecimal distToBaselineNm, + // 행동 + String activityState, + // 위협 + Boolean isDark, + Integer gapDurationMin, + String darkPattern, + BigDecimal spoofingScore, + Integer speedJumpCount, + // 환적 + Boolean transshipSuspect, + String transshipPairMmsi, + Integer transshipDurationMin, + // 선단 + Integer fleetClusterId, + String fleetRole, + Boolean fleetIsLeader, + // 위험도 + Integer riskScore, + String riskLevel, + // 확장 + String gearCode, + String gearJudgment, + String permitStatus, + // features + Map features +) { + public static VesselAnalysisResponse from(VesselAnalysisResult e) { + return new VesselAnalysisResponse( + e.getId(), + e.getMmsi(), + e.getAnalyzedAt(), + e.getVesselType(), + e.getConfidence(), + e.getFishingPct(), + e.getSeason(), + e.getLat(), + e.getLon(), + e.getZoneCode(), + e.getDistToBaselineNm(), + e.getActivityState(), + e.getIsDark(), + e.getGapDurationMin(), + e.getDarkPattern(), + e.getSpoofingScore(), + e.getSpeedJumpCount(), + e.getTransshipSuspect(), + e.getTransshipPairMmsi(), + e.getTransshipDurationMin(), + e.getFleetClusterId(), + e.getFleetRole(), + e.getFleetIsLeader(), + e.getRiskScore(), + e.getRiskLevel(), + e.getGearCode(), + e.getGearJudgment(), + e.getPermitStatus(), + 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 new file mode 100644 index 0000000..cedc132 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisResult.java @@ -0,0 +1,135 @@ +package gc.mda.kcg.domain.analysis; + +import jakarta.persistence.*; +import lombok.*; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; + +import java.math.BigDecimal; +import java.time.OffsetDateTime; +import java.util.Map; + +/** + * vessel_analysis_results 읽기 전용 Entity. + * prediction 엔진이 5분 주기로 INSERT, 백엔드는 READ만 수행. + * + * DB PK는 (id, analyzed_at) 복합키(파티션)이지만, + * BIGSERIAL id가 전역 유니크이므로 JPA에서는 id만 @Id로 매핑. + */ +@Entity +@Table(name = "vessel_analysis_results", schema = "kcg") +@Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) +public class VesselAnalysisResult { + + @Id + private Long id; + + @Column(name = "mmsi", nullable = false, length = 20) + private String mmsi; + + @Column(name = "analyzed_at", nullable = false) + private OffsetDateTime analyzedAt; + + // 분류 + @Column(name = "vessel_type", length = 30) + private String vesselType; + + @Column(name = "confidence", precision = 5, scale = 4) + private BigDecimal confidence; + + @Column(name = "fishing_pct", precision = 5, scale = 4) + private BigDecimal fishingPct; + + @Column(name = "cluster_id") + private Integer clusterId; + + @Column(name = "season", length = 20) + private String season; + + // 위치 + @Column(name = "lat") + private Double lat; + + @Column(name = "lon") + private Double lon; + + @Column(name = "zone_code", length = 30) + private String zoneCode; + + @Column(name = "dist_to_baseline_nm", precision = 8, scale = 2) + private BigDecimal distToBaselineNm; + + // 행동 분석 + @Column(name = "activity_state", length = 20) + private String activityState; + + @Column(name = "ucaf_score", precision = 5, scale = 4) + private BigDecimal ucafScore; + + @Column(name = "ucft_score", precision = 5, scale = 4) + private BigDecimal ucftScore; + + // 위협 탐지 + @Column(name = "is_dark") + private Boolean isDark; + + @Column(name = "gap_duration_min") + private Integer gapDurationMin; + + @Column(name = "dark_pattern", length = 30) + private String darkPattern; + + @Column(name = "spoofing_score", precision = 5, scale = 4) + private BigDecimal spoofingScore; + + @Column(name = "bd09_offset_m", precision = 8, scale = 2) + private BigDecimal bd09OffsetM; + + @Column(name = "speed_jump_count") + private Integer speedJumpCount; + + // 환적 + @Column(name = "transship_suspect") + private Boolean transshipSuspect; + + @Column(name = "transship_pair_mmsi", length = 20) + private String transshipPairMmsi; + + @Column(name = "transship_duration_min") + private Integer transshipDurationMin; + + // 선단 + @Column(name = "fleet_cluster_id") + private Integer fleetClusterId; + + @Column(name = "fleet_role", length = 20) + private String fleetRole; + + @Column(name = "fleet_is_leader") + private Boolean fleetIsLeader; + + // 위험도 + @Column(name = "risk_score") + private Integer riskScore; + + @Column(name = "risk_level", length = 20) + private String riskLevel; + + // 확장 + @Column(name = "gear_code", length = 20) + private String gearCode; + + @Column(name = "gear_judgment", length = 30) + private String gearJudgment; + + @Column(name = "permit_status", length = 20) + private String permitStatus; + + // features JSONB + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "features", columnDefinition = "jsonb") + private Map features; + + @Column(name = "created_at") + private OffsetDateTime createdAt; +} 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 new file mode 100644 index 0000000..79a6555 --- /dev/null +++ b/backend/src/main/java/gc/mda/kcg/domain/analysis/VesselAnalysisService.java @@ -0,0 +1,83 @@ +package gc.mda.kcg.domain.analysis; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.domain.Specification; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.OffsetDateTime; +import java.util.List; + +/** + * vessel_analysis_results 직접 조회 서비스. + * prediction이 write한 분석 결과를 프론트엔드에 제공. + */ +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class VesselAnalysisService { + + private final VesselAnalysisRepository repository; + + /** + * 분석 결과 목록 조회 (동적 필터). + */ + public Page getAnalysisResults( + String mmsi, String zoneCode, String riskLevel, Boolean isDark, + OffsetDateTime after, Pageable pageable + ) { + Specification spec = Specification.where(null); + + if (after != null) { + spec = spec.and((root, query, cb) -> cb.greaterThan(root.get("analyzedAt"), after)); + } + if (mmsi != null && !mmsi.isBlank()) { + spec = spec.and((root, query, cb) -> cb.equal(root.get("mmsi"), mmsi)); + } + if (zoneCode != null && !zoneCode.isBlank()) { + spec = spec.and((root, query, cb) -> cb.equal(root.get("zoneCode"), zoneCode)); + } + if (riskLevel != null && !riskLevel.isBlank()) { + spec = spec.and((root, query, cb) -> cb.equal(root.get("riskLevel"), riskLevel)); + } + if (isDark != null && isDark) { + spec = spec.and((root, query, cb) -> cb.isTrue(root.get("isDark"))); + } + + return repository.findAll(spec, pageable); + } + + /** + * 특정 선박 최신 분석 결과. + */ + public VesselAnalysisResult getLatestByMmsi(String mmsi) { + return repository.findTopByMmsiOrderByAnalyzedAtDesc(mmsi) + .orElseThrow(() -> new IllegalArgumentException("ANALYSIS_NOT_FOUND: " + mmsi)); + } + + /** + * 특정 선박 분석 이력 (시간 범위). + */ + public List getHistory(String mmsi, int hours) { + OffsetDateTime after = OffsetDateTime.now().minusHours(hours); + return repository.findByMmsiAndAnalyzedAtAfterOrderByAnalyzedAtDesc(mmsi, after); + } + + /** + * 다크 베셀 목록 (최신 분석, MMSI 중복 제거). + */ + public Page getDarkVessels(int hours, Pageable pageable) { + OffsetDateTime after = OffsetDateTime.now().minusHours(hours); + return repository.findLatestDarkVessels(after, pageable); + } + + /** + * 환적 의심 목록 (최신 분석, MMSI 중복 제거). + */ + public Page getTransshipSuspects(int hours, Pageable pageable) { + OffsetDateTime after = OffsetDateTime.now().minusHours(hours); + return repository.findLatestTransshipSuspects(after, pageable); + } +} diff --git a/backend/src/main/java/gc/mda/kcg/domain/enforcement/EnforcementController.java b/backend/src/main/java/gc/mda/kcg/domain/enforcement/EnforcementController.java index dd26dfc..92fefb8 100644 --- a/backend/src/main/java/gc/mda/kcg/domain/enforcement/EnforcementController.java +++ b/backend/src/main/java/gc/mda/kcg/domain/enforcement/EnforcementController.java @@ -32,9 +32,10 @@ public class EnforcementController { @RequirePermission(resource = "enforcement:enforcement-history", operation = "READ") public Page listRecords( @RequestParam(required = false) String violationType, + @RequestParam(required = false) String vesselMmsi, Pageable pageable ) { - return service.listRecords(violationType, pageable); + return service.listRecords(violationType, vesselMmsi, pageable); } /** diff --git a/backend/src/main/java/gc/mda/kcg/domain/enforcement/EnforcementService.java b/backend/src/main/java/gc/mda/kcg/domain/enforcement/EnforcementService.java index 27464be..cf40432 100644 --- a/backend/src/main/java/gc/mda/kcg/domain/enforcement/EnforcementService.java +++ b/backend/src/main/java/gc/mda/kcg/domain/enforcement/EnforcementService.java @@ -32,7 +32,10 @@ public class EnforcementService { // 단속 이력 // ======================================================================== - public Page listRecords(String violationType, Pageable pageable) { + public Page listRecords(String violationType, String vesselMmsi, Pageable pageable) { + if (vesselMmsi != null && !vesselMmsi.isBlank()) { + return recordRepository.findByVesselMmsiOrderByEnforcedAtDesc(vesselMmsi, pageable); + } if (violationType != null && !violationType.isBlank()) { return recordRepository.findByViolationType(violationType, pageable); } diff --git a/backend/src/main/java/gc/mda/kcg/domain/enforcement/repository/EnforcementRecordRepository.java b/backend/src/main/java/gc/mda/kcg/domain/enforcement/repository/EnforcementRecordRepository.java index 749259e..541e34a 100644 --- a/backend/src/main/java/gc/mda/kcg/domain/enforcement/repository/EnforcementRecordRepository.java +++ b/backend/src/main/java/gc/mda/kcg/domain/enforcement/repository/EnforcementRecordRepository.java @@ -8,4 +8,5 @@ import org.springframework.data.jpa.repository.JpaRepository; public interface EnforcementRecordRepository extends JpaRepository { Page findAllByOrderByEnforcedAtDesc(Pageable pageable); Page findByViolationType(String violationType, Pageable pageable); + Page findByVesselMmsiOrderByEnforcedAtDesc(String vesselMmsi, Pageable pageable); } diff --git a/backend/src/main/java/gc/mda/kcg/domain/event/PredictionEvent.java b/backend/src/main/java/gc/mda/kcg/domain/event/PredictionEvent.java index cbef145..0292096 100644 --- a/backend/src/main/java/gc/mda/kcg/domain/event/PredictionEvent.java +++ b/backend/src/main/java/gc/mda/kcg/domain/event/PredictionEvent.java @@ -7,6 +7,7 @@ import org.hibernate.type.SqlTypes; import java.math.BigDecimal; import java.time.OffsetDateTime; +import java.util.Map; import java.util.UUID; /** @@ -93,6 +94,10 @@ public class PredictionEvent { @Column(name = "dedup_key", length = 200) private String dedupKey; + @JdbcTypeCode(SqlTypes.JSON) + @Column(name = "features", columnDefinition = "jsonb") + private Map features; + @Column(name = "created_at", nullable = false) private OffsetDateTime createdAt; diff --git a/backend/src/main/resources/db/migration/V018__prediction_event_features.sql b/backend/src/main/resources/db/migration/V018__prediction_event_features.sql new file mode 100644 index 0000000..9ec3322 --- /dev/null +++ b/backend/src/main/resources/db/migration/V018__prediction_event_features.sql @@ -0,0 +1,11 @@ +-- ============================================================ +-- V018: prediction_events에 features JSONB 컬럼 추가 +-- event_generator가 분석 결과의 핵심 특성(dark_tier, transship_score 등)을 +-- 이벤트와 함께 저장하여 프론트엔드에서 직접 활용할 수 있도록 한다. +-- ============================================================ + +ALTER TABLE kcg.prediction_events + ADD COLUMN IF NOT EXISTS features JSONB; + +COMMENT ON COLUMN kcg.prediction_events.features IS + '분석 결과 핵심 특성 (dark_tier, dark_suspicion_score, transship_tier, transship_score 등)'; diff --git a/prediction/output/event_generator.py b/prediction/output/event_generator.py index e1ad2d4..3b1f5d0 100644 --- a/prediction/output/event_generator.py +++ b/prediction/output/event_generator.py @@ -6,6 +6,7 @@ dedup: 동일 mmsi + category + 윈도우 내 중복 방지. """ import hashlib +import json import logging from datetime import datetime, timedelta, timezone from typing import Optional @@ -214,6 +215,10 @@ def run_event_generator(analysis_results: list[dict]) -> dict: event_uid = _make_event_uid(now, seq) seq += 1 + # features 추출: 이벤트에 연관된 핵심 특성만 저장 + raw_features = result.get('features') + features_json = json.dumps(raw_features, ensure_ascii=False) if raw_features else None + events_to_insert.append(( event_uid, now, # occurred_at @@ -233,6 +238,7 @@ def run_event_generator(analysis_results: list[dict]) -> dict: result.get('confidence') or result.get('risk_score', 0) / 100.0, 'NEW', # status dedup_key, + features_json, )) generated += 1 # break 제거: 한 분석결과가 여러 룰에 매칭되면 모두 생성 @@ -244,7 +250,7 @@ def run_event_generator(analysis_results: list[dict]) -> dict: f"""INSERT INTO {EVENTS_TABLE} (event_uid, occurred_at, level, category, title, detail, vessel_mmsi, vessel_name, area_name, zone_code, lat, lon, speed_kn, - source_type, source_ref_id, ai_confidence, status, dedup_key) + source_type, source_ref_id, ai_confidence, status, dedup_key, features) VALUES %s ON CONFLICT (event_uid) DO NOTHING""", events_to_insert,