diff --git a/backend/src/main/resources/db/migration/V032__menu_illegal_fishing_pattern.sql b/backend/src/main/resources/db/migration/V032__menu_illegal_fishing_pattern.sql new file mode 100644 index 0000000..caec07f --- /dev/null +++ b/backend/src/main/resources/db/migration/V032__menu_illegal_fishing_pattern.sql @@ -0,0 +1,40 @@ +-- V032: 불법 조업 이벤트 (IllegalFishingPattern) 메뉴·권한 seed +-- +-- event_generator 가 생산하는 카테고리 중 "불법 조업" 관련 3종 +-- (GEAR_ILLEGAL / EEZ_INTRUSION / ZONE_DEPARTURE) 을 통합해서 보여주는 +-- READ 전용 대시보드. 운영자 액션(ack/status 변경) 은 /event-list 에서 수행. +-- +-- Phase 0-2: prediction-analysis.md P1 권고의 "UI 미노출 탐지" 해소 중 첫 번째. + +-- ────────────────────────────────────────────────────────────────── +-- 1. 권한 트리 / 메뉴 슬롯 (V024 이후 detection 그룹은 평탄화됨: parent_cd=NULL) +-- nav_sort=920 은 chinaFishing(900) 과 gearCollision(950) 사이에 배치 +-- ────────────────────────────────────────────────────────────────── +INSERT INTO kcg.auth_perm_tree + (rsrc_cd, parent_cd, rsrc_nm, rsrc_level, sort_ord, + url_path, label_key, component_key, nav_sort, labels) +VALUES + ('detection:illegal-fishing', NULL, '불법 조업 이벤트', 1, 45, + '/illegal-fishing', 'nav.illegalFishing', + 'features/detection/IllegalFishingPattern', 920, + '{"ko":"불법 조업 이벤트","en":"Illegal Fishing"}'::jsonb) +ON CONFLICT (rsrc_cd) DO NOTHING; + +-- ────────────────────────────────────────────────────────────────── +-- 2. 권한 부여 +-- READ 전용 페이지 — 모든 역할에 READ만 부여. +-- 운영자가 ack/status 변경을 원하면 EventList(monitoring) 권한으로 이동. +-- ADMIN 은 일관성을 위해 5 ops 전부 부여 (메뉴 등록/삭제 정도). +-- ────────────────────────────────────────────────────────────────── +INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn) +SELECT r.role_sn, 'detection:illegal-fishing', op.oper_cd, 'Y' +FROM kcg.auth_role r +CROSS JOIN (VALUES ('READ'), ('CREATE'), ('UPDATE'), ('DELETE'), ('EXPORT')) AS op(oper_cd) +WHERE r.role_cd = 'ADMIN' +ON CONFLICT (role_sn, rsrc_cd, oper_cd) DO NOTHING; + +INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn) +SELECT r.role_sn, 'detection:illegal-fishing', 'READ', 'Y' +FROM kcg.auth_role r +WHERE r.role_cd IN ('OPERATOR', 'ANALYST', 'FIELD', 'VIEWER') +ON CONFLICT (role_sn, rsrc_cd, oper_cd) DO NOTHING; diff --git a/docs/RELEASE-NOTES.md b/docs/RELEASE-NOTES.md index 5da5714..60c1a87 100644 --- a/docs/RELEASE-NOTES.md +++ b/docs/RELEASE-NOTES.md @@ -4,6 +4,9 @@ ## [Unreleased] +### 추가 +- **불법 조업 이벤트 전용 페이지 신설 (Phase 0-2)** — `/illegal-fishing` 경로에 READ 전용 대시보드 추가. event_generator 가 생산하는 `GEAR_ILLEGAL`(G-01/G-05/G-06) + `EEZ_INTRUSION`(영해·접속수역) + `ZONE_DEPARTURE`(특정수역 진입) 3 카테고리를 한 화면에서 통합 조회. 심각도 KPI 5장 + 카테고리별 3장 + DataTable(7컬럼) + 필터(category/level/mmsi) + JSON features 상세 패널 + EventList 네비게이션. 기존 `/api/events` 를 category 다중 병렬 조회로 래핑하여 backend 변경 없이 구현. V032 마이그레이션으로 `detection:illegal-fishing` 권한 트리 + 전 역할 READ 부여 (운영자 처리 액션은 EventList 경유) + ### 수정 - **gear_group_parent_candidate_snapshots.candidate_source VARCHAR(30)→(100) 확장 (V031)** — prediction `gear_parent_inference` 가 여러 source 라벨을 쉼표로 join 한 값(최대 ~39자)이 VARCHAR(30) 제약을 넘어 매 사이클 `StringDataRightTruncation` 으로 gear correlation 스테이지 전체가 실패하던 기존 버그. Phase 0-1 (PR #83) 의 `logger.exception` 전환으로 풀 stacktrace 가 journal 에 찍히면서 원인 특정. backend JPA 엔티티 미참조로 재빌드 불필요, Flyway 자동 적용, prediction 재기동만으로 해소 diff --git a/frontend/src/app/componentRegistry.ts b/frontend/src/app/componentRegistry.ts index 2bbd930..61141b4 100644 --- a/frontend/src/app/componentRegistry.ts +++ b/frontend/src/app/componentRegistry.ts @@ -42,6 +42,9 @@ export const COMPONENT_REGISTRY: Record = { 'features/detection/GearCollisionDetection': lazy(() => import('@features/detection').then((m) => ({ default: m.GearCollisionDetection })), ), + 'features/detection/IllegalFishingPattern': lazy(() => + import('@features/detection').then((m) => ({ default: m.IllegalFishingPattern })), + ), // ── 단속·이벤트 ── 'features/enforcement/EnforcementHistory': lazy(() => import('@features/enforcement').then((m) => ({ default: m.EnforcementHistory })), diff --git a/frontend/src/features/detection/IllegalFishingPattern.tsx b/frontend/src/features/detection/IllegalFishingPattern.tsx new file mode 100644 index 0000000..9fb545b --- /dev/null +++ b/frontend/src/features/detection/IllegalFishingPattern.tsx @@ -0,0 +1,391 @@ +import { useCallback, useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Ban, RefreshCw, ExternalLink } from 'lucide-react'; + +import { PageContainer, PageHeader, Section } from '@shared/components/layout'; +import { Badge } from '@shared/components/ui/badge'; +import { Button } from '@shared/components/ui/button'; +import { Input } from '@shared/components/ui/input'; +import { Select } from '@shared/components/ui/select'; +import { Card, CardContent } from '@shared/components/ui/card'; +import { DataTable, type DataColumn } from '@shared/components/common/DataTable'; + +import { formatDateTime } from '@shared/utils/dateFormat'; +import { getAlertLevelIntent, getAlertLevelLabel } from '@shared/constants/alertLevels'; +import { + ILLEGAL_FISHING_CATEGORIES, + listIllegalFishingEvents, + type IllegalFishingCategory, + type IllegalFishingPatternPage, +} from '@/services/illegalFishingPatternApi'; +import type { PredictionEvent } from '@/services/event'; +import { useSettingsStore } from '@stores/settingsStore'; + +/** + * 불법 조업 이벤트 — event_generator 가 생산하는 카테고리 중 "불법 조업" 관련 3종을 + * 묶어 한 화면에서 조회하는 대시보드. + * + * GEAR_ILLEGAL : G-01 수역-어구 / G-05 고정어구 drift / G-06 쌍끌이 공조 + * EEZ_INTRUSION : 영해 침범 / 접속수역 고위험 + * ZONE_DEPARTURE : 특정수역 진입 (risk ≥ 40) + * + * 운영자 액션(확인/상태변경/단속 등록)은 /event-list 이벤트 목록에서 수행. + * 이 페이지는 **READ 전용** 대시보드. + */ + +const LEVEL_OPTIONS = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW'] as const; +const DEFAULT_SIZE = 200; + +export function IllegalFishingPattern() { + const { t } = useTranslation('detection'); + const { t: tc } = useTranslation('common'); + const lang = useSettingsStore((s) => s.language) as 'ko' | 'en'; + + const [page, setPage] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(''); + const [categoryFilter, setCategoryFilter] = useState(''); + const [levelFilter, setLevelFilter] = useState(''); + const [mmsiFilter, setMmsiFilter] = useState(''); + const [selected, setSelected] = useState(null); + + const loadData = useCallback(async () => { + setLoading(true); + setError(''); + try { + const result = await listIllegalFishingEvents({ + category: categoryFilter || undefined, + level: levelFilter || undefined, + vesselMmsi: mmsiFilter || undefined, + size: DEFAULT_SIZE, + }); + setPage(result); + } catch (e: unknown) { + setError(e instanceof Error ? e.message : t('illegalPattern.error.loadFailed')); + } finally { + setLoading(false); + } + }, [categoryFilter, levelFilter, mmsiFilter, t]); + + useEffect(() => { + loadData(); + }, [loadData]); + + const rows = page?.content ?? []; + const levelCount = (code: string) => page?.byLevel?.[code] ?? 0; + const categoryCount = (code: string) => page?.byCategory?.[code] ?? 0; + + const cols: DataColumn>[] = useMemo( + () => [ + { + key: 'occurredAt', + label: t('illegalPattern.columns.occurredAt'), + width: '140px', + sortable: true, + render: (v) => ( + {formatDateTime(v as string)} + ), + }, + { + key: 'level', + label: t('illegalPattern.columns.level'), + width: '90px', + align: 'center', + sortable: true, + render: (v) => ( + + {getAlertLevelLabel(v as string, tc, lang)} + + ), + }, + { + key: 'category', + label: t('illegalPattern.columns.category'), + width: '130px', + align: 'center', + sortable: true, + render: (v) => ( + + {t(`illegalPattern.category.${v as string}`, { defaultValue: v as string })} + + ), + }, + { + key: 'title', + label: t('illegalPattern.columns.title'), + minWidth: '260px', + render: (v) => {v as string}, + }, + { + key: 'vesselMmsi', + label: t('illegalPattern.columns.mmsi'), + width: '110px', + render: (v, row) => ( + + {(v as string) || '-'} + {row.vesselName ? ({row.vesselName}) : null} + + ), + }, + { + key: 'zoneCode', + label: t('illegalPattern.columns.zone'), + width: '130px', + render: (v) => {(v as string) || '-'}, + }, + { + key: 'status', + label: t('illegalPattern.columns.status'), + width: '90px', + align: 'center', + sortable: true, + render: (v) => ( + + {t(`illegalPattern.status.${v as string}`, { defaultValue: v as string })} + + ), + }, + ], + [t, tc, lang], + ); + + return ( + + } + > + {t('illegalPattern.refresh')} + + } + /> + + {error && ( + + {error} + + )} + +
+
+ + + + + +
+
+ +
+
+ {ILLEGAL_FISHING_CATEGORIES.map((code) => ( + + +
+
+ {t(`illegalPattern.category.${code}`)} +
+
+ {t(`illegalPattern.categoryDesc.${code}`)} +
+
+
{categoryCount(code)}
+
+
+ ))} +
+
+ +
+
+ + + setMmsiFilter(e.target.value)} + /> +
+ + {t('illegalPattern.filters.limit')} · {DEFAULT_SIZE} + +
+
+ + {rows.length === 0 && !loading ? ( +

{t('illegalPattern.list.empty')}

+ ) : ( + )[]} + columns={cols} + pageSize={20} + showSearch={false} + showExport={false} + showPrint={false} + onRowClick={(row) => setSelected(row as PredictionEvent)} + /> + )} +
+ + {selected && ( +
+
+
+ + + + + + + + + +
+
+ {selected.detail && ( +

+ {selected.detail} +

+ )} + {selected.features && Object.keys(selected.features).length > 0 && ( +
+
+ {t('illegalPattern.detail.features')} +
+
+                    {JSON.stringify(selected.features, null, 2)}
+                  
+
+ )} +
+ + +
+
+
+
+ )} +
+ ); +} + +// ─── 내부 컴포넌트 ───────────── + +interface StatCardProps { + label: string; + value: number; + intent?: 'warning' | 'info' | 'critical' | 'muted'; +} + +function StatCard({ label, value, intent }: StatCardProps) { + return ( + + + {label} + {intent ? ( + + {value} + + ) : ( + {value} + )} + + + ); +} + +interface DetailRowProps { + label: string; + value: string; + mono?: boolean; +} + +function DetailRow({ label, value, mono }: DetailRowProps) { + return ( +
+ {label} + {value} +
+ ); +} + +export default IllegalFishingPattern; diff --git a/frontend/src/features/detection/index.ts b/frontend/src/features/detection/index.ts index 94f2b13..36cec99 100644 --- a/frontend/src/features/detection/index.ts +++ b/frontend/src/features/detection/index.ts @@ -3,3 +3,4 @@ export { GearDetection } from './GearDetection'; export { ChinaFishing } from './ChinaFishing'; export { GearIdentification } from './GearIdentification'; export { GearCollisionDetection } from './GearCollisionDetection'; +export { IllegalFishingPattern } from './IllegalFishingPattern'; diff --git a/frontend/src/lib/i18n/locales/en/common.json b/frontend/src/lib/i18n/locales/en/common.json index 86600d7..fe3b734 100644 --- a/frontend/src/lib/i18n/locales/en/common.json +++ b/frontend/src/lib/i18n/locales/en/common.json @@ -9,6 +9,7 @@ "gearDetection": "Gear Detection", "chinaFishing": "Chinese Vessel", "gearCollision": "Gear Collision", + "illegalFishing": "Illegal Fishing", "patrolRoute": "Patrol Route", "fleetOptimization": "Fleet Optimize", "enforcementHistory": "History", diff --git a/frontend/src/lib/i18n/locales/en/detection.json b/frontend/src/lib/i18n/locales/en/detection.json index 6a6ed67..9ea7470 100644 --- a/frontend/src/lib/i18n/locales/en/detection.json +++ b/frontend/src/lib/i18n/locales/en/detection.json @@ -15,6 +15,66 @@ "title": "Gear Identification", "desc": "SFR-10 | AI-based gear origin & type automatic identification" }, + "illegalPattern": { + "title": "Illegal Fishing Events", + "desc": "Integrated view of illegal fishing–related events: zone/gear mismatch, territorial sea intrusion, protected zone entry (READ only — take actions from Event List)", + "refresh": "Refresh", + "stats": { + "title": "Severity distribution", + "total": "Total" + }, + "byCategory": { + "title": "By category" + }, + "category": { + "GEAR_ILLEGAL": "Gear Violation", + "EEZ_INTRUSION": "Territorial/Contiguous", + "ZONE_DEPARTURE": "Protected Zone Entry" + }, + "categoryDesc": { + "GEAR_ILLEGAL": "G-01/G-05/G-06 zone-gear mismatch, fixed-gear drift, pair trawl", + "EEZ_INTRUSION": "Territorial sea (CRITICAL) / Contiguous zone high-risk", + "ZONE_DEPARTURE": "Protected zone entry with risk ≥ 40" + }, + "list": { + "title": "Events", + "empty": "No illegal fishing events match the filters." + }, + "columns": { + "occurredAt": "Occurred", + "level": "Level", + "category": "Category", + "title": "Title", + "mmsi": "MMSI", + "zone": "Zone", + "status": "Status" + }, + "filters": { + "category": "Category", + "level": "Level", + "mmsi": "MMSI", + "limit": "Limit", + "allCategory": "All categories", + "allLevel": "All levels" + }, + "status": { + "NEW": "New", + "ACKED": "Acked", + "RESOLVED": "Resolved", + "FALSE_ALARM": "False alarm" + }, + "detail": { + "title": "Event detail", + "vesselName": "Vessel name", + "location": "Location", + "features": "Extra info", + "close": "Close", + "openEventList": "Open in Event List" + }, + "error": { + "loadFailed": "Failed to load events." + } + }, "gearCollision": { "title": "Gear Identity Collision", "desc": "Same gear name broadcasting from multiple MMSIs in the same cycle — gear duplication / spoofing suspicion", diff --git a/frontend/src/lib/i18n/locales/ko/common.json b/frontend/src/lib/i18n/locales/ko/common.json index 6d306b2..90225fd 100644 --- a/frontend/src/lib/i18n/locales/ko/common.json +++ b/frontend/src/lib/i18n/locales/ko/common.json @@ -9,6 +9,7 @@ "gearDetection": "어구 탐지", "chinaFishing": "중국어선 분석", "gearCollision": "어구 정체성 충돌", + "illegalFishing": "불법 조업 이벤트", "patrolRoute": "순찰경로 추천", "fleetOptimization": "다함정 최적화", "enforcementHistory": "단속 이력", diff --git a/frontend/src/lib/i18n/locales/ko/detection.json b/frontend/src/lib/i18n/locales/ko/detection.json index 8a4b4d7..01d1a05 100644 --- a/frontend/src/lib/i18n/locales/ko/detection.json +++ b/frontend/src/lib/i18n/locales/ko/detection.json @@ -15,6 +15,66 @@ "title": "어구 식별 분석", "desc": "SFR-10 | AI 기반 어구 원산지·유형 자동 식별 및 판정" }, + "illegalPattern": { + "title": "불법 조업 이벤트", + "desc": "수역-어구 위반 / 영해 침범 / 특정수역 진입 등 불법 조업 의심 이벤트 통합 조회 (READ 전용 — 처리 액션은 이벤트 목록에서)", + "refresh": "새로고침", + "stats": { + "title": "심각도 분포", + "total": "전체" + }, + "byCategory": { + "title": "카테고리별 건수" + }, + "category": { + "GEAR_ILLEGAL": "어구 위반", + "EEZ_INTRUSION": "영해/접속수역 침범", + "ZONE_DEPARTURE": "특정수역 진입" + }, + "categoryDesc": { + "GEAR_ILLEGAL": "G-01/G-05/G-06 수역·어구 불일치, 고정어구 drift, 쌍끌이 공조", + "EEZ_INTRUSION": "영해(CRITICAL) / 접속수역 + 고위험 위반", + "ZONE_DEPARTURE": "관심 수역(ZONE_I~IV) 진입 + 위험도 40+" + }, + "list": { + "title": "이벤트 목록", + "empty": "조건에 맞는 불법 조업 이벤트가 없습니다." + }, + "columns": { + "occurredAt": "발생 시각", + "level": "심각도", + "category": "카테고리", + "title": "제목", + "mmsi": "MMSI", + "zone": "수역", + "status": "상태" + }, + "filters": { + "category": "카테고리", + "level": "심각도", + "mmsi": "MMSI 검색", + "limit": "최대", + "allCategory": "전체 카테고리", + "allLevel": "전체 심각도" + }, + "status": { + "NEW": "신규", + "ACKED": "확인", + "RESOLVED": "처리완료", + "FALSE_ALARM": "오탐" + }, + "detail": { + "title": "이벤트 상세", + "vesselName": "선박명", + "location": "좌표", + "features": "추가 정보", + "close": "닫기", + "openEventList": "이벤트 목록에서 열기" + }, + "error": { + "loadFailed": "이벤트를 불러오지 못했습니다." + } + }, "gearCollision": { "title": "어구 정체성 충돌 탐지", "desc": "동일 어구 이름이 서로 다른 MMSI 로 같은 사이클에 동시 송출되는 공존 패턴 — 어구 복제/스푸핑 의심", diff --git a/frontend/src/services/illegalFishingPatternApi.ts b/frontend/src/services/illegalFishingPatternApi.ts new file mode 100644 index 0000000..2cda3cb --- /dev/null +++ b/frontend/src/services/illegalFishingPatternApi.ts @@ -0,0 +1,77 @@ +/** + * 불법 조업 이벤트 전용 서비스 — 기존 /api/events 를 category 다중 조회로 래핑. + * + * category 는 event_generator 의 rule 에서 오는 단일 값이지만, UI 관점에서 "불법 조업" + * 은 여러 카테고리의 합집합이다: + * - GEAR_ILLEGAL : G-01 수역-어구 / G-05 고정어구 drift / G-06 쌍끌이 + * - EEZ_INTRUSION : 영해 침범 / 접속수역 + 고위험 + * - ZONE_DEPARTURE : 특정수역 진입 (관심구역 모니터링) + * + * backend 변경 없이 클라이언트에서 병렬 조회 후 머지한다. + */ +import { getEvents, type EventPageResponse, type PredictionEvent } from './event'; + +export const ILLEGAL_FISHING_CATEGORIES = [ + 'GEAR_ILLEGAL', + 'EEZ_INTRUSION', + 'ZONE_DEPARTURE', +] as const; + +export type IllegalFishingCategory = (typeof ILLEGAL_FISHING_CATEGORIES)[number]; + +export interface ListParams { + /** 단일 카테고리를 지정하면 해당 카테고리만, '' 이면 3개 모두 병합 조회 */ + category?: IllegalFishingCategory | ''; + level?: string; + status?: string; + vesselMmsi?: string; + size?: number; +} + +export interface IllegalFishingPatternPage { + content: PredictionEvent[]; + totalElements: number; + byCategory: Record; + byLevel: Record; +} + +/** + * 병합 조회 — category 미지정 시 3개 병렬 조회 후 occurredAt desc 정렬로 머지. + * 각 카테고리 최대 size 건씩 수집하므로, 기본 200 * 3 = 600 건이 상한. + */ +export async function listIllegalFishingEvents(params?: ListParams): Promise { + const size = params?.size ?? 200; + const targetCategories: IllegalFishingCategory[] = params?.category + ? [params.category] + : [...ILLEGAL_FISHING_CATEGORIES]; + + const pages: EventPageResponse[] = await Promise.all( + targetCategories.map((category) => + getEvents({ + category, + level: params?.level, + status: params?.status, + vesselMmsi: params?.vesselMmsi, + page: 0, + size, + }), + ), + ); + + const allEvents: PredictionEvent[] = pages.flatMap((p) => p.content); + allEvents.sort((a, b) => b.occurredAt.localeCompare(a.occurredAt)); + + const byCategory: Record = {}; + const byLevel: Record = {}; + for (const e of allEvents) { + byCategory[e.category] = (byCategory[e.category] ?? 0) + 1; + byLevel[e.level] = (byLevel[e.level] ?? 0) + 1; + } + + return { + content: allEvents, + totalElements: pages.reduce((acc, p) => acc + p.totalElements, 0), + byCategory, + byLevel, + }; +}