From 14eb4c7ea3e5897e9f6f39d85119d26b89c26906 Mon Sep 17 00:00:00 2001 From: htlee Date: Thu, 16 Apr 2026 14:30:49 +0900 Subject: [PATCH 1/4] =?UTF-8?q?feat(prediction):=20vessel=5Fanalysis=5Fres?= =?UTF-8?q?ults=20=EC=97=90=20=EB=B6=84=EC=84=9D=20=EC=8B=9C=EC=A0=90=20la?= =?UTF-8?q?t/lon=20=EC=A0=80=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AnalysisResult 에 lat/lon 필드 + to_db_tuple 반영 + upsert_results SQL 컬럼 추가. 분류 파이프라인(last_row) / 경량 분석(all_positions) 두 경로 모두 분석 시점의 선박 위치를 함께 기록해 프론트 미니맵에서 특이운항 판별 위치를 실제 항적 위에 표시할 수 있게 한다. 배포 후 첫 사이클 8173/8173 lat/lon non-null 확인. --- prediction/db/kcgdb.py | 4 +++- prediction/models/result.py | 8 ++++++++ prediction/scheduler.py | 7 +++++++ 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/prediction/db/kcgdb.py b/prediction/db/kcgdb.py index 05d463d..7a18cae 100644 --- a/prediction/db/kcgdb.py +++ b/prediction/db/kcgdb.py @@ -72,7 +72,7 @@ def upsert_results(results: list['AnalysisResult']) -> int: insert_sql = """ INSERT INTO vessel_analysis_results ( mmsi, analyzed_at, vessel_type, confidence, fishing_pct, - cluster_id, season, zone_code, dist_to_baseline_nm, activity_state, + cluster_id, season, lat, lon, zone_code, dist_to_baseline_nm, activity_state, ucaf_score, ucft_score, is_dark, gap_duration_min, spoofing_score, bd09_offset_m, speed_jump_count, fleet_cluster_id, fleet_is_leader, fleet_role, @@ -88,6 +88,8 @@ def upsert_results(results: list['AnalysisResult']) -> int: fishing_pct = EXCLUDED.fishing_pct, cluster_id = EXCLUDED.cluster_id, season = EXCLUDED.season, + lat = EXCLUDED.lat, + lon = EXCLUDED.lon, zone_code = EXCLUDED.zone_code, dist_to_baseline_nm = EXCLUDED.dist_to_baseline_nm, activity_state = EXCLUDED.activity_state, diff --git a/prediction/models/result.py b/prediction/models/result.py index 91a22cb..833d1c2 100644 --- a/prediction/models/result.py +++ b/prediction/models/result.py @@ -20,6 +20,8 @@ class AnalysisResult: # ALGO 01: 위치 zone: str = 'EEZ_OR_BEYOND' dist_to_baseline_nm: float = 999.0 + lat: Optional[float] = None + lon: Optional[float] = None # ALGO 02: 활동 상태 activity_state: str = 'UNKNOWN' @@ -95,6 +97,10 @@ class AnalysisResult: safe_features = _sanitize(self.features) if self.features else {} + def _opt_f(v: object) -> Optional[float]: + """Optional float (None 유지).""" + return float(v) if v is not None else None + return ( str(self.mmsi), self.analyzed_at, # analyzed_at (PK 파티션키) @@ -103,6 +109,8 @@ class AnalysisResult: _f(self.fishing_pct), _i(self.cluster_id), str(self.season), + _opt_f(self.lat), + _opt_f(self.lon), str(self.zone), # → zone_code _f(self.dist_to_baseline_nm), str(self.activity_state), diff --git a/prediction/scheduler.py b/prediction/scheduler.py index b8906e5..91a786b 100644 --- a/prediction/scheduler.py +++ b/prediction/scheduler.py @@ -466,6 +466,9 @@ def run_analysis_cycle(): 'registered_fishery_code': registered_fishery_code or '', } + # 분석 시점의 선박 위치 — 특이운항 판별 근거 좌표로 DB에 저장 + lat_val = last_row.get('lat') + lon_val = last_row.get('lon') results.append(AnalysisResult( mmsi=mmsi, timestamp=ts, @@ -474,6 +477,8 @@ def run_analysis_cycle(): fishing_pct=c['fishing_pct'], cluster_id=fleet_info.get('cluster_id', -1), season=c['season'], + lat=float(lat_val) if lat_val is not None else None, + lon=float(lon_val) if lon_val is not None else None, zone=zone_info.get('zone', 'EEZ_OR_BEYOND'), dist_to_baseline_nm=zone_info.get('dist_from_baseline_nm', 999.0), activity_state=activity, @@ -615,6 +620,8 @@ def run_analysis_cycle(): vessel_type='UNKNOWN', confidence=0.0, fishing_pct=0.0, + lat=float(lat) if lat is not None else None, + lon=float(lon) if lon is not None else None, zone=zone_info.get('zone', 'EEZ_OR_BEYOND'), dist_to_baseline_nm=zone_info.get('dist_from_baseline_nm', 999.0), activity_state=state, -- 2.45.2 From 820ed75585ae75578203dd74a53dfb5c9ee527e0 Mon Sep 17 00:00:00 2001 From: htlee Date: Thu, 16 Apr 2026 14:31:02 +0900 Subject: [PATCH 2/4] =?UTF-8?q?feat(backend):=20/api/analysis=20stats=20+?= =?UTF-8?q?=20gear-detections=20=EC=97=94=EB=93=9C=ED=8F=AC=EC=9D=B8?= =?UTF-8?q?=ED=8A=B8=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()); + } + /** * 특정 선박 최신 분석 결과. */ -- 2.45.2 From d82eaf7e7951c0dacd6399586a6d5b98ef9735db Mon Sep 17 00:00:00 2001 From: htlee Date: Thu, 16 Apr 2026 14:31:26 +0900 Subject: [PATCH 3/4] =?UTF-8?q?feat(frontend):=20=EC=A4=91=EA=B5=AD?= =?UTF-8?q?=EC=96=B4=EC=84=A0=20=EA=B0=90=EC=8B=9C=20=EC=8B=A4=EB=8D=B0?= =?UTF-8?q?=EC=9D=B4=ED=84=B0=20=EC=97=B0=EB=8F=99=20+=20=ED=8A=B9?= =?UTF-8?q?=EC=9D=B4=EC=9A=B4=ED=95=AD=20=EB=AF=B8=EB=8B=88=EB=A7=B5/?= =?UTF-8?q?=ED=8C=90=EB=B3=84=20=ED=8C=A8=EB=84=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tab 1 AI 감시 대시보드 / Tab 2 환적탐지 / Tab 3 어구판별 3개 탭을 deprecated iran proxy 에서 자체 /api/analysis/* 로 전환하고, 특이운항 선박의 24h 항적과 판별 구간 상세를 지도와 패널로 제공한다. 서비스 계층 - analysisApi.ts 확장: getAnalysisStats / getAnalysisVessels(필터 3종) / getGearDetections 추가. VesselAnalysis 에 violationCategories / bd09OffsetM / ucafScore / ucftScore / clusterId 필드 노출 - analysisAdapter.ts: flat VesselAnalysis → nested VesselAnalysisItem 변환으로 기존 컴포넌트 재사용 - vesselAnalysisApi.ts fetchVesselAnalysis @deprecated 마킹 Tab 1 (ChinaFishing) - 서버 집계(stats) 기준 카운터 재구성. 중국어선 / Dark / 환적 / 고위험 모두 mmsiPrefix=412 로 서버 필터 - 선박 리스트 vessel_type UNKNOWN 인 경우 "중국어선" + "미분류" 로 표시 - 특이운항 row 클릭 → 아래 행에 미니맵 + 판별 패널 배치 - 관심영역 / VIIRS / 기상 / VTS 카드에 "데모 데이터" 뱃지. 비허가 / 제재 / 관심 탭 disabled + "준비중" 뱃지 Tab 2 (RealVesselAnalysis) - /analysis/dark / /analysis/transship / /analysis/vessels mode별 분기 - 상단 통계 카드를 items 클라이언트 집계로 전환해 하단 테이블과 정합 Tab 3 (GearIdentification) - 최하단 "최근 자동탐지 결과" 섹션 추가. row 클릭 시 상단 입력 폼 프리필 + 결과 패널에 자동탐지 근거 프리셋 특이운항 판별 시각화 (VesselMiniMap / VesselAnomalyPanel / vesselAnomaly 유틸) - 24h getAnalysisHistory 로드 → classifyAnomaly 로 DARK/SPOOFING/ TRANSSHIP/GEAR_VIOLATION/HIGH_RISK 5개 카테고리 판별. 좌표는 top-level lat/lon 우선, features.gap_start_* fallback - groupAnomaliesToSegments: 5분 주기 반복되는 동일 신호를 시작~종료 구간으로 병합 - 미니맵: 전체 궤적은 연한 파랑, segment 시간범위와 매칭되는 AIS 궤적 서브구간을 severity 색(CRITICAL 빨강 / WARNING 주황 / INFO 파랑) 으로 하이라이트. 이벤트 기준 좌표는 작은 흰 점 - 판별 패널: 시작→종료 · 지속 · N회 연속 감지 · 카테고리 뱃지 · 설명 --- .../src/features/detection/ChinaFishing.tsx | 306 ++++++++++++------ .../features/detection/GearIdentification.tsx | 254 ++++++++++++++- .../features/detection/RealVesselAnalysis.tsx | 83 +++-- .../components/VesselAnomalyPanel.tsx | 138 ++++++++ .../detection/components/VesselMiniMap.tsx | 259 +++++++++++++++ .../detection/components/vesselAnomaly.ts | 236 ++++++++++++++ frontend/src/services/analysisAdapter.ts | 57 ++++ frontend/src/services/analysisApi.ts | 147 ++++++--- frontend/src/services/vesselAnalysisApi.ts | 6 + 9 files changed, 1303 insertions(+), 183 deletions(-) create mode 100644 frontend/src/features/detection/components/VesselAnomalyPanel.tsx create mode 100644 frontend/src/features/detection/components/VesselMiniMap.tsx create mode 100644 frontend/src/features/detection/components/vesselAnomaly.ts create mode 100644 frontend/src/services/analysisAdapter.ts diff --git a/frontend/src/features/detection/ChinaFishing.tsx b/frontend/src/features/detection/ChinaFishing.tsx index 2ea7e8f..aacb328 100644 --- a/frontend/src/features/detection/ChinaFishing.tsx +++ b/frontend/src/features/detection/ChinaFishing.tsx @@ -3,9 +3,9 @@ import { Card, CardContent } from '@shared/components/ui/card'; import { Badge } from '@shared/components/ui/badge'; import { PageContainer } from '@shared/components/layout'; import { - Search, Ship, Clock, ChevronRight, ChevronLeft, Cloud, + Search, Clock, ChevronRight, ChevronLeft, Cloud, Eye, AlertTriangle, Radio, RotateCcw, - MapPin, Brain, RefreshCw, Crosshair as CrosshairIcon, Loader2 + Brain, RefreshCw, Crosshair as CrosshairIcon, Loader2 } from 'lucide-react'; import { formatDateTime } from '@shared/utils/dateFormat'; import { ALERT_LEVELS, getAlertLevelLabel, type AlertLevel } from '@shared/constants/alertLevels'; @@ -14,22 +14,23 @@ import { useSettingsStore } from '@stores/settingsStore'; import { useTranslation } from 'react-i18next'; import { GearIdentification } from './GearIdentification'; import { RealAllVessels, RealTransshipSuspects } from './RealVesselAnalysis'; +import { VesselMiniMap } from './components/VesselMiniMap'; +import { VesselAnomalyPanel } from './components/VesselAnomalyPanel'; +import { extractAnomalies, groupAnomaliesToSegments } from './components/vesselAnomaly'; import { PieChart as EcPieChart } from '@lib/charts'; +import type { VesselAnalysisItem } from '@/services/vesselAnalysisApi'; import { - fetchVesselAnalysis, - filterDarkVessels, - filterTransshipSuspects, - type VesselAnalysisItem, - type VesselAnalysisStats, -} from '@/services/vesselAnalysisApi'; + getAnalysisStats, + getAnalysisVessels, + getAnalysisHistory, + type AnalysisStats, + type VesselAnalysis, +} from '@/services/analysisApi'; +import { toVesselItem } from '@/services/analysisAdapter'; // ─── 중국 MMSI prefix ───────────── const CHINA_MMSI_PREFIX = '412'; -function isChinaVessel(mmsi: string): boolean { - return mmsi.startsWith(CHINA_MMSI_PREFIX); -} - // ─── 특이운항 선박 리스트 타입 ──────────────── type VesselStatus = '의심' | '양호' | '경고'; interface VesselItem { @@ -53,14 +54,16 @@ function deriveVesselStatus(score: number): VesselStatus { function mapToVesselItem(item: VesselAnalysisItem, idx: number): VesselItem { const score = item.algorithms.riskScore.score; + const vt = item.classification.vesselType; + const hasType = vt && vt !== 'UNKNOWN' && vt !== ''; return { id: String(idx + 1), mmsi: item.mmsi, callSign: '-', channel: '', source: 'AIS', - name: item.classification.vesselType || item.mmsi, - type: item.classification.fishingPct > 0.5 ? 'Fishing' : 'Cargo', + name: hasType ? vt : '중국어선', + type: item.classification.fishingPct > 0.5 ? 'Fishing' : hasType ? 'Cargo' : '미분류', country: 'China', status: deriveVesselStatus(score), riskPct: score, @@ -202,10 +205,14 @@ export function ChinaFishing() { const [mode, setMode] = useState<'dashboard' | 'transfer' | 'gear'>('dashboard'); const [vesselTab, setVesselTab] = useState<'특이운항' | '비허가 선박' | '제재 선박' | '관심 선박'>('특이운항'); const [statsTab, setStatsTab] = useState<'불법조업 통계' | '특이선박 통계' | '위험선박 통계'>('불법조업 통계'); + const [selectedMmsi, setSelectedMmsi] = useState(null); + const [history, setHistory] = useState([]); + const [historyLoading, setHistoryLoading] = useState(false); + const [historyError, setHistoryError] = useState(''); // API state - const [allItems, setAllItems] = useState([]); - const [apiStats, setApiStats] = useState(null); + const [topVessels, setTopVessels] = useState([]); + const [apiStats, setApiStats] = useState(null); const [serviceAvailable, setServiceAvailable] = useState(true); const [apiLoading, setApiLoading] = useState(false); const [apiError, setApiError] = useState(''); @@ -214,10 +221,18 @@ export function ChinaFishing() { setApiLoading(true); setApiError(''); try { - const res = await fetchVesselAnalysis(); - setServiceAvailable(res.serviceAvailable); - setAllItems(res.items); - setApiStats(res.stats); + const [stats, topPage] = await Promise.all([ + getAnalysisStats({ hours: 1, mmsiPrefix: CHINA_MMSI_PREFIX }), + getAnalysisVessels({ + hours: 1, + mmsiPrefix: CHINA_MMSI_PREFIX, + minRiskScore: 40, + size: 20, + }), + ]); + setApiStats(stats); + setTopVessels(topPage.content.map(toVesselItem)); + setServiceAvailable(true); } catch (e: unknown) { setApiError(e instanceof Error ? e.message : '데이터를 불러올 수 없습니다'); setServiceAvailable(false); @@ -228,55 +243,77 @@ export function ChinaFishing() { useEffect(() => { loadApi(); }, [loadApi]); - // 중국어선 필터 - const chinaVessels = useMemo( - () => allItems.filter((i) => isChinaVessel(i.mmsi)), - [allItems], + // 선박 선택 시 24h 분석 이력 로드 (미니맵 anomaly 포인트 + 판별 패널 공통 데이터) + useEffect(() => { + if (!selectedMmsi) { setHistory([]); setHistoryError(''); return; } + let cancelled = false; + setHistoryLoading(true); setHistoryError(''); + getAnalysisHistory(selectedMmsi, 24) + .then((rows) => { if (!cancelled) setHistory(rows); }) + .catch((e: unknown) => { + if (cancelled) return; + setHistory([]); + setHistoryError(e instanceof Error ? e.message : '이력 조회 실패'); + }) + .finally(() => { if (!cancelled) setHistoryLoading(false); }); + return () => { cancelled = true; }; + }, [selectedMmsi]); + + const anomalySegments = useMemo( + () => groupAnomaliesToSegments(extractAnomalies(history)), + [history], ); - const chinaDark = useMemo(() => filterDarkVessels(chinaVessels), [chinaVessels]); - const chinaTransship = useMemo(() => filterTransshipSuspects(chinaVessels), [chinaVessels]); + // ─ 파생 계산 (서버 집계 우선) ─ + // Tab 1 '분석 대상' 및 카운터는 apiStats 값이 SSOT. + // topVessels 는 minRiskScore=40 으로 필터된 상위 20척 (특이운항 리스트 전용). + const countersRow1 = useMemo(() => { + if (!apiStats) return []; + return [ + { label: '통합', count: apiStats.total, color: '#6b7280' }, + { label: 'AIS', count: apiStats.total, color: '#3b82f6' }, + { label: 'EEZ 내', count: apiStats.territorialCount + apiStats.contiguousCount, color: '#8b5cf6' }, + { label: '어업선', count: apiStats.fishingCount, color: '#10b981' }, + ]; + }, [apiStats]); - // 센서 카운터 (API 기반) - const countersRow1 = useMemo(() => [ - { label: '통합', count: allItems.length, color: '#6b7280' }, - { label: 'AIS', count: allItems.length, color: '#3b82f6' }, - { label: 'EEZ 내', count: allItems.filter((i) => i.algorithms.location.zone !== 'EEZ_OR_BEYOND').length, color: '#8b5cf6' }, - { label: '어업선', count: allItems.filter((i) => i.classification.fishingPct > 0.5).length, color: '#10b981' }, - ], [allItems]); + const countersRow2 = useMemo(() => { + if (!apiStats) return []; + return [ + { label: '중국어선', count: apiStats.total, color: '#f97316' }, + { label: 'Dark Vessel', count: apiStats.darkCount, color: '#ef4444' }, + { label: '환적 의심', count: apiStats.transshipCount, color: '#06b6d4' }, + { label: '고위험', count: apiStats.criticalCount + apiStats.highCount, color: '#ef4444' }, + ]; + }, [apiStats]); - const countersRow2 = useMemo(() => [ - { label: '중국어선', count: chinaVessels.length, color: '#f97316' }, - { label: 'Dark Vessel', count: chinaDark.length, color: '#ef4444' }, - { label: '환적 의심', count: chinaTransship.length, color: '#06b6d4' }, - { label: '고위험', count: chinaVessels.filter((i) => i.algorithms.riskScore.score >= 70).length, color: '#ef4444' }, - ], [chinaVessels, chinaDark, chinaTransship]); - - // 특이운항 선박 리스트 (중국어선 중 riskScore >= 40) + // 특이운항 선박 리스트 (서버에서 이미 riskScore >= 40 로 필터링된 상위 20척) const vesselList: VesselItem[] = useMemo( - () => chinaVessels - .filter((i) => i.algorithms.riskScore.score >= 40) - .sort((a, b) => b.algorithms.riskScore.score - a.algorithms.riskScore.score) - .slice(0, 20) - .map((item, idx) => mapToVesselItem(item, idx)), - [chinaVessels], + () => topVessels.map((item, idx) => mapToVesselItem(item, idx)), + [topVessels], ); - // 위험도별 분포 (도넛 차트용) + // 위험도별 분포 (도넛 차트용) — apiStats 기반 const riskDistribution = useMemo(() => { - const critical = chinaVessels.filter((i) => i.algorithms.riskScore.level === 'CRITICAL').length; - const high = chinaVessels.filter((i) => i.algorithms.riskScore.level === 'HIGH').length; - const medium = chinaVessels.filter((i) => i.algorithms.riskScore.level === 'MEDIUM').length; - const low = chinaVessels.filter((i) => i.algorithms.riskScore.level === 'LOW').length; - return { critical, high, medium, low, total: chinaVessels.length }; - }, [chinaVessels]); + if (!apiStats) return { critical: 0, high: 0, medium: 0, low: 0, total: 0 }; + return { + critical: apiStats.criticalCount, + high: apiStats.highCount, + medium: apiStats.mediumCount, + low: apiStats.lowCount, + total: apiStats.total, + }; + }, [apiStats]); - // 안전도 지수 계산 + // 안전도 지수 계산 (avgRiskScore 0~100 → 0~10 스케일) const safetyIndex = useMemo(() => { - if (chinaVessels.length === 0) return { risk: 0, safety: 100 }; - const avgRisk = chinaVessels.reduce((s, i) => s + i.algorithms.riskScore.score, 0) / chinaVessels.length; - return { risk: Number((avgRisk / 10).toFixed(2)), safety: Number(((100 - avgRisk) / 10).toFixed(2)) }; - }, [chinaVessels]); + const avgRisk = apiStats ? Number(apiStats.avgRiskScore) : 0; + if (!apiStats || apiStats.total === 0) return { risk: 0, safety: 100 }; + return { + risk: Number((avgRisk / 10).toFixed(2)), + safety: Number(((100 - avgRisk) / 10).toFixed(2)), + }; + }, [apiStats]); const vesselTabs = ['특이운항', '비허가 선박', '제재 선박', '관심 선박'] as const; const statsTabs = ['불법조업 통계', '특이선박 통계', '위험선박 통계'] as const; @@ -319,7 +356,7 @@ export function ChinaFishing() { {!serviceAvailable && (
- iran 분석 서비스 미연결 - 실시간 데이터를 불러올 수 없습니다 + 분석 API 호출 실패 - 잠시 후 다시 시도해주세요
)} @@ -371,7 +408,7 @@ export function ChinaFishing() {
해역 전체 통항량 - {allItems.length.toLocaleString()} + {(apiStats?.total ?? 0).toLocaleString()} (척)
@@ -422,12 +459,15 @@ export function ChinaFishing() { - {/* 관심영역 안전도 */} + {/* 관심영역 안전도 (해역 지오펜스 미구축 → 데모) */}
- 관심영역 안전도 +
+ 관심영역 안전도 + 데모 데이터 +