feat: S4 alerts API + AIAlert/Dashboard 위험선박 실데이터 전환

백엔드:
- PredictionAlert 엔티티 + Repository
- AlertController: GET /api/alerts (페이징 + eventId 필터)

프론트:
- AIAlert: mock alerts → GET /api/alerts 실제 호출
- Dashboard 위험선박: vesselStore mock → fetchVesselAnalysis() API
  - riskScore TOP 8 선박, 다크/GPS변조/전재 배지 표시
- Dashboard 이벤트 타임라인: eventStore API 기반 동작 확인

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
htlee 2026-04-07 13:09:08 +09:00
부모 474e672683
커밋 cc1b1e20df
6개의 변경된 파일363개의 추가작업 그리고 84개의 파일을 삭제

파일 보기

@ -0,0 +1,39 @@
package gc.mda.kcg.domain.event;
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.web.bind.annotation.*;
import java.util.List;
/**
* 알림 조회 API.
* 예측 이벤트에 대해 발송된 알림(SMS, 푸시 ) 이력을 제공.
*/
@RestController
@RequestMapping("/api/alerts")
@RequiredArgsConstructor
public class AlertController {
private final PredictionAlertRepository alertRepository;
/**
* 알림 목록 조회 (페이징). eventId 파라미터로 특정 이벤트의 알림만 필터 가능.
*/
@GetMapping
@RequirePermission(resource = "monitoring", operation = "READ")
public Object getAlerts(
@RequestParam(required = false) Long eventId,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size
) {
if (eventId != null) {
return alertRepository.findByEventIdOrderBySentAtDesc(eventId);
}
return alertRepository.findAllByOrderBySentAtDesc(
PageRequest.of(page, size)
);
}
}

파일 보기

@ -0,0 +1,56 @@
package gc.mda.kcg.domain.event;
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;
/**
* AI 예측 알림.
* 이벤트 발생 발송된 알림(SMS, 푸시 ) 이력을 저장.
*/
@Entity
@Table(name = "prediction_alerts", schema = "kcg")
@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder
public class PredictionAlert {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "event_id")
private Long eventId;
@Column(name = "channel", length = 20)
private String channel;
@Column(name = "recipient", length = 200)
private String recipient;
@Column(name = "sent_at")
private OffsetDateTime sentAt;
@Column(name = "delivery_status", nullable = false, length = 20)
private String deliveryStatus;
@Column(name = "ai_confidence", precision = 5, scale = 4)
private BigDecimal aiConfidence;
@JdbcTypeCode(SqlTypes.JSON)
@Column(columnDefinition = "jsonb")
private Map<String, Object> metadata;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "event_id", insertable = false, updatable = false)
private PredictionEvent event;
@PrePersist
void prePersist() {
if (deliveryStatus == null) deliveryStatus = "SENT";
if (sentAt == null) sentAt = OffsetDateTime.now();
}
}

파일 보기

@ -0,0 +1,14 @@
package gc.mda.kcg.domain.event;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface PredictionAlertRepository extends JpaRepository<PredictionAlert, Long> {
List<PredictionAlert> findByEventIdOrderBySentAtDesc(Long eventId);
Page<PredictionAlert> findAllByOrderBySentAtDesc(Pageable pageable);
}

파일 보기

