feat(detection): 환적 의심 전용 탐지 페이지 신설 (Phase 0-3)

docs/prediction-analysis.md §7 P1 권고의 "UI 미노출 탐지" 해소 중 두 번째.
prediction algorithms/transshipment.py 5단계 필터 파이프라인 결과를 전체 목록·
집계·상세 수준으로 조회하는 READ 전용 대시보드.

### 배경
기존 features/vessel/TransferDetection.tsx 는 선박 상세 수준(특정 MMSI 의 환적
이력)이고, 환적 의심 선박 전체 목록을 보려면 ChinaFishing 의 탭 중 하나를 거쳐야
했다. /api/analysis/transship 엔드포인트는 이미 존재하나 전용 페이지가 없었음.

### 변경
- frontend/src/features/detection/TransshipmentDetection.tsx 신설 (405 라인)
  - PageContainer + PageHeader(ArrowLeftRight) + KPI 5장
    (Total / Transship tier CRITICAL/HIGH/MEDIUM / Risk CRITICAL)
  - DataTable 8컬럼 (analyzedAt / mmsi / pairMmsi / duration / tier / risk / zone)
  - features.transship_tier 읽어 Badge 로 심각도 표시
  - 필터: hours(1/6/12/24/48) / riskLevel / mmsi 검색
  - 상세 패널: 분석 피처 JSON 원본 + 좌표 + transship_score
  - 기존 analysisApi.getTransshipSuspects 재사용 — backend 변경 없음
- index.ts + componentRegistry.ts 등록
- detection.json (ko/en) transshipment.* 네임스페이스 추가 (각 44키)
- common.json (ko/en) nav.transshipment 추가
- V033__menu_transshipment_detection.sql
  - auth_perm_tree(detection:transshipment, nav_sort=910)
  - ADMIN 5 ops + OPERATOR/ANALYST/FIELD/VIEWER READ

### 권한 주의
/api/analysis/transship 의 @RequirePermission 은 현재 detection:dark-vessel.
이 메뉴 READ 만으로는 API 호출 불가. 현행 운영자 역할(OPERATOR/ANALYST/FIELD)
은 dark-vessel READ 도 보유하므로 실용 동작.
향후 VesselAnalysisController.listTransshipSuspects 의 @RequirePermission 을
detection:transshipment 로 교체하는 권한 일관화는 별도 MR (후속).

### 검증
- npx tsc --noEmit 통과
- pre-commit tsc + ESLint 통과 예정
- Flyway V033 자동 적용 (백엔드 재배포 필요)
This commit is contained in:
htlee 2026-04-20 05:51:06 +09:00
부모 f2d145c9a2
커밋 cbfed23823
8개의 변경된 파일545개의 추가작업 그리고 0개의 파일을 삭제

파일 보기

@ -0,0 +1,46 @@
-- V033: 환적 의심 탐지 (TransshipmentDetection) 메뉴·권한 seed
--
-- prediction `algorithms/transshipment.py` 의 5단계 필터 파이프라인 결과
-- (is_transship_suspect=true) 를 전체 목록·집계·상세 수준으로 조회하는 READ 전용
-- 대시보드. 기존 features/vessel/TransferDetection.tsx 는 선박 상세 수준이고,
-- 이 페이지는 전체 목록·통계 운영 대시보드.
--
-- 참고: 실제 API `/api/analysis/transship` 는 VesselAnalysisController 에서 권한을
-- `detection:dark-vessel` 로 가드 중이므로, 이 메뉴 READ 만으로는 API 호출 불가.
-- 현재 운영자(OPERATOR/ANALYST/FIELD) 는 양쪽 READ 를 모두 가지므로 실용상 문제 없음.
-- 향후 VesselAnalysisController.listTransshipSuspects 의 @RequirePermission 을
-- `detection:transshipment` 로 교체하는 것이 권한 일관성상 바람직 (별도 MR).
--
-- Phase 0-3: prediction-analysis.md P1 권고의 "UI 미노출 탐지" 해소 중 두 번째.
-- ──────────────────────────────────────────────────────────────────
-- 1. 권한 트리 / 메뉴 슬롯 (detection 그룹 평탄화: parent_cd=NULL)
-- nav_sort=910 은 chinaFishing(900) 과 illegalFishing(920) 사이
-- ──────────────────────────────────────────────────────────────────
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:transshipment', NULL, '환적 의심 탐지', 1, 48,
'/transshipment', 'nav.transshipment',
'features/detection/TransshipmentDetection', 910,
'{"ko":"환적 의심 탐지","en":"Transshipment"}'::jsonb)
ON CONFLICT (rsrc_cd) DO NOTHING;
-- ──────────────────────────────────────────────────────────────────
-- 2. 권한 부여 — READ 전용 대시보드
-- ADMIN: 5 ops 전부 (메뉴 관리 일관성)
-- 나머지(OPERATOR/ANALYST/FIELD/VIEWER): READ
-- ──────────────────────────────────────────────────────────────────
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
SELECT r.role_sn, 'detection:transshipment', 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:transshipment', '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;

