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 6306d8f..0ba31d5 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 @@ -58,6 +58,7 @@ public class VesselAnalysisResult { private Double spoofingScore; + @Column(name = "bd09_offset_m") private Double bd09OffsetM; private Integer speedJumpCount; diff --git a/frontend/src/hooks/useKoreaFilters.ts b/frontend/src/hooks/useKoreaFilters.ts index bdfcf2d..2070a62 100644 --- a/frontend/src/hooks/useKoreaFilters.ts +++ b/frontend/src/hooks/useKoreaFilters.ts @@ -308,9 +308,17 @@ export function useKoreaFilters( return visibleShips.filter(s => { const mtCat = getMarineTrafficCategory(s.typecode, s.category); if (filters.illegalFishing) { + const analysis = analysisMap?.get(s.mmsi); + if (analysis) { + // Python 분석: 영해/접속수역 침범 또는 위험도 HIGH+ 어선 + const zone = analysis.algorithms.location.zone; + const riskLevel = analysis.algorithms.riskScore.level; + const isThreat = zone === 'TERRITORIAL_SEA' || zone === 'CONTIGUOUS_ZONE' + || riskLevel === 'CRITICAL' || riskLevel === 'HIGH'; + if (isThreat) return true; + } + // 비한국 어선 (기본 필터) if (mtCat === 'fishing' && s.flag !== 'KR') return true; - const riskLevel = analysisMap?.get(s.mmsi)?.algorithms.riskScore.level; - if (mtCat === 'fishing' && (riskLevel === 'CRITICAL' || riskLevel === 'HIGH')) return true; } if (filters.illegalTransship && transshipSuspects.has(s.mmsi)) return true; if (filters.darkVessel && darkVesselSet.has(s.mmsi)) return true; diff --git a/frontend/src/services/chnPrmShip.ts b/frontend/src/services/chnPrmShip.ts new file mode 100644 index 0000000..1a4c890 --- /dev/null +++ b/frontend/src/services/chnPrmShip.ts @@ -0,0 +1,54 @@ +import type { ChnPrmShipInfo } from '../types'; + +const SIGNAL_BATCH_BASE = '/signal-batch'; +const CACHE_TTL_MS = 5 * 60_000; // 5분 + +let cachedList: ChnPrmShipInfo[] = []; +let cacheTime = 0; +let fetchPromise: Promise | null = null; + +async function fetchList(): Promise { + const now = Date.now(); + if (cachedList.length > 0 && now - cacheTime < CACHE_TTL_MS) { + return cachedList; + } + + if (fetchPromise) return fetchPromise; + + fetchPromise = (async () => { + try { + const res = await fetch( + `${SIGNAL_BATCH_BASE}/api/v2/vessels/chnprmship/recent-positions?minutes=60`, + { headers: { accept: 'application/json' } }, + ); + if (!res.ok) return cachedList; + const json: unknown = await res.json(); + cachedList = Array.isArray(json) ? (json as ChnPrmShipInfo[]) : []; + cacheTime = Date.now(); + return cachedList; + } catch { + return cachedList; + } finally { + fetchPromise = null; + } + })(); + + return fetchPromise; +} + +/** mmsi로 허가어선 정보 조회 — 목록을 캐시하고 lookup */ +export async function lookupPermittedShip(mmsi: string): Promise { + const list = await fetchList(); + return list.find((s) => s.mmsi === mmsi) ?? null; +} + +/** 허가어선 mmsi Set (빠른 조회용) */ +export async function getPermittedMmsiSet(): Promise> { + const list = await fetchList(); + return new Set(list.map((s) => s.mmsi)); +} + +/** 캐시 강제 갱신 */ +export function invalidateCache(): void { + cacheTime = 0; +}