@ -15,7 +15,7 @@ import { AreaChart, PieChart } from '@lib/charts';
import { useKpiStore } from '@stores/kpiStore';
import { useEventStore } from '@stores/eventStore';
import { usePatrolStore } from '@stores/patrolStore';
import { useVesselStore } from '@stores/vesselStore';
import { fetchVesselAnalysis, type VesselAnalysisItem } from '@/services/vesselAnalysisApi';
// ─── 작전 경보 등급 ─────────────────────
type AlertLevel = 'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW';
@ -44,6 +44,7 @@ const KPI_UI_MAP: Record<string, { icon: LucideIcon; color: string }> = {
};
// TODO: /api/risk-grid 연동 예정
const AREA_RISK_DATA = [
{ area: '서해 NLL', vessels: 8, risk: 95, trend: 'up' },
{ area: 'EEZ 북부', vessels: 14, risk: 91, trend: 'up' },
@ -54,12 +55,14 @@ const AREA_RISK_DATA = [
{ area: '남해 서부', vessels: 1, risk: 22, trend: 'stable' },
];
// TODO: /api/stats/daily 연동 예정
const HOURLY_DETECTION = [
{ hour: '00', count: 5, eez: 2 }, { hour: '01', count: 4, eez: 1 }, { hour: '02', count: 6, eez: 3 },
{ hour: '03', count: 8, eez: 4 }, { hour: '04', count: 12, eez: 6 }, { hour: '05', count: 18, eez: 8 },
{ hour: '06', count: 28, eez: 12 }, { hour: '07', count: 35, eez: 15 }, { hour: '08', count: 47, eez: 18 },
];
// TODO: /api/stats/daily 연동 예정
const VESSEL_TYPE_DATA = [
{ name: 'EEZ 침범', value: 18, color: '#ef4444' },
{ name: '다크베셀', value: 12, color: '#f97316' },
@ -68,6 +71,7 @@ const VESSEL_TYPE_DATA = [
{ name: '고속도주', value: 4, color: '#06b6d4' },
];
// TODO: /api/weather 연동 예정
const WEATHER_DATA = {
wind: { speed: 12, direction: 'NW', gust: 18 },
wave: { height: 1.8, period: 6 },
@ -175,6 +179,7 @@ function FuelGauge({ percent }: { percent: number }) {
// ─── 해역 위협 미니맵 (Leaflet) ───────────────────
// TODO: /api/risk-grid 연동 예정
const THREAT_AREAS = [
{ name: '서해 NLL', lat: 37.80, lng: 124.90, risk: 95, vessels: 8 },
{ name: 'EEZ 북부', lat: 37.20, lng: 124.63, risk: 91, vessels: 14 },
@ -284,14 +289,26 @@ export function Dashboard() {
const kpiStore = useKpiStore();
const eventStore = useEventStore();
const vesselStore = useVesselStore();
const patrolStore = usePatrolStore();
const [riskVessels, setRiskVessels] = useState<VesselAnalysisItem[]>([]);
useEffect(() => { if (!kpiStore.loaded) kpiStore.load(); }, [kpiStore.loaded, kpiStore.load]);
useEffect(() => { if (!eventStore.loaded) eventStore.load(); }, [eventStore.loaded, eventStore.load]);
useEffect(() => { if (!vesselStore.loaded) vesselStore.load(); }, [vesselStore.loaded, vesselStore.load]);
useEffect(() => { if (!patrolStore.loaded) patrolStore.load(); }, [patrolStore.loaded, patrolStore.load]);
useEffect(() => {
fetchVesselAnalysis()
.then((res) => {
if (!res.serviceAvailable) { setRiskVessels([]); return; }
const sorted = [...res.items].sort(
(a, b) => b.algorithms.riskScore.score - a.algorithms.riskScore.score,
);
setRiskVessels(sorted.slice(0, 8));
})
.catch(() => setRiskVessels([]));
}, []);
const KPI_DATA = useMemo(() => kpiStore.metrics.map((m) => {
const ui = KPI_UI_MAP[m.id] ?? KPI_UI_MAP[m.label] ?? { icon: Radar, color: '#3b82f6' };
return {
@ -313,19 +330,21 @@ export function Dashboard() {
area: e.area ?? '-',
})), [eventStore.events]);
const TOP_RISK_VESSELS = useMemo(() => vesselStore.suspects.slice(0, 8).map((v) => ({
id: v.id,
name: v.name,
risk: v.risk / 100,
type: v.pattern ?? v.type,
flag: v.flag === 'CN' ? '중국' : v.flag === 'KR' ? '한국' : '미상',
tonnage: v.tonnage ?? null,
speed: v.speed != null ? `${v.speed}kt` : '-',
heading: v.heading != null ? `${v.heading}°` : '-',
lastAIS: v.lastSignal ?? '-',
location: `N${v.lat.toFixed(2)} E${v.lng.toFixed(2)}`,
pattern: v.status,
})), [vesselStore.suspects]);
const TOP_RISK_VESSELS = useMemo(() => riskVessels.map((v) => {
const risk = v.algorithms.riskScore;
return {
id: v.mmsi,
name: v.mmsi,
risk: risk.score,
type: v.classification.vesselType,
riskLevel: risk.level,
zone: v.algorithms.location.zone,
isDark: v.algorithms.darkVessel.isDark,
activity: v.algorithms.activity.state,
isSpoofing: v.algorithms.gpsSpoofing.spoofingScore >= 0.3,
isTransship: v.algorithms.transship.isSuspect,
};
}), [riskVessels]);
const PATROL_SHIPS = useMemo(() => patrolStore.ships.map((s) => ({
name: s.name,
@ -583,52 +602,35 @@ export function Dashboard() {
</CardHeader>
<CardContent className="px-4 pb-4 pt-2">
{/* 테이블 헤더 */}
<div className="grid grid-cols-[32px_1fr_80px_80px_80px_80px_80px_90px_100px] gap-2 px-3 py-2 text-[9px] text-hint border-b border-slate-700/50 font-medium">
<div className="grid grid-cols-[32px_1fr_80px_80px_80px_80px_100px] gap-2 px-3 py-2 text-[9px] text-hint border-b border-slate-700/50 font-medium">
<span>#</span>
<span> / ID</span>
<span> </span>
<span>/</span>
<span>/</span>
<span>AIS </span>
<span></span>
<span></span>
<span>MMSI</span>
<span></span>
<span></span>
<span> </span>
<span></span>
<span></span>
</div>
<div className="space-y-0.5">
{TOP_RISK_VESSELS.map((vessel, index) => (
<div
key={vessel.id}
className="grid grid-cols-[32px_1fr_80px_80px_80px_80px_80px_90px_100px] gap-2 px-3 py-2.5 rounded-lg hover:bg-surface-overlay transition-colors cursor-pointer group items-center"
className="grid grid-cols-[32px_1fr_80px_80px_80px_80px_100px] gap-2 px-3 py-2.5 rounded-lg hover:bg-surface-overlay transition-colors cursor-pointer group items-center"
>
<span className="text-hint text-xs font-bold">#{index + 1}</span>
<div>
<div className="flex items-center gap-1.5">
<PulsingDot color={vessel.risk > 0.9 ? 'bg-red-500' : vessel.risk > 0.8 ? 'bg-orange-500' : 'bg-yellow-500'} />
<span className="text-heading text-[11px] font-bold">{vessel.name}</span>
<PulsingDot color={vessel.risk > 0.9 ? 'bg-red-500' : vessel.risk > 0.7 ? 'bg-orange-500' : 'bg-yellow-500'} />
<span className="text-heading text-[11px] font-bold tabular-nums">{vessel.name}</span>
</div>
<span className="text-[8px] text-hint ml-5">{vessel.id}</span>
<span className="text-[10px] text-muted-foreground">{vessel.type}</span>
<span className="text-[10px] text-muted-foreground truncate">{vessel.zone}</span>
<span className="text-[10px] text-muted-foreground">{vessel.activity}</span>
<div className="flex items-center gap-1">
{vessel.isDark && <Badge className="bg-orange-500/15 text-orange-400 text-[8px] px-1 py-0 border-0"></Badge>}
{vessel.isSpoofing && <Badge className="bg-yellow-500/15 text-yellow-400 text-[8px] px-1 py-0 border-0">GPS변조</Badge>}
{vessel.isTransship && <Badge className="bg-purple-500/15 text-purple-400 text-[8px] px-1 py-0 border-0"></Badge>}
{!vessel.isDark && !vessel.isSpoofing && !vessel.isTransship && <span className="text-[9px] text-hint">-</span>}
</div>
<Badge className={`text-[8px] px-1.5 py-0 border ${
vessel.type.includes('침범') || vessel.type.includes('선단') ? 'bg-red-500/15 text-red-400 border-red-500/30'
: vessel.type.includes('다크') ? 'bg-orange-500/15 text-orange-400 border-orange-500/30'
: vessel.type.includes('환적') ? 'bg-purple-500/15 text-purple-400 border-purple-500/30'
: vessel.type.includes('MMSI') ? 'bg-yellow-500/15 text-yellow-400 border-yellow-500/30'
: 'bg-cyan-500/15 text-cyan-400 border-cyan-500/30'
}`}>{vessel.type}</Badge>
<span className="text-[10px] text-muted-foreground">{vessel.flag}</span>
<div>
<div className="text-[10px] text-heading">{vessel.speed}</div>
<div className="text-[8px] text-hint">{vessel.heading}</div>
</div>
<span className={`text-[10px] ${vessel.lastAIS === '소실' ? 'text-red-400 font-bold' : 'text-muted-foreground'}`}>{vessel.lastAIS}</span>
<Badge className={`text-[8px] px-1 py-0 border-0 ${
vessel.pattern === '도주' ? 'bg-red-500/20 text-red-400'
: vessel.pattern === '조업 중' ? 'bg-orange-500/20 text-orange-400'
: vessel.pattern === '정박/접현' ? 'bg-purple-500/20 text-purple-400'
: vessel.pattern === '고속이동' ? 'bg-cyan-500/20 text-cyan-400'
: 'bg-muted text-muted-foreground'
}`}>{vessel.pattern}</Badge>
<span className="text-[9px] text-hint tabular-nums">{vessel.location}</span>
<RiskBar value={vessel.risk} />
</div>
))}

파일 보기

@ -1,61 +1,195 @@
import { useEffect, useMemo } from 'react';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Card, CardContent } from '@shared/components/ui/card';
import { Badge } from '@shared/components/ui/badge';
import { DataTable, type DataColumn } from '@shared/components/common/DataTable';
import { Bell, Send, CheckCircle, XCircle, Clock, MapPin, AlertTriangle, Ship } from 'lucide-react';
import { useEventStore } from '@stores/eventStore';
import { Send, Loader2, AlertTriangle } from 'lucide-react';
import { getAlerts, type PredictionAlert } from '@/services/event';
/* SFR-17: 현장 함정 즉각 대응 AI 알림 메시지 발송 기능 */
interface Alert { id: string; time: string; type: string; location: string; confidence: number; target: string; status: string; received: string; [key: string]: unknown; }
const cols: DataColumn<Alert>[] = [
{ key: 'id', label: 'ID', width: '70px', render: v => <span className="text-hint font-mono text-[10px]">{v as string}</span> },
{ key: 'time', label: '탐지 시각', width: '80px', sortable: true, render: v => <span className="text-muted-foreground font-mono text-[10px]">{v as string}</span> },
{ key: 'type', label: '탐지 유형', width: '80px', sortable: true, render: v => <Badge className="bg-red-500/15 text-red-400 border-0 text-[9px]">{v as string}</Badge> },
{ key: 'location', label: '위치좌표', width: '120px', render: v => <span className="text-muted-foreground font-mono text-[10px]">{v as string}</span> },
{ key: 'confidence', label: '신뢰도', width: '60px', align: 'center', sortable: true,
render: v => { const n = v as number; return <span className={`font-bold ${n > 90 ? 'text-red-400' : n > 80 ? 'text-orange-400' : 'text-yellow-400'}`}>{n}%</span>; } },
{ key: 'target', label: '수신 대상', render: v => <span className="text-cyan-400">{v as string}</span> },
{ key: 'status', label: '발송 상태', width: '80px', align: 'center', sortable: true,
render: v => { const s = v as string; const c = s === '수신확인' ? 'bg-green-500/20 text-green-400' : s === '발송완료' ? 'bg-blue-500/20 text-blue-400' : 'bg-red-500/20 text-red-400'; return <Badge className={`border-0 text-[9px] ${c}`}>{s}</Badge>; } },
{ key: 'received', label: '수신 시각', width: '80px', render: v => <span className="text-hint font-mono text-[10px]">{v as string}</span> },
interface AlertRow {
id: number;
eventId: number;
time: string;
channel: string;
recipient: string;
confidence: string;
status: string;
[key: string]: unknown;
}
const STATUS_LABEL: Record<string, string> = {
SENT: '발송완료',
DELIVERED: '수신확인',
FAILED: '발송실패',
};
const cols: DataColumn<AlertRow>[] = [
{
key: 'id',
label: 'ID',
width: '70px',
render: (v) => <span className="text-hint font-mono text-[10px]">{v as number}</span>,
},
{
key: 'eventId',
label: '이벤트',
width: '80px',
render: (v) => <span className="text-cyan-400 font-mono text-[10px]">EVT-{v as number}</span>,
},
{
key: 'time',
label: '발송 시각',
width: '130px',
sortable: true,
render: (v) => <span className="text-muted-foreground font-mono text-[10px]">{v as string}</span>,
},
{
key: 'channel',
label: '채널',
width: '80px',
sortable: true,
render: (v) => (
<Badge className="bg-blue-500/15 text-blue-400 border-0 text-[9px]">{v as string}</Badge>
),
},
{
key: 'recipient',
label: '수신 대상',
render: (v) => <span className="text-cyan-400">{v as string}</span>,
},
{
key: 'confidence',
label: '신뢰도',
width: '70px',
align: 'center',
sortable: true,
render: (v) => {
const s = v as string;
if (!s) return <span className="text-hint">-</span>;
const n = parseFloat(s);
const color = n > 0.9 ? 'text-red-400' : n > 0.8 ? 'text-orange-400' : 'text-yellow-400';
return <span className={`font-bold ${color}`}>{(n * 100).toFixed(0)}%</span>;
},
},
{
key: 'status',
label: '상태',
width: '80px',
align: 'center',
sortable: true,
render: (v) => {
const s = v as string;
const c =
s === 'DELIVERED'
? 'bg-green-500/20 text-green-400'
: s === 'SENT'
? 'bg-blue-500/20 text-blue-400'
: 'bg-red-500/20 text-red-400';
return (
<Badge className={`border-0 text-[9px] ${c}`}>{STATUS_LABEL[s] ?? s}</Badge>
);
},
},
];
const PAGE_SIZE = 10;
export function AIAlert() {
const { t } = useTranslation('fieldOps');
const { alerts: storeAlerts, load } = useEventStore();
useEffect(() => { load(); }, [load]);
const [alerts, setAlerts] = useState<PredictionAlert[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [totalElements, setTotalElements] = useState(0);
const DATA: Alert[] = useMemo(
const fetchAlerts = useCallback(async () => {
setLoading(true);
setError(null);
try {
const res = await getAlerts({ page: 0, size: 100 });
setAlerts(res.content);
setTotalElements(res.totalElements);
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchAlerts();
}, [fetchAlerts]);
const data: AlertRow[] = useMemo(
() =>
storeAlerts.map((a) => ({
alerts.map((a) => ({
id: a.id,
time: a.time,
type: a.type,
location: a.location,
confidence: a.confidence,
target: a.target,
status: a.status,
received: a.status === '수신확인' ? a.time.replace(/:\d{2}$/, (m) => `:${String(Number(m.slice(1)) + 3).padStart(2, '0')}`) : '-',
eventId: a.eventId,
time: a.sentAt ? new Date(a.sentAt).toLocaleString('ko-KR') : '-',
channel: a.channel ?? '-',
recipient: a.recipient ?? '-',
confidence: a.aiConfidence != null ? String(a.aiConfidence) : '',
status: a.deliveryStatus,
})),
[storeAlerts],
[alerts],
);
const deliveredCount = alerts.filter((a) => a.deliveryStatus === 'DELIVERED').length;
const failedCount = alerts.filter((a) => a.deliveryStatus === 'FAILED').length;
if (loading) {
return (
<div className="p-5 flex items-center justify-center gap-2 text-muted-foreground">
<Loader2 className="w-5 h-5 animate-spin" />
<span> ...</span>
</div>
);
}
if (error) {
return (
<div className="p-5 flex items-center justify-center gap-2 text-red-400">
<AlertTriangle className="w-5 h-5" />
<span> : {error}</span>
<button type="button" onClick={fetchAlerts} className="ml-2 text-xs underline text-cyan-400">
</button>
</div>
);
}
return (
<div className="p-5 space-y-4">
<div>
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2"><Send className="w-5 h-5 text-yellow-400" />{t('aiAlert.title')}</h2>
<h2 className="text-lg font-bold text-heading whitespace-nowrap flex items-center gap-2">
<Send className="w-5 h-5 text-yellow-400" />
{t('aiAlert.title')}
</h2>
<p className="text-[10px] text-hint mt-0.5">{t('aiAlert.desc')}</p>
</div>
<div className="flex gap-2">
{[{ l: '총 발송', v: DATA.length, c: 'text-heading' }, { l: '수신확인', v: DATA.filter(d => d.status === '수신확인').length, c: 'text-green-400' }, { l: '미수신', v: DATA.filter(d => d.status === '미수신').length, c: 'text-red-400' }].map(k => (
<div key={k.l} className="flex-1 flex items-center gap-2 px-3 py-2 rounded-xl border border-border bg-card">
<span className={`text-base font-bold ${k.c}`}>{k.v}</span><span className="text-[9px] text-hint">{k.l}</span>
{[
{ l: '총 발송', v: totalElements, c: 'text-heading' },
{ l: '수신확인', v: deliveredCount, c: 'text-green-400' },
{ l: '실패', v: failedCount, c: 'text-red-400' },
].map((k) => (
<div
key={k.l}
className="flex-1 flex items-center gap-2 px-3 py-2 rounded-xl border border-border bg-card"
>
<span className={`text-base font-bold ${k.c}`}>{k.v}</span>
<span className="text-[9px] text-hint">{k.l}</span>
</div>
))}
</div>
<DataTable data={DATA} columns={cols} pageSize={10} searchPlaceholder="유형, 대상, 좌표 검색..." searchKeys={['type', 'target', 'location']} exportFilename="AI알림이력" />
<DataTable
data={data}
columns={cols}
pageSize={PAGE_SIZE}
searchPlaceholder="채널, 수신대상 검색..."
searchKeys={['channel', 'recipient']}
exportFilename="AI알림이력"
/>
</div>
);
}

파일 보기

@ -100,6 +100,40 @@ export async function getEventStats(): Promise<EventStats> {
return res.json();
}
// ─── 알림 API ────────────────────────────────────
export interface PredictionAlert {
id: number;
eventId: number;
channel: string;
recipient: string | null;
sentAt: string;
deliveryStatus: string;
aiConfidence: number | null;
}
export interface AlertPageResponse {
content: PredictionAlert[];
totalElements: number;
totalPages: number;
number: number;
size: number;
}
export async function getAlerts(params?: {
eventId?: number;
page?: number;
size?: number;
}): Promise<AlertPageResponse> {
const query = new URLSearchParams();
if (params?.eventId != null) query.set('eventId', String(params.eventId));
query.set('page', String(params?.page ?? 0));
query.set('size', String(params?.size ?? 20));
const res = await fetch(`${API_BASE}/alerts?${query}`, { credentials: 'include' });
if (!res.ok) throw new Error(`API error: ${res.status}`);
return res.json();
}
// ─── 하위 호환 헬퍼 (기존 EventRecord 형식 → PredictionEvent 매핑) ──
/** @deprecated PredictionEvent를 직접 사용하세요 */