파일 보기

@ -45,6 +45,9 @@ export const COMPONENT_REGISTRY: Record<string, LazyComponent> = {
'features/detection/IllegalFishingPattern': lazy(() =>
import('@features/detection').then((m) => ({ default: m.IllegalFishingPattern })),
),
'features/detection/TransshipmentDetection': lazy(() =>
import('@features/detection').then((m) => ({ default: m.TransshipmentDetection })),
),
// ── 단속·이벤트 ──
'features/enforcement/EnforcementHistory': lazy(() =>
import('@features/enforcement').then((m) => ({ default: m.EnforcementHistory })),

파일 보기

@ -0,0 +1,405 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { ArrowLeftRight, RefreshCw } 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 { getTransshipSuspects, type VesselAnalysis } from '@/services/analysisApi';
import { useSettingsStore } from '@stores/settingsStore';
/**
* (Transshipment) .
*
* prediction `algorithms/transshipment.py` 5
* (transship_suspect=true) ·· .
*
* features.transship_tier (CRITICAL/HIGH/MEDIUM) transship_score .
* `features/vessel/TransferDetection.tsx` ,
* · .
*/
const HOUR_OPTIONS = [1, 6, 12, 24, 48] as const;
const LEVEL_OPTIONS = ['CRITICAL', 'HIGH', 'MEDIUM', 'LOW'] as const;
const DEFAULT_HOURS = 6;
const DEFAULT_SIZE = 200;
type TransshipTier = 'CRITICAL' | 'HIGH' | 'MEDIUM' | string;
interface TransshipFeatures {
transship_tier?: TransshipTier;
transship_score?: number;
dark_tier?: string;
[key: string]: unknown;
}
function readTier(row: VesselAnalysis): string {
const f = (row.features ?? {}) as TransshipFeatures;
return f.transship_tier ?? '-';
}
function readScore(row: VesselAnalysis): number | null {
const f = (row.features ?? {}) as TransshipFeatures;
return typeof f.transship_score === 'number' ? f.transship_score : null;
}
export function TransshipmentDetection() {
const { t } = useTranslation('detection');
const { t: tc } = useTranslation('common');
const lang = useSettingsStore((s) => s.language) as 'ko' | 'en';
const [rows, setRows] = useState<VesselAnalysis[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [hours, setHours] = useState<number>(DEFAULT_HOURS);
const [levelFilter, setLevelFilter] = useState('');
const [mmsiFilter, setMmsiFilter] = useState('');
const [selected, setSelected] = useState<VesselAnalysis | null>(null);
const loadData = useCallback(async () => {
setLoading(true);
setError('');
try {
const resp = await getTransshipSuspects({ hours, page: 0, size: DEFAULT_SIZE });
setRows(resp.content);
} catch (e: unknown) {
setError(e instanceof Error ? e.message : t('transshipment.error.loadFailed'));
} finally {
setLoading(false);
}
}, [hours, t]);
useEffect(() => {
loadData();
}, [loadData]);
const filteredRows = useMemo(() => {
return rows.filter((r) => {
if (levelFilter && r.riskLevel !== levelFilter) return false;
if (mmsiFilter && !r.mmsi.includes(mmsiFilter)) return false;
return true;
});
}, [rows, levelFilter, mmsiFilter]);
const stats = useMemo(() => {
const byLevel: Record<string, number> = { CRITICAL: 0, HIGH: 0, MEDIUM: 0, LOW: 0 };
const byTier: Record<string, number> = { CRITICAL: 0, HIGH: 0, MEDIUM: 0 };
for (const r of rows) {
if (r.riskLevel && byLevel[r.riskLevel] !== undefined) {
byLevel[r.riskLevel]++;
}
const tier = readTier(r);
if (byTier[tier] !== undefined) {
byTier[tier]++;
}
}
return { byLevel, byTier };
}, [rows]);
const cols: DataColumn<VesselAnalysis & Record<string, unknown>>[] = useMemo(
() => [
{
key: 'analyzedAt',
label: t('transshipment.columns.analyzedAt'),
width: '140px',
sortable: true,
render: (v) => (
<span className="text-muted-foreground text-[10px]">{formatDateTime(v as string)}</span>
),
},
{
key: 'mmsi',
label: t('transshipment.columns.mmsi'),
width: '120px',
sortable: true,
render: (v) => (
<span className="font-mono text-[10px] text-cyan-600 dark:text-cyan-400">
{v as string}
</span>
),
},
{
key: 'transshipPairMmsi',
label: t('transshipment.columns.pairMmsi'),
width: '120px',
render: (v) => (
<span className="font-mono text-[10px] text-label">{(v as string) || '-'}</span>
),
},
{
key: 'transshipDurationMin',
label: t('transshipment.columns.durationMin'),
width: '90px',
align: 'right',
sortable: true,
render: (v) => {
const n = typeof v === 'number' ? v : Number(v ?? 0);
return <span className="font-mono text-label">{n.toFixed(0)}</span>;
},
},
{
key: 'features',
label: t('transshipment.columns.tier'),
width: '90px',
align: 'center',
render: (_, row) => {
const tier = readTier(row);
const isKnown = ['CRITICAL', 'HIGH', 'MEDIUM'].includes(tier);
return (
<Badge
intent={isKnown ? getAlertLevelIntent(tier) : 'muted'}
size="sm"
>
{isKnown ? getAlertLevelLabel(tier, tc, lang) : tier}
</Badge>
);
},
},
{
key: 'riskScore',
label: t('transshipment.columns.riskScore'),
width: '80px',
align: 'right',
sortable: true,
render: (v) => <span className="font-mono text-label">{(v as number) ?? 0}</span>,
},
{
key: 'riskLevel',
label: t('transshipment.columns.riskLevel'),
width: '90px',
align: 'center',
sortable: true,
render: (v) => (
<Badge intent={getAlertLevelIntent(v as string)} size="sm">
{getAlertLevelLabel(v as string, tc, lang)}
</Badge>
),
},
{
key: 'zoneCode',
label: t('transshipment.columns.zone'),
width: '130px',
render: (v) => <span className="text-hint text-[10px]">{(v as string) || '-'}</span>,
},
],
[t, tc, lang],
);
return (
<PageContainer>
<PageHeader
icon={ArrowLeftRight}
iconColor="text-purple-600 dark:text-purple-400"
title={t('transshipment.title')}
description={t('transshipment.desc')}
actions={
<Button
variant="outline"
size="sm"
onClick={loadData}
disabled={loading}
icon={<RefreshCw className="w-3.5 h-3.5" />}
>
{t('transshipment.refresh')}
</Button>
}
/>
{error && (
<Card variant="default">
<CardContent className="text-destructive text-xs py-2">{error}</CardContent>
</Card>
)}
<Section title={t('transshipment.stats.title')}>
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
<StatCard label={t('transshipment.stats.total')} value={rows.length} />
<StatCard label={t('transshipment.stats.tierCritical')} value={stats.byTier.CRITICAL} intent="critical" />
<StatCard label={t('transshipment.stats.tierHigh')} value={stats.byTier.HIGH} intent="warning" />
<StatCard label={t('transshipment.stats.tierMedium')} value={stats.byTier.MEDIUM} intent="info" />
<StatCard label={t('transshipment.stats.riskCritical')} value={stats.byLevel.CRITICAL} intent="critical" />
</div>
</Section>
<Section title={t('transshipment.list.title')}>
<div className="grid grid-cols-1 md:grid-cols-4 gap-2 mb-3">
<Select
aria-label={t('transshipment.filters.hours')}
value={String(hours)}
onChange={(e) => setHours(Number(e.target.value))}
>
{HOUR_OPTIONS.map((h) => (
<option key={h} value={h}>
{t('transshipment.filters.hoursValue', { h })}
</option>
))}
</Select>
<Select
aria-label={t('transshipment.filters.level')}
value={levelFilter}
onChange={(e) => setLevelFilter(e.target.value)}
>
<option value="">{t('transshipment.filters.allLevel')}</option>
{LEVEL_OPTIONS.map((l) => (
<option key={l} value={l}>
{getAlertLevelLabel(l, tc, lang)}
</option>
))}
</Select>
<Input
aria-label={t('transshipment.filters.mmsi')}
placeholder={t('transshipment.filters.mmsi')}
value={mmsiFilter}
onChange={(e) => setMmsiFilter(e.target.value)}
/>
<div className="flex items-center justify-end gap-1.5">
<Badge intent="info" size="sm">
{filteredRows.length} / {rows.length}
</Badge>
</div>
</div>
{filteredRows.length === 0 && !loading ? (
<p className="text-hint text-xs py-4 text-center">
{t('transshipment.list.empty', { hours })}
</p>
) : (
<DataTable
data={filteredRows as (VesselAnalysis & Record<string, unknown>)[]}
columns={cols}
pageSize={20}
showSearch={false}
showExport={false}
showPrint={false}
onRowClick={(row) => setSelected(row as VesselAnalysis)}
/>
)}
</Section>
{selected && (
<Section title={t('transshipment.detail.title')}>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 text-xs">
<div className="space-y-1.5">
<DetailRow
label={t('transshipment.columns.analyzedAt')}
value={formatDateTime(selected.analyzedAt)}
/>
<DetailRow label={t('transshipment.columns.mmsi')} value={selected.mmsi} mono />
<DetailRow
label={t('transshipment.columns.pairMmsi')}
value={selected.transshipPairMmsi ?? '-'}
mono
/>
<DetailRow
label={t('transshipment.columns.durationMin')}
value={`${selected.transshipDurationMin ?? 0}`}
/>
<DetailRow
label={t('transshipment.columns.riskScore')}
value={String(selected.riskScore ?? 0)}
/>
<DetailRow
label={t('transshipment.columns.zone')}
value={selected.zoneCode ?? '-'}
/>
<DetailRow
label={t('transshipment.detail.location')}
value={
selected.lat != null && selected.lon != null
? `${selected.lat.toFixed(4)}, ${selected.lon.toFixed(4)}`
: '-'
}
/>
</div>
<div className="space-y-2">
<div className="flex items-center gap-2">
<span className="text-xs text-label">
{t('transshipment.columns.tier')}:
</span>
{(() => {
const tier = readTier(selected);
const isKnown = ['CRITICAL', 'HIGH', 'MEDIUM'].includes(tier);
return (
<Badge intent={isKnown ? getAlertLevelIntent(tier) : 'muted'} size="sm">
{isKnown ? getAlertLevelLabel(tier, tc, lang) : tier}
</Badge>
);
})()}
<span className="text-xs text-label ml-3">
{t('transshipment.detail.transshipScore')}:
</span>
<span className="text-xs font-mono text-heading">
{readScore(selected)?.toFixed(1) ?? '-'}
</span>
</div>
{selected.features && Object.keys(selected.features).length > 0 && (
<div>
<div className="text-[10px] text-hint mb-1">
{t('transshipment.detail.features')}
</div>
<pre className="bg-surface-raised text-[10px] text-label p-2 overflow-auto max-h-48">
{JSON.stringify(selected.features, null, 2)}
</pre>
</div>
)}
<div className="flex justify-end pt-2">
<Button variant="ghost" size="sm" onClick={() => setSelected(null)}>
{t('transshipment.detail.close')}
</Button>
</div>
</div>
</div>
</Section>
)}
</PageContainer>
);
}
// ─── 내부 컴포넌트 ─────────────
interface StatCardProps {
label: string;
value: number;
intent?: 'warning' | 'info' | 'critical' | 'muted';
}
function StatCard({ label, value, intent }: StatCardProps) {
return (
<Card variant="default">
<CardContent className="py-3 flex flex-col items-center gap-1">
<span className="text-[10px] text-hint text-center">{label}</span>
{intent ? (
<Badge intent={intent} size="md">
{value}
</Badge>
) : (
<span className="text-lg font-bold text-heading">{value}</span>
)}
</CardContent>
</Card>
);
}
interface DetailRowProps {
label: string;
value: string;
mono?: boolean;
}
function DetailRow({ label, value, mono }: DetailRowProps) {
return (
<div className="flex items-start gap-2">
<span className="text-hint w-24 shrink-0">{label}</span>
<span className={mono ? 'font-mono text-label' : 'text-label'}>{value}</span>
</div>
);
}
export default TransshipmentDetection;

파일 보기

@ -4,3 +4,4 @@ export { ChinaFishing } from './ChinaFishing';
export { GearIdentification } from './GearIdentification';
export { GearCollisionDetection } from './GearCollisionDetection';
export { IllegalFishingPattern } from './IllegalFishingPattern';
export { TransshipmentDetection } from './TransshipmentDetection';

파일 보기

@ -10,6 +10,7 @@
"chinaFishing": "Chinese Vessel",
"gearCollision": "Gear Collision",
"illegalFishing": "Illegal Fishing",
"transshipment": "Transshipment",
"patrolRoute": "Patrol Route",
"fleetOptimization": "Fleet Optimize",
"enforcementHistory": "History",

파일 보기

@ -15,6 +15,50 @@
"title": "Gear Identification",
"desc": "SFR-10 | AI-based gear origin & type automatic identification"
},
"transshipment": {
"title": "Transshipment Suspects",
"desc": "Vessels passing prediction's 5-stage transshipment filter (cross-type pair → monitoring zone → RENDEZVOUS 90min+ → score 50+ → anti-cluster burst). Severity from features.transship_tier",
"refresh": "Refresh",
"stats": {
"title": "Overview",
"total": "Total",
"tierCritical": "Transship CRITICAL",
"tierHigh": "Transship HIGH",
"tierMedium": "Transship MEDIUM",
"riskCritical": "Risk CRITICAL"
},
"list": {
"title": "Suspect vessels",
"empty": "No transshipment suspects in the last {{hours}} hours."
},
"columns": {
"analyzedAt": "Analyzed",
"mmsi": "MMSI",
"pairMmsi": "Pair MMSI",
"durationMin": "Duration (min)",
"tier": "Transship tier",
"riskScore": "Risk",
"riskLevel": "Risk level",
"zone": "Zone"
},
"filters": {
"hours": "Window",
"level": "Risk level",
"mmsi": "MMSI",
"hoursValue": "Last {{h}}h",
"allLevel": "All levels"
},
"detail": {
"title": "Transshipment detail",
"location": "Location",
"features": "Raw features",
"transshipScore": "Transship score",
"close": "Close"
},
"error": {
"loadFailed": "Failed to load transshipment suspects."
}
},
"illegalPattern": {
"title": "Illegal Fishing Events",
"desc": "Integrated view of illegal fishingrelated events: zone/gear mismatch, territorial sea intrusion, protected zone entry (READ only — take actions from Event List)",

파일 보기

@ -10,6 +10,7 @@
"chinaFishing": "중국어선 분석",
"gearCollision": "어구 정체성 충돌",
"illegalFishing": "불법 조업 이벤트",
"transshipment": "환적 의심 탐지",
"patrolRoute": "순찰경로 추천",
"fleetOptimization": "다함정 최적화",
"enforcementHistory": "단속 이력",

파일 보기

@ -15,6 +15,50 @@
"title": "어구 식별 분석",
"desc": "SFR-10 | AI 기반 어구 원산지·유형 자동 식별 및 판정"
},
"transshipment": {
"title": "환적 의심 탐지",
"desc": "prediction 5단계 필터 파이프라인(이종 쌍 → 감시영역 → RENDEZVOUS 90분+ → 점수 50+ → 밀집 방폭) 통과 선박 목록. features.transship_tier 기반 심각도 분류",
"refresh": "새로고침",
"stats": {
"title": "현황 요약",
"total": "전체",
"tierCritical": "환적 CRITICAL",
"tierHigh": "환적 HIGH",
"tierMedium": "환적 MEDIUM",
"riskCritical": "종합위험 CRITICAL"
},
"list": {
"title": "환적 의심 선박",
"empty": "최근 {{hours}}시간 내 환적 의심 선박이 없습니다."
},
"columns": {
"analyzedAt": "분석 시각",
"mmsi": "MMSI",
"pairMmsi": "상대 MMSI",
"durationMin": "지속(분)",
"tier": "환적 tier",
"riskScore": "위험도",
"riskLevel": "종합 위험",
"zone": "수역"
},
"filters": {
"hours": "조회 기간",
"level": "위험도",
"mmsi": "MMSI 검색",
"hoursValue": "최근 {{h}}시간",
"allLevel": "전체 위험도"
},
"detail": {
"title": "환적 의심 상세",
"location": "좌표",
"features": "분석 피처 원본",
"transshipScore": "환적 점수",
"close": "닫기"
},
"error": {
"loadFailed": "환적 의심 목록을 불러오지 못했습니다."
}
},
"illegalPattern": {
"title": "불법 조업 이벤트",
"desc": "수역-어구 위반 / 영해 침범 / 특정수역 진입 등 불법 조업 의심 이벤트 통합 조회 (READ 전용 — 처리 액션은 이벤트 목록에서)",