Compare commits

..

1 커밋

작성자 SHA1 메시지 날짜
Nan Kyung Lee
353c960c3f feat: SFR-05~14 화면 시안 전면 반영 및 UI 신규 구현
- SFR-05: 위험도지도 좌측 필터 패널 + 우측 격자상세(SHAP) + 내보내기
- SFR-06: 단속계획 3단 메뉴 구조 + SFR-11 하위 11개 화면
- SFR-07: 순찰경로 가중치 슬라이더(α/β/γ) + 시나리오 + 결과통계
- SFR-08: 다함정최적화 커버리지/중복 슬라이더 + 함정별 상세 + 일괄승인
- SFR-09: 불법어선탐지 필터탭 + 탐지요약 + SHAP 패널 + AIS등급
- SFR-11: 단속 사건관리 통합(리스트→등록→상세→수정), AI탐지연계
- SFR-12: 경보현황판 지도중심 레이아웃 + 5등급 경보 + 필터 + 선박목록
- SFR-13: 통계분석 세로스크롤 대시보드 + 기관비교표 + 보고서생성
- SFR-14: 외부서비스 비식별정책 + API정의 + 이용현황 + 장비구성도
- SFR-15: 모바일서비스 상태바+경보+퀵메뉴+위치정보+지도+네비바
- 공통: OSM 지도 적용, Vite CORS 프록시 수정, 3단 메뉴 지원

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 17:05:44 +09:00
296개의 변경된 파일9703개의 추가작업 그리고 28817개의 파일을 삭제

2
.gitignore vendored
파일 보기

@ -5,7 +5,6 @@ backend/target/
backend/build/
# === Python (prediction) ===
.venv/
prediction/.venv/
prediction/__pycache__/
prediction/**/__pycache__/
@ -55,7 +54,6 @@ frontend/.vite/
# === 대용량/참고 문서 ===
*.hwpx
*.docx
# === Claude Code ===
!.claude/

173
CLAUDE.md
파일 보기

@ -2,45 +2,6 @@
해양경찰청 AI 기반 불법어선 탐지 및 단속 지원 플랫폼
## 🚨 절대 지침 (Absolute Rules)
아래 두 지침은 모든 작업에 우선 적용된다. 사용자가 명시적으로 해제하지 않는 한 우회 금지.
### 1. 신규 기능 설계·구현 착수 시: 원격 develop 동기화 필수
신규 기능/버그 수정/리팩터 등 **어떤 작업이든 브랜치를 새로 만들기 전**에는 아래 절차를 반드시 수행한다.
```bash
git fetch origin --prune
# origin/develop이 로컬 develop보다 앞서 있는지 확인
git log --oneline develop..origin/develop | head
```
- **로컬 develop이 뒤처진 경우** → 사용자에게 다음을 권유하고 동의를 받은 후 진행:
> "`origin/develop`이 로컬보다 N개 커밋 앞서 있습니다. 최신화 후 신규 브랜치를 생성하는 것을 권장합니다. 진행할까요?"
승인 시: `git checkout develop && git pull --ff-only origin develop` → 그 위에서 `git checkout -b <new-branch>`
- **로컬 develop이 최신인 경우** → 그대로 develop에서 신규 브랜치 분기
- **로컬 develop이 없는 경우**`git checkout -b develop origin/develop`로 tracking branch 먼저 생성
- **로컬에 unstaged/uncommitted 변경이 있을 때** → 사용자에게 먼저 알리고 stash/commit 여부 확인 후 진행. 임의로 폐기 금지.
**이유**: 오래된 develop 위에서 작업하면 머지 충돌·리베이스 비용이 커지고, 이미 해결된 이슈를 중복 해결할 위험이 있다. 브랜치 분기 시점의 기반을 항상 최신으로 맞춘다.
**적용 범위**: `/push`, `/mr`, `/create-mr`, `/release`, `/fix-issue` 스킬 실행 시, 그리고 Claude가 자발적으로 새 브랜치를 만들 때 모두.
### 2. 프론트엔드 개발 시: `design-system.html` 쇼케이스 규칙 전면 준수
`frontend/` 하위의 모든 페이지·컴포넌트·스타일 작성은 `design-system.html`(쇼케이스)에 정의된 컴포넌트·토큰·카탈로그만 사용한다. 이 문서 하단 **"디자인 시스템 (필수 준수)"** 섹션의 규칙을 **예외 없이** 따른다.
핵심 요약 (상세는 하단 섹션 참조):
- 공통 컴포넌트 우선 사용: `Badge`, `Button`, `Input`, `Select`, `TabBar`, `Card`, `PageContainer`, `PageHeader`, `Section`
- 라벨/색상은 `shared/constants/` 카탈로그 API(`getAlertLevelIntent` 등) 경유, ad-hoc 문자열 매핑 금지
- **인라인 색상·하드코딩 Tailwind 색상·`!important` 전면 금지**
- 접근성: `<button type="button">`, 아이콘 전용은 `aria-label`, 폼 요소는 `aria-label`/`<label>` 필수
위반 시 리뷰 단계에서 반려 대상. 신규 페이지는 하단의 **"페이지 작성 표준 템플릿"** 을 시작점으로 사용한다.
---
## 모노레포 구조
```
@ -48,7 +9,7 @@ kcg-ai-monitoring/
├── frontend/ # React 19 + TypeScript + Vite (UI)
├── backend/ # Spring Boot 3.x + Java 21 (인증/권한/감사 + 분석 API)
├── prediction/ # Python 3.9 + FastAPI (AIS 분석 엔진, 5분 주기)
├── database/ # PostgreSQL 마이그레이션 (Flyway V001~V016, 48 테이블)
├── database/ # PostgreSQL 마이그레이션 (Flyway V001~V013)
│ └── migration/
├── deploy/ # 배포 가이드 + 서버 설정 문서
├── docs/ # 프로젝트 문서 (SFR, 아키텍처)
@ -63,14 +24,14 @@ kcg-ai-monitoring/
```
[Frontend Vite :5173] ──→ [Backend Spring :8080] ──→ [PostgreSQL kcgaidb]
↑ write
[Prediction FastAPI :18092] ─────┘ (5분 주기 분석 결과 저장)
↑ read
[SNPDB PostgreSQL] (AIS 원본)
[Prediction FastAPI :8001] ──────┘ (5분 주기 분석 결과 저장)
↑ read ↑ read
[SNPDB PostgreSQL] (AIS 원본) [Iran Backend] (레거시 프록시, 선택)
```
- **자체 백엔드**: 인증/권한/감사로그/관리자 + 운영자 의사결정 (확정/제외/학습) + prediction 분석 결과 조회 API (`/api/analysis/*`)
- **Prediction**: AIS → 분석 결과를 kcgaidb 에 직접 write (백엔드 호출 없음)
- **DB 공유 아키텍처**: 백엔드와 prediction 은 HTTP 호출 없이 kcgaidb 를 통해서만 연동
- **자체 백엔드**: 인증/권한/감사로그/관리자 + 운영자 의사결정 (확정/제외/학습)
- **iran 백엔드 프록시**: 분석 결과 read-only 참조 (vessel_analysis, group_polygons, correlations)
- **신규 DB (kcgaidb)**: 자체 생산 데이터만 저장, prediction 분석 테이블은 미복사
## 명령어
@ -140,126 +101,6 @@ make format # 프론트 prettier
- 커밋: Conventional Commits (한국어), `.githooks/commit-msg` 검증
- pre-commit: `frontend/` 디렉토리 기준 TypeScript + ESLint 검증
## 디자인 시스템 (필수 준수)
프론트엔드 UI는 **`/design-system.html` 쇼케이스를 단일 진실 공급원(SSOT)** 으로 한다.
모든 페이지/컴포넌트는 쇼케이스에 정의된 컴포넌트와 토큰만 사용한다.
### 쇼케이스 진입
- **URL**: https://kcg-ai-monitoring.gc-si.dev/design-system.html (메인 SPA와 별개)
- **소스**: `frontend/design-system.html` + `frontend/src/designSystemMain.tsx` + `frontend/src/design-system/`
- **추적 ID 체계**: `TRK-<카테고리>-<슬러그>` (예: `TRK-BADGE-critical-sm`)
- 호버 시 툴팁, "ID 복사 모드"에서 클릭 시 클립보드 복사
- URL 해시 딥링크: `#trk=TRK-BUTTON-primary-md`
- **단축키 `A`**: 다크/라이트 테마 토글
### 공통 컴포넌트 (반드시 사용)
| 컴포넌트 | 위치 | 용도 |
|---|---|---|
| `Badge` | `@shared/components/ui/badge` | 8 intent × 4 size, **className으로 색상 override 금지** |
| `Button` | `@shared/components/ui/button` | 5 variant × 3 size (primary/secondary/ghost/outline/destructive) |
| `Input` / `Select` / `Textarea` / `Checkbox` / `Radio` | `@shared/components/ui/` | 폼 요소 (Select는 aria-label 타입 강제) |
| `TabBar` / `TabButton` | `@shared/components/ui/tabs` | underline / pill / segmented 3 variant |
| `Card` / `CardHeader` / `CardTitle` / `CardContent` | `@shared/components/ui/card` | 4 variant |
| `PageContainer` | `@shared/components/layout` | 페이지 루트 (size sm/md/lg + fullBleed) |
| `PageHeader` | `@shared/components/layout` | 페이지 헤더 (icon + title + description + demo + actions) |
| `Section` | `@shared/components/layout` | Card + CardHeader + CardTitle + CardContent 단축 |
### 카탈로그 기반 라벨/색상
분류 데이터는 `frontend/src/shared/constants/`의 19+ 카탈로그를 참조한다.
중앙 레지스트리는 `catalogRegistry.ts`이며, 쇼케이스가 자동 열거한다.
```tsx
import { Badge } from '@shared/components/ui/badge';
import { getAlertLevelIntent, getAlertLevelLabel } from '@shared/constants/alertLevels';
<Badge intent={getAlertLevelIntent(event.level)} size="sm">
{getAlertLevelLabel(event.level, t, lang)}
</Badge>
```
ad-hoc 한글/영문 상태 문자열은 `getStatusIntent()` (statusIntent.ts) 사용.
숫자 위험도는 `getRiskIntent(0~100)` 사용.
### CSS 작성 규칙
1. **인라인 색상 금지**`style={{ backgroundColor: '#ef4444' }}` 같은 정적 색상은 작성 금지
- 예외: 동적 데이터 기반 (`backgroundColor: meta.hex`, progress width `${value}%`)
2. **하드코딩 Tailwind 색상 금지**`bg-red-500/20 text-red-400` 같은 직접 작성 금지
- 반드시 Badge intent 또는 카탈로그 API 호출
3. **className override 정책**
- ✅ 레이아웃/위치 보정: `<Badge intent="info" className="w-full justify-center">`
- ❌ 색상/글자 크기 override: `<Badge intent="info" className="bg-red-500 text-xs">`
4. **시맨틱 토큰 우선**`theme.css @layer utilities`의 토큰 사용
- `text-heading` / `text-label` / `text-hint` / `text-on-vivid` / `text-on-bright`
- `bg-surface-raised` / `bg-surface-overlay` / `bg-card` / `bg-background`
5. **!important 절대 금지** — `cn()` + `tailwind-merge`로 충돌 해결
6. **`-webkit-` 벤더 prefix** — 수동 작성 CSS는 `backdrop-filter` 등 prefix 직접 추가 (Tailwind는 자동)
### 페이지 작성 표준 템플릿
```tsx
import { PageContainer, PageHeader, Section } from '@shared/components/layout';
import { Button } from '@shared/components/ui/button';
import { Badge } from '@shared/components/ui/badge';
import { getAlertLevelIntent, getAlertLevelLabel } from '@shared/constants/alertLevels';
import { Shield, Plus } from 'lucide-react';
import { useTranslation } from 'react-i18next';
export function MyPage() {
const { t, i18n } = useTranslation('common');
const lang = i18n.language as 'ko' | 'en';
return (
<PageContainer>
<PageHeader
icon={Shield}
iconColor="text-blue-400"
title="페이지 제목"
description="페이지 설명"
actions={
<Button variant="primary" icon={<Plus className="w-4 h-4" />}>
추가
</Button>
}
/>
<Section title="데이터 목록">
<Badge intent={getAlertLevelIntent('HIGH')} size="sm">
{getAlertLevelLabel('HIGH', t, lang)}
</Badge>
</Section>
</PageContainer>
);
}
```
### 접근성 (a11y) 필수
- **`<button>`**: `type="button"` 명시 + 아이콘 전용은 `aria-label` 필수
- **`<input>` / `<textarea>` / `<select>`**: `aria-label` 또는 `<label htmlFor>` 필수
- **`Select` 컴포넌트**: TypeScript union type으로 `aria-label`/`aria-labelledby`/`title` 중 하나 컴파일 타임 강제
- 위반 시 WCAG 2.1 Level A 위반 + axe DevTools 경고
### 변경 사이클
1. 디자인 변경이 필요하면 → **쇼케이스에서 먼저 미세조정** → 시각 검증
2. 카탈로그 라벨/색상 변경 → `shared/constants/*` 또는 `variantMeta.ts`만 수정
3. 컴포넌트 변형 추가 → `lib/theme/variants.ts` CVA에만 추가
4. 실 페이지는 **컴포넌트만 사용**, 변경 시 자동 반영
### 금지 패턴 체크리스트
- ❌ `<Badge className="bg-red-500/20 text-red-400">``<Badge intent="critical">`
- ❌ `<button className="bg-blue-600 ...">``<Button variant="primary">`
- ❌ `<input className="bg-surface ...">``<Input>`
- ❌ `<div className="p-5 space-y-4">` 페이지 루트 → `<PageContainer>`
- ❌ `-m-4` negative margin 해킹 → `<PageContainer fullBleed>`
- ❌ `style={{ color: '#ef4444' }}` 정적 색상 → 시맨틱 토큰 또는 카탈로그
- ❌ `!important``cn()` 활용
- ❌ 페이지 내 `const STATUS_COLORS = {...}` 로컬 재정의 → shared/constants 카탈로그
## System Flow 뷰어 (개발 단계용)
- **URL**: https://kcg-ai-monitoring.gc-si.dev/system-flow.html (메인 SPA와 별개)

파일 보기

@ -12,7 +12,7 @@ Phase 2에서 초기화 예정.
## 책임
- 자체 인증/권한/감사로그
- 운영자 의사결정 (모선 확정/제외/학습)
- prediction 분석 결과 조회 API (`/api/analysis/*`)
- iran 백엔드 분석 데이터 프록시
- 관리자 화면 API
상세 설계: `.claude/plans/vast-tinkering-knuth.md`

파일 보기

@ -142,7 +142,6 @@
<goal>compile</goal>
</goals>
<configuration>
<parameters>true</parameters>
<annotationProcessorPaths>
<path>
<groupId>org.springframework.boot</groupId>
@ -162,7 +161,6 @@
<goal>testCompile</goal>
</goals>
<configuration>
<parameters>true</parameters>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>

파일 보기

@ -1,11 +1,17 @@
package gc.mda.kcg.admin;
import gc.mda.kcg.audit.AccessLogRepository;
import gc.mda.kcg.audit.AuditLogRepository;
import gc.mda.kcg.auth.LoginHistoryRepository;
import gc.mda.kcg.permission.annotation.RequirePermission;
import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
@ -22,23 +28,127 @@ import java.util.Map;
@RequiredArgsConstructor
public class AdminStatsController {
private final AdminStatsService adminStatsService;
private final AuditLogRepository auditLogRepository;
private final AccessLogRepository accessLogRepository;
private final LoginHistoryRepository loginHistoryRepository;
private final JdbcTemplate jdbc;
/**
* 감사 로그 통계.
* - total: 전체 건수
* - last24h: 24시간 건수
* - failed24h: 24시간 FAILED 건수
* - byAction: 액션별 카운트 (top 10)
* - hourly24: 시간별 24시간 추세
*/
@GetMapping("/audit")
@RequirePermission(resource = "admin:audit-logs", operation = "READ")
public Map<String, Object> auditStats() {
return adminStatsService.auditStats();
Map<String, Object> result = new LinkedHashMap<>();
result.put("total", auditLogRepository.count());
result.put("last24h", jdbc.queryForObject(
"SELECT COUNT(*) FROM kcg.auth_audit_log WHERE created_at > now() - interval '24 hours'", Long.class));
result.put("failed24h", jdbc.queryForObject(
"SELECT COUNT(*) FROM kcg.auth_audit_log WHERE created_at > now() - interval '24 hours' AND result = 'FAILED'", Long.class));
List<Map<String, Object>> byAction = jdbc.queryForList(
"SELECT action_cd AS action, COUNT(*) AS count FROM kcg.auth_audit_log " +
"WHERE created_at > now() - interval '7 days' " +
"GROUP BY action_cd ORDER BY count DESC LIMIT 10");
result.put("byAction", byAction);
List<Map<String, Object>> hourly = jdbc.queryForList(
"SELECT date_trunc('hour', created_at) AS hour, COUNT(*) AS count " +
"FROM kcg.auth_audit_log " +
"WHERE created_at > now() - interval '24 hours' " +
"GROUP BY hour ORDER BY hour");
result.put("hourly24", hourly);
return result;
}
/**
* 접근 로그 통계.
* - total: 전체 건수
* - last24h: 24시간
* - error4xx, error5xx: 24시간 에러
* - avgDurationMs: 24시간 평균 응답 시간
* - topPaths: 24시간 호출 많은 경로
*/
@GetMapping("/access")
@RequirePermission(resource = "admin:access-logs", operation = "READ")
public Map<String, Object> accessStats() {
return adminStatsService.accessStats();
Map<String, Object> result = new LinkedHashMap<>();
result.put("total", accessLogRepository.count());
result.put("last24h", jdbc.queryForObject(
"SELECT COUNT(*) FROM kcg.auth_access_log WHERE created_at > now() - interval '24 hours'", Long.class));
result.put("error4xx", jdbc.queryForObject(
"SELECT COUNT(*) FROM kcg.auth_access_log WHERE created_at > now() - interval '24 hours' AND status_code >= 400 AND status_code < 500", Long.class));
result.put("error5xx", jdbc.queryForObject(
"SELECT COUNT(*) FROM kcg.auth_access_log WHERE created_at > now() - interval '24 hours' AND status_code >= 500", Long.class));
Double avg = jdbc.queryForObject(
"SELECT AVG(duration_ms)::float FROM kcg.auth_access_log WHERE created_at > now() - interval '24 hours'",
Double.class);
result.put("avgDurationMs", avg != null ? Math.round(avg * 10) / 10.0 : 0);
List<Map<String, Object>> topPaths = jdbc.queryForList(
"SELECT request_path AS path, COUNT(*) AS count, AVG(duration_ms)::int AS avg_ms " +
"FROM kcg.auth_access_log " +
"WHERE created_at > now() - interval '24 hours' AND request_path NOT LIKE '/actuator%' " +
"GROUP BY request_path ORDER BY count DESC LIMIT 10");
result.put("topPaths", topPaths);
return result;
}
/**
* 로그인 통계.
* - total: 전체 건수
* - success24h: 24시간 성공
* - failed24h: 24시간 실패
* - locked24h: 24시간 잠금
* - successRate: 성공률 (24시간 , %)
* - byUser: 사용자별 성공 카운트 (top 10)
* - daily7d: 7일 일별 추세
*/
@GetMapping("/login")
@RequirePermission(resource = "admin:login-history", operation = "READ")
public Map<String, Object> loginStats() {
return adminStatsService.loginStats();
Map<String, Object> result = new LinkedHashMap<>();
result.put("total", loginHistoryRepository.count());
Long success24h = jdbc.queryForObject(
"SELECT COUNT(*) FROM kcg.auth_login_hist WHERE login_dtm > now() - interval '24 hours' AND result = 'SUCCESS'", Long.class);
Long failed24h = jdbc.queryForObject(
"SELECT COUNT(*) FROM kcg.auth_login_hist WHERE login_dtm > now() - interval '24 hours' AND result = 'FAILED'", Long.class);
Long locked24h = jdbc.queryForObject(
"SELECT COUNT(*) FROM kcg.auth_login_hist WHERE login_dtm > now() - interval '24 hours' AND result = 'LOCKED'", Long.class);
result.put("success24h", success24h);
result.put("failed24h", failed24h);
result.put("locked24h", locked24h);
long total24h = (success24h == null ? 0 : success24h) + (failed24h == null ? 0 : failed24h) + (locked24h == null ? 0 : locked24h);
double rate = total24h == 0 ? 0 : (success24h == null ? 0 : success24h) * 100.0 / total24h;
result.put("successRate", Math.round(rate * 10) / 10.0);
List<Map<String, Object>> byUser = jdbc.queryForList(
"SELECT user_acnt, COUNT(*) AS count FROM kcg.auth_login_hist " +
"WHERE login_dtm > now() - interval '7 days' AND result = 'SUCCESS' " +
"GROUP BY user_acnt ORDER BY count DESC LIMIT 10");
result.put("byUser", byUser);
List<Map<String, Object>> daily = jdbc.queryForList(
"SELECT date_trunc('day', login_dtm) AS day, " +
"COUNT(*) FILTER (WHERE result='SUCCESS') AS success, " +
"COUNT(*) FILTER (WHERE result='FAILED') AS failed, " +
"COUNT(*) FILTER (WHERE result='LOCKED') AS locked " +
"FROM kcg.auth_login_hist " +
"WHERE login_dtm > now() - interval '7 days' " +
"GROUP BY day ORDER BY day");
result.put("daily7d", daily);
return result;
}
}

파일 보기

@ -1,124 +0,0 @@
package gc.mda.kcg.admin;
import gc.mda.kcg.audit.AccessLogRepository;
import gc.mda.kcg.audit.AuditLogRepository;
import gc.mda.kcg.auth.LoginHistoryRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 시스템 관리 대시보드 메트릭 서비스.
* 감사 로그 / 접근 로그 / 로그인 이력의 집계 쿼리를 담당한다.
*/
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class AdminStatsService {
private final AuditLogRepository auditLogRepository;
private final AccessLogRepository accessLogRepository;
private final LoginHistoryRepository loginHistoryRepository;
private final JdbcTemplate jdbc;
/**
* 감사 로그 통계.
*/
public Map<String, Object> auditStats() {
Map<String, Object> result = new LinkedHashMap<>();
result.put("total", auditLogRepository.count());
result.put("last24h", jdbc.queryForObject(
"SELECT COUNT(*) FROM kcg.auth_audit_log WHERE created_at > now() - interval '24 hours'", Long.class));
result.put("failed24h", jdbc.queryForObject(
"SELECT COUNT(*) FROM kcg.auth_audit_log WHERE created_at > now() - interval '24 hours' AND result = 'FAILED'", Long.class));
List<Map<String, Object>> byAction = jdbc.queryForList(
"SELECT action_cd AS action, COUNT(*) AS count FROM kcg.auth_audit_log " +
"WHERE created_at > now() - interval '7 days' " +
"GROUP BY action_cd ORDER BY count DESC LIMIT 10");
result.put("byAction", byAction);
List<Map<String, Object>> hourly = jdbc.queryForList(
"SELECT date_trunc('hour', created_at) AS hour, COUNT(*) AS count " +
"FROM kcg.auth_audit_log " +
"WHERE created_at > now() - interval '24 hours' " +
"GROUP BY hour ORDER BY hour");
result.put("hourly24", hourly);
return result;
}
/**
* 접근 로그 통계.
*/
public Map<String, Object> accessStats() {
Map<String, Object> result = new LinkedHashMap<>();
result.put("total", accessLogRepository.count());
result.put("last24h", jdbc.queryForObject(
"SELECT COUNT(*) FROM kcg.auth_access_log WHERE created_at > now() - interval '24 hours'", Long.class));
result.put("error4xx", jdbc.queryForObject(
"SELECT COUNT(*) FROM kcg.auth_access_log WHERE created_at > now() - interval '24 hours' AND status_code >= 400 AND status_code < 500", Long.class));
result.put("error5xx", jdbc.queryForObject(
"SELECT COUNT(*) FROM kcg.auth_access_log WHERE created_at > now() - interval '24 hours' AND status_code >= 500", Long.class));
Double avg = jdbc.queryForObject(
"SELECT AVG(duration_ms)::float FROM kcg.auth_access_log WHERE created_at > now() - interval '24 hours'",
Double.class);
result.put("avgDurationMs", avg != null ? Math.round(avg * 10) / 10.0 : 0);
List<Map<String, Object>> topPaths = jdbc.queryForList(
"SELECT request_path AS path, COUNT(*) AS count, AVG(duration_ms)::int AS avg_ms " +
"FROM kcg.auth_access_log " +
"WHERE created_at > now() - interval '24 hours' AND request_path NOT LIKE '/actuator%' " +
"GROUP BY request_path ORDER BY count DESC LIMIT 10");
result.put("topPaths", topPaths);
return result;
}
/**
* 로그인 통계.
*/
public Map<String, Object> loginStats() {
Map<String, Object> result = new LinkedHashMap<>();
result.put("total", loginHistoryRepository.count());
Long success24h = jdbc.queryForObject(
"SELECT COUNT(*) FROM kcg.auth_login_hist WHERE login_dtm > now() - interval '24 hours' AND result = 'SUCCESS'", Long.class);
Long failed24h = jdbc.queryForObject(
"SELECT COUNT(*) FROM kcg.auth_login_hist WHERE login_dtm > now() - interval '24 hours' AND result = 'FAILED'", Long.class);
Long locked24h = jdbc.queryForObject(
"SELECT COUNT(*) FROM kcg.auth_login_hist WHERE login_dtm > now() - interval '24 hours' AND result = 'LOCKED'", Long.class);
result.put("success24h", success24h);
result.put("failed24h", failed24h);
result.put("locked24h", locked24h);
long total24h = (success24h == null ? 0 : success24h) + (failed24h == null ? 0 : failed24h) + (locked24h == null ? 0 : locked24h);
double rate = total24h == 0 ? 0 : (success24h == null ? 0 : success24h) * 100.0 / total24h;
result.put("successRate", Math.round(rate * 10) / 10.0);
List<Map<String, Object>> byUser = jdbc.queryForList(
"SELECT user_acnt, COUNT(*) AS count FROM kcg.auth_login_hist " +
"WHERE login_dtm > now() - interval '7 days' AND result = 'SUCCESS' " +
"GROUP BY user_acnt ORDER BY count DESC LIMIT 10");
result.put("byUser", byUser);
List<Map<String, Object>> daily = jdbc.queryForList(
"SELECT date_trunc('day', login_dtm) AS day, " +
"COUNT(*) FILTER (WHERE result='SUCCESS') AS success, " +
"COUNT(*) FILTER (WHERE result='FAILED') AS failed, " +
"COUNT(*) FILTER (WHERE result='LOCKED') AS locked " +
"FROM kcg.auth_login_hist " +
"WHERE login_dtm > now() - interval '7 days' " +
"GROUP BY day ORDER BY day");
result.put("daily7d", daily);
return result;
}
}

파일 보기

@ -4,7 +4,6 @@ import gc.mda.kcg.auth.dto.LoginRequest;
import gc.mda.kcg.auth.dto.UserInfoResponse;
import gc.mda.kcg.auth.provider.AuthProvider;
import gc.mda.kcg.config.AppProperties;
import gc.mda.kcg.menu.MenuConfigService;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
@ -26,7 +25,6 @@ public class AuthController {
private final AuthService authService;
private final JwtService jwtService;
private final AppProperties appProperties;
private final MenuConfigService menuConfigService;
@PostMapping("/login")
public ResponseEntity<?> login(@RequestBody LoginRequest req,
@ -97,8 +95,7 @@ public class AuthController {
u.getUserSttsCd(),
u.getAuthProvider(),
info.roles(),
info.permissions(),
menuConfigService.getActiveMenuConfig()
info.permissions()
);
}

파일 보기

@ -1,7 +1,5 @@
package gc.mda.kcg.auth.dto;
import gc.mda.kcg.menu.MenuConfigDto;
import java.util.List;
import java.util.Map;
@ -14,6 +12,5 @@ public record UserInfoResponse(
String status,
String authProvider,
List<String> roles,
Map<String, List<String>> permissions,
List<MenuConfigDto> menuConfig
Map<String, List<String>> permissions
) {}

파일 보기

@ -12,7 +12,7 @@ import org.springframework.context.annotation.Configuration;
public class AppProperties {
private Prediction prediction = new Prediction();
private SignalBatch signalBatch = new SignalBatch();
private IranBackend iranBackend = new IranBackend();
private Cors cors = new Cors();
private Jwt jwt = new Jwt();
@ -22,7 +22,7 @@ public class AppProperties {
}
@Getter @Setter
public static class SignalBatch {
public static class IranBackend {
private String baseUrl;
}

파일 보기

@ -1,49 +0,0 @@
package gc.mda.kcg.config;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestClient;
/**
* 외부 서비스용 RestClient Bean 정의.
* Proxy controller 들이 @PostConstruct 에서 ad-hoc 생성하던 RestClient 일원화한다.
*/
@Slf4j
@Configuration
@RequiredArgsConstructor
public class RestClientConfig {
private final AppProperties appProperties;
/**
* prediction FastAPI 서비스 호출용.
* base-url: ${PREDICTION_BASE_URL:http://localhost:8001}
*/
@Bean
public RestClient predictionRestClient(RestClient.Builder builder) {
String baseUrl = appProperties.getPrediction().getBaseUrl();
String resolved = (baseUrl != null && !baseUrl.isBlank()) ? baseUrl : "http://localhost:8001";
log.info("predictionRestClient initialized: baseUrl={}", resolved);
return builder
.baseUrl(resolved)
.defaultHeader("Accept", "application/json")
.build();
}
/**
* signal-batch 선박 항적 서비스 호출용.
* base-url: ${SIGNAL_BATCH_BASE_URL:http://192.168.1.18:18090/signal-batch}
*/
@Bean
public RestClient signalBatchRestClient(RestClient.Builder builder) {
String baseUrl = appProperties.getSignalBatch().getBaseUrl();
String resolved = (baseUrl != null && !baseUrl.isBlank()) ? baseUrl : "http://192.168.1.18:18090/signal-batch";
log.info("signalBatchRestClient initialized: baseUrl={}", resolved);
return builder
.baseUrl(resolved)
.defaultHeader("Accept", "application/json")
.build();
}
}

파일 보기

@ -1,27 +0,0 @@
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
) {
}

파일 보기

@ -1,64 +0,0 @@
package gc.mda.kcg.domain.analysis;
import gc.mda.kcg.permission.annotation.RequirePermission;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.web.bind.annotation.*;
/**
* 어구 정체성 충돌(GEAR_IDENTITY_COLLISION) 조회 + 분류 API.
*
* 경로: /api/analysis/gear-collisions
* - GET / 목록 (status/severity/name 필터, hours 윈도우)
* - GET /stats status/severity 집계
* - GET /{id} 단건 상세
* - POST /{id}/resolve 분류 (REVIEWED / CONFIRMED_ILLEGAL / FALSE_POSITIVE / REOPEN)
*/
@RestController
@RequestMapping("/api/analysis/gear-collisions")
@RequiredArgsConstructor
public class GearCollisionController {
private static final String RESOURCE = "detection:gear-collision";
private final GearIdentityCollisionService service;
@GetMapping
@RequirePermission(resource = RESOURCE, operation = "READ")
public Page<GearCollisionResponse> list(
@RequestParam(required = false) String status,
@RequestParam(required = false) String severity,
@RequestParam(required = false) String name,
@RequestParam(defaultValue = "48") int hours,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "50") int size
) {
return service.list(status, severity, name, hours,
PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "lastSeenAt")))
.map(GearCollisionResponse::from);
}
@GetMapping("/stats")
@RequirePermission(resource = RESOURCE, operation = "READ")
public GearCollisionStatsResponse stats(@RequestParam(defaultValue = "48") int hours) {
return service.stats(hours);
}
@GetMapping("/{id}")
@RequirePermission(resource = RESOURCE, operation = "READ")
public GearCollisionResponse get(@PathVariable Long id) {
return GearCollisionResponse.from(service.get(id));
}
@PostMapping("/{id}/resolve")
@RequirePermission(resource = RESOURCE, operation = "UPDATE")
public GearCollisionResponse resolve(
@PathVariable Long id,
@Valid @RequestBody GearCollisionResolveRequest req
) {
return GearCollisionResponse.from(service.resolve(id, req));
}
}

파일 보기

@ -1,18 +0,0 @@
package gc.mda.kcg.domain.analysis;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
/**
* gear_identity_collisions 분류(해결) 액션 요청.
*
* action: REVIEWED | CONFIRMED_ILLEGAL | FALSE_POSITIVE | REOPEN
* note : 선택 (운영자 메모)
*/
public record GearCollisionResolveRequest(
@NotBlank
@Pattern(regexp = "REVIEWED|CONFIRMED_ILLEGAL|FALSE_POSITIVE|REOPEN")
String action,
String note
) {
}

파일 보기

@ -1,57 +0,0 @@
package gc.mda.kcg.domain.analysis;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Map;
/**
* gear_identity_collisions 조회 응답 DTO.
*/
public record GearCollisionResponse(
Long id,
String name,
String mmsiLo,
String mmsiHi,
String parentName,
Long parentVesselId,
OffsetDateTime firstSeenAt,
OffsetDateTime lastSeenAt,
Integer coexistenceCount,
Integer swapCount,
BigDecimal maxDistanceKm,
BigDecimal lastLatLo,
BigDecimal lastLonLo,
BigDecimal lastLatHi,
BigDecimal lastLonHi,
String severity,
String status,
String resolutionNote,
List<Map<String, Object>> evidence,
OffsetDateTime updatedAt
) {
public static GearCollisionResponse from(GearIdentityCollision e) {
return new GearCollisionResponse(
e.getId(),
e.getName(),
e.getMmsiLo(),
e.getMmsiHi(),
e.getParentName(),
e.getParentVesselId(),
e.getFirstSeenAt(),
e.getLastSeenAt(),
e.getCoexistenceCount(),
e.getSwapCount(),
e.getMaxDistanceKm(),
e.getLastLatLo(),
e.getLastLonLo(),
e.getLastLatHi(),
e.getLastLonHi(),
e.getSeverity(),
e.getStatus(),
e.getResolutionNote(),
e.getEvidence(),
e.getUpdatedAt()
);
}
}

파일 보기

@ -1,14 +0,0 @@
package gc.mda.kcg.domain.analysis;
import java.util.Map;
/**
* gear_identity_collisions status/severity 집계 응답.
*/
public record GearCollisionStatsResponse(
long total,
Map<String, Long> byStatus,
Map<String, Long> bySeverity,
int hours
) {
}

파일 보기

@ -1,38 +0,0 @@
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<String> 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()
);
}
}

파일 보기

@ -1,99 +0,0 @@
package gc.mda.kcg.domain.analysis;
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.List;
import java.util.Map;
import java.util.UUID;
/**
* gear_identity_collisions 엔티티 (GEAR_IDENTITY_COLLISION 탐지 패턴).
*
* 동일 어구 이름이 서로 다른 MMSI 같은 cycle 동시 송출되는 공존 이력.
* prediction 엔진이 5분 주기로 UPSERT, 백엔드는 조회 운영자 분류(status) 갱신.
*/
@Entity
@Table(name = "gear_identity_collisions", schema = "kcg")
@Getter
@Setter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
public class GearIdentityCollision {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "name", nullable = false, length = 200)
private String name;
@Column(name = "mmsi_lo", nullable = false, length = 20)
private String mmsiLo;
@Column(name = "mmsi_hi", nullable = false, length = 20)
private String mmsiHi;
@Column(name = "parent_name", length = 100)
private String parentName;
@Column(name = "parent_vessel_id")
private Long parentVesselId;
@Column(name = "first_seen_at", nullable = false)
private OffsetDateTime firstSeenAt;
@Column(name = "last_seen_at", nullable = false)
private OffsetDateTime lastSeenAt;
@Column(name = "coexistence_count", nullable = false)
private Integer coexistenceCount;
@Column(name = "swap_count", nullable = false)
private Integer swapCount;
@Column(name = "max_distance_km", precision = 8, scale = 2)
private BigDecimal maxDistanceKm;
@Column(name = "last_lat_lo", precision = 9, scale = 6)
private BigDecimal lastLatLo;
@Column(name = "last_lon_lo", precision = 10, scale = 6)
private BigDecimal lastLonLo;
@Column(name = "last_lat_hi", precision = 9, scale = 6)
private BigDecimal lastLatHi;
@Column(name = "last_lon_hi", precision = 10, scale = 6)
private BigDecimal lastLonHi;
@Column(name = "severity", nullable = false, length = 20)
private String severity;
@Column(name = "status", nullable = false, length = 30)
private String status;
@Column(name = "resolved_by")
private UUID resolvedBy;
@Column(name = "resolved_at")
private OffsetDateTime resolvedAt;
@Column(name = "resolution_note", columnDefinition = "text")
private String resolutionNote;
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "evidence", columnDefinition = "jsonb")
private List<Map<String, Object>> evidence;
@Column(name = "created_at", nullable = false, updatable = false)
private OffsetDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private OffsetDateTime updatedAt;
}

파일 보기

@ -1,41 +0,0 @@
package gc.mda.kcg.domain.analysis;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.jpa.repository.Query;
import java.time.OffsetDateTime;
import java.util.List;
public interface GearIdentityCollisionRepository
extends JpaRepository<GearIdentityCollision, Long>,
JpaSpecificationExecutor<GearIdentityCollision> {
Page<GearIdentityCollision> findAllByLastSeenAtAfterOrderByLastSeenAtDesc(
OffsetDateTime after, Pageable pageable);
/**
* status 카운트 집계 (hours 윈도우).
* 반환: [{status, count}, ...] Object[] {String status, Long count}
*/
@Query("""
SELECT g.status AS status, COUNT(g) AS cnt
FROM GearIdentityCollision g
WHERE g.lastSeenAt > :after
GROUP BY g.status
""")
List<Object[]> countByStatus(OffsetDateTime after);
/**
* severity 카운트 집계 (hours 윈도우).
*/
@Query("""
SELECT g.severity AS severity, COUNT(g) AS cnt
FROM GearIdentityCollision g
WHERE g.lastSeenAt > :after
GROUP BY g.severity
""")
List<Object[]> countBySeverity(OffsetDateTime after);
}

파일 보기

@ -1,133 +0,0 @@
package gc.mda.kcg.domain.analysis;
import gc.mda.kcg.audit.annotation.Auditable;
import gc.mda.kcg.auth.AuthPrincipal;
import jakarta.persistence.EntityNotFoundException;
import jakarta.persistence.criteria.Predicate;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.OffsetDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
/**
* 어구 정체성 충돌(GEAR_IDENTITY_COLLISION) 조회/분류 서비스.
*
* 조회는 모두 {@link Transactional}(readOnly=true), 분류 액션은 {@link Auditable}
* 감사로그 기록. 상태 전이 화이트리스트는 REVIEWED / CONFIRMED_ILLEGAL / FALSE_POSITIVE / REOPEN.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class GearIdentityCollisionService {
private static final String RESOURCE_TYPE = "GEAR_COLLISION";
private final GearIdentityCollisionRepository repository;
@Transactional(readOnly = true)
public Page<GearIdentityCollision> list(
String status,
String severity,
String name,
int hours,
Pageable pageable
) {
OffsetDateTime after = OffsetDateTime.now().minusHours(Math.max(hours, 1));
Specification<GearIdentityCollision> spec = (root, query, cb) -> {
List<Predicate> preds = new ArrayList<>();
preds.add(cb.greaterThan(root.get("lastSeenAt"), after));
if (status != null && !status.isBlank()) {
preds.add(cb.equal(root.get("status"), status));
}
if (severity != null && !severity.isBlank()) {
preds.add(cb.equal(root.get("severity"), severity));
}
if (name != null && !name.isBlank()) {
preds.add(cb.like(root.get("name"), "%" + name + "%"));
}
return cb.and(preds.toArray(new Predicate[0]));
};
return repository.findAll(spec, pageable);
}
@Transactional(readOnly = true)
public GearIdentityCollision get(Long id) {
return repository.findById(id)
.orElseThrow(() -> new EntityNotFoundException("GEAR_COLLISION_NOT_FOUND: " + id));
}
@Transactional(readOnly = true)
public GearCollisionStatsResponse stats(int hours) {
OffsetDateTime after = OffsetDateTime.now().minusHours(Math.max(hours, 1));
Map<String, Long> byStatus = new HashMap<>();
long total = 0;
for (Object[] row : repository.countByStatus(after)) {
String s = (String) row[0];
long c = ((Number) row[1]).longValue();
byStatus.put(s, c);
total += c;
}
Map<String, Long> bySeverity = new HashMap<>();
for (Object[] row : repository.countBySeverity(after)) {
bySeverity.put((String) row[0], ((Number) row[1]).longValue());
}
return new GearCollisionStatsResponse(total, byStatus, bySeverity, hours);
}
@Auditable(action = "GEAR_COLLISION_RESOLVE", resourceType = RESOURCE_TYPE)
@Transactional
public GearIdentityCollision resolve(Long id, GearCollisionResolveRequest req) {
GearIdentityCollision row = repository.findById(id)
.orElseThrow(() -> new EntityNotFoundException("GEAR_COLLISION_NOT_FOUND: " + id));
AuthPrincipal principal = currentPrincipal();
OffsetDateTime now = OffsetDateTime.now();
switch (req.action().toUpperCase()) {
case "REVIEWED" -> {
row.setStatus("REVIEWED");
row.setResolvedBy(principal != null ? principal.getUserId() : null);
row.setResolvedAt(now);
row.setResolutionNote(req.note());
}
case "CONFIRMED_ILLEGAL" -> {
row.setStatus("CONFIRMED_ILLEGAL");
row.setResolvedBy(principal != null ? principal.getUserId() : null);
row.setResolvedAt(now);
row.setResolutionNote(req.note());
}
case "FALSE_POSITIVE" -> {
row.setStatus("FALSE_POSITIVE");
row.setResolvedBy(principal != null ? principal.getUserId() : null);
row.setResolvedAt(now);
row.setResolutionNote(req.note());
}
case "REOPEN" -> {
row.setStatus("OPEN");
row.setResolvedBy(null);
row.setResolvedAt(null);
row.setResolutionNote(req.note());
}
default -> throw new IllegalArgumentException("UNKNOWN_ACTION: " + req.action());
}
row.setUpdatedAt(now);
return repository.save(row);
}
private AuthPrincipal currentPrincipal() {
var auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null && auth.getPrincipal() instanceof AuthPrincipal p) {
return p;
}
return null;
}
}

파일 보기

@ -0,0 +1,70 @@
package gc.mda.kcg.domain.analysis;
import gc.mda.kcg.config.AppProperties;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClientException;
import java.time.Duration;
import java.util.Map;
/**
* iran 백엔드 REST 클라이언트.
*
* 운영 환경: https://kcg.gc-si.dev (Spring Boot + Prediction 통합)
* 호출 실패 graceful degradation: null 반환 프론트에 응답.
*
* 향후 prediction 이관 IranBackendClient를 PredictionDirectClient로 교체하면 .
*/
@Slf4j
@Component
public class IranBackendClient {
private final RestClient restClient;
private final boolean enabled;
public IranBackendClient(AppProperties appProperties) {
String baseUrl = appProperties.getIranBackend().getBaseUrl();
this.enabled = baseUrl != null && !baseUrl.isBlank();
this.restClient = enabled
? RestClient.builder()
.baseUrl(baseUrl)
.defaultHeader("Accept", "application/json")
.build()
: RestClient.create();
log.info("IranBackendClient initialized: enabled={}, baseUrl={}", enabled, baseUrl);
}
public boolean isEnabled() {
return enabled;
}
/**
* GET 호출 (Map 반환). 실패 null 반환.
*/
public Map<String, Object> getJson(String path) {
if (!enabled) return null;
try {
@SuppressWarnings("unchecked")
Map<String, Object> body = restClient.get().uri(path).retrieve().body(Map.class);
return body;
} catch (RestClientException e) {
log.debug("iran 백엔드 호출 실패: {} - {}", path, e.getMessage());
return null;
}
}
/**
* 임의 타입 GET 호출.
*/
public <T> T getAs(String path, Class<T> responseType) {
if (!enabled) return null;
try {
return restClient.get().uri(path).retrieve().body(responseType);
} catch (RestClientException e) {
log.debug("iran 백엔드 호출 실패: {} - {}", path, e.getMessage());
return null;
}
}
}

파일 보기

@ -2,129 +2,66 @@ package gc.mda.kcg.domain.analysis;
import gc.mda.kcg.permission.annotation.RequirePermission;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClientException;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
/**
* Prediction FastAPI 서비스 프록시.
* 기본 baseUrl: app.prediction.base-url (개발 http://localhost:8001, 운영 http://192.168.1.19:18092)
*
* 엔드포인트:
* GET /api/prediction/health FastAPI /health
* GET /api/prediction/status FastAPI /api/v1/analysis/status
* POST /api/prediction/trigger FastAPI /api/v1/analysis/trigger
* POST /api/prediction/chat stub (Phase 9)
* GET /api/prediction/groups/{key}/history FastAPI /api/v1/groups/{key}/history?hours=
* GET /api/prediction/correlation/{key}/tracks FastAPI /api/v1/correlation/{key}/tracks?hours=&min_score=
* Prediction (Python FastAPI) 서비스 프록시.
* 현재는 stub - Phase 5에서 연결.
*/
@Slf4j
@RestController
@RequestMapping("/api/prediction")
@RequiredArgsConstructor
public class PredictionProxyController {
@Qualifier("predictionRestClient")
private final RestClient predictionClient;
private final IranBackendClient iranClient;
@GetMapping("/health")
public ResponseEntity<?> health() {
return proxyGet("/health", Map.of(
Map<String, Object> data = iranClient.getJson("/api/prediction/health");
if (data == null) {
return ResponseEntity.ok(Map.of(
"status", "DISCONNECTED",
"message", "Prediction 서비스 미연결"
"message", "Prediction 서비스 미연결 (Phase 5에서 연결 예정)"
));
}
return ResponseEntity.ok(data);
}
@GetMapping("/status")
@RequirePermission(resource = "monitoring", operation = "READ")
public ResponseEntity<?> status() {
return proxyGet("/api/v1/analysis/status", Map.of("status", "DISCONNECTED"));
Map<String, Object> data = iranClient.getJson("/api/prediction/status");
if (data == null) {
return ResponseEntity.ok(Map.of("status", "DISCONNECTED"));
}
return ResponseEntity.ok(data);
}
@PostMapping("/trigger")
@RequirePermission(resource = "ai-operations:mlops", operation = "UPDATE")
public ResponseEntity<?> trigger() {
return proxyPost("/api/v1/analysis/trigger", null,
Map.of("ok", false, "message", "Prediction 서비스 미연결"));
return ResponseEntity.ok(Map.of("ok", false, "message", "Prediction 서비스 미연결"));
}
/**
* AI 채팅 프록시 (POST) Phase 9에서 연결.
* AI 채팅 프록시 (POST).
* 향후 prediction 인증 통과 SSE 스트리밍으로 전환.
*/
@PostMapping("/chat")
@RequirePermission(resource = "ai-operations:ai-assistant", operation = "READ")
public ResponseEntity<?> chat(@RequestBody Map<String, Object> body) {
public ResponseEntity<?> chat(@org.springframework.web.bind.annotation.RequestBody Map<String, Object> body) {
// iran 백엔드에 인증 토큰이 필요하므로 현재 stub 응답
// 향후: iranClient에 Bearer 토큰 전달 + SSE 스트리밍
return ResponseEntity.ok(Map.of(
"ok", false,
"serviceAvailable", false,
"message", "Prediction 채팅 인증 연동 대기 중 (Phase 9에서 활성화 예정). 입력: "
+ body.getOrDefault("message", "")
"message", "Prediction 채팅 인증 연동 대기 중 (Phase 9에서 활성화 예정). 입력: " + body.getOrDefault("message", "")
));
}
/**
* 그룹 스냅샷 이력 (FastAPI 위임).
*/
@GetMapping("/groups/{groupKey}/history")
@RequirePermission(resource = "detection:gear-detection", operation = "READ")
public ResponseEntity<?> groupHistory(
@PathVariable String groupKey,
@RequestParam(defaultValue = "24") int hours
) {
return proxyGet("/api/v1/groups/" + groupKey + "/history?hours=" + hours,
Map.of("serviceAvailable", false, "groupKey", groupKey));
}
/**
* 상관관계 궤적 (FastAPI 위임).
*/
@GetMapping("/correlation/{groupKey}/tracks")
@RequirePermission(resource = "detection:gear-detection", operation = "READ")
public ResponseEntity<?> correlationTracks(
@PathVariable String groupKey,
@RequestParam(defaultValue = "24") int hours,
@RequestParam(name = "min_score", required = false) Double minScore
) {
String path = "/api/v1/correlation/" + groupKey + "/tracks?hours=" + hours;
if (minScore != null) path += "&min_score=" + minScore;
return proxyGet(path, Map.of("serviceAvailable", false, "groupKey", groupKey));
}
// 내부 헬퍼
@SuppressWarnings("unchecked")
private ResponseEntity<?> proxyGet(String path, Map<String, Object> fallback) {
try {
Map<String, Object> body = predictionClient.get()
.uri(path)
.retrieve()
.body(Map.class);
return ResponseEntity.ok(body != null ? body : fallback);
} catch (RestClientException e) {
log.debug("Prediction 호출 실패 GET {}: {}", path, e.getMessage());
return ResponseEntity.ok(fallback);
}
}
@SuppressWarnings("unchecked")
private ResponseEntity<?> proxyPost(String path, Object requestBody, Map<String, Object> fallback) {
try {
var spec = predictionClient.post().uri(path);
Map<String, Object> body;
if (requestBody != null) {
body = spec.body(requestBody).retrieve().body(Map.class);
} else {
body = spec.retrieve().body(Map.class);
}
return ResponseEntity.ok(body != null ? body : fallback);
} catch (RestClientException e) {
log.debug("Prediction 호출 실패 POST {}: {}", path, e.getMessage());
return ResponseEntity.ok(fallback);
}
}
}

파일 보기

@ -1,134 +0,0 @@
package gc.mda.kcg.domain.analysis;
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.data.domain.Sort;
import org.springframework.web.bind.annotation.*;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
import java.util.List;
/**
* vessel_analysis_results 직접 조회 API.
* prediction이 kcgaidb에 저장한 분석 결과를 프론트엔드에 직접 제공한다 (/api/analysis/*).
*/
@RestController
@RequestMapping("/api/analysis")
@RequiredArgsConstructor
public class VesselAnalysisController {
private final VesselAnalysisService service;
/**
* 분석 결과 목록 조회 (필터 + 페이징).
* 기본: 최근 1시간 결과.
*/
@GetMapping("/vessels")
@RequirePermission(resource = "detection:dark-vessel", operation = "READ")
public Page<VesselAnalysisResponse> listVessels(
@RequestParam(required = false) String mmsi,
@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,
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<GearDetectionResponse> 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 포함).
*/
@GetMapping("/vessels/{mmsi}")
@RequirePermission(resource = "detection:dark-vessel", operation = "READ")
public VesselAnalysisResponse getLatest(@PathVariable String mmsi) {
return VesselAnalysisResponse.from(service.getLatestByMmsi(mmsi));
}
/**
* 특정 선박 분석 이력 (기본 24시간).
*/
@GetMapping("/vessels/{mmsi}/history")
@RequirePermission(resource = "detection:dark-vessel", operation = "READ")
public List<VesselAnalysisResponse> getHistory(
@PathVariable String mmsi,
@RequestParam(defaultValue = "24") int hours
) {
return service.getHistory(mmsi, hours).stream()
.map(VesselAnalysisResponse::from)
.toList();
}
/**
* 다크 베셀 목록 (최신 분석, MMSI 중복 제거).
*/
@GetMapping("/dark")
@RequirePermission(resource = "detection:dark-vessel", operation = "READ")
public Page<VesselAnalysisResponse> listDarkVessels(
@RequestParam(defaultValue = "1") int hours,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "50") int size
) {
return service.getDarkVessels(hours,
PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "riskScore"))
).map(VesselAnalysisResponse::from);
}
/**
* 환적 의심 목록 (최신 분석, MMSI 중복 제거).
*/
@GetMapping("/transship")
@RequirePermission(resource = "detection:dark-vessel", operation = "READ")
public Page<VesselAnalysisResponse> listTransshipSuspects(
@RequestParam(defaultValue = "1") int hours,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "50") int size
) {
return service.getTransshipSuspects(hours,
PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "riskScore"))
).map(VesselAnalysisResponse::from);
}
}

파일 보기

@ -1,373 +0,0 @@
package gc.mda.kcg.domain.analysis;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import gc.mda.kcg.audit.annotation.Auditable;
import gc.mda.kcg.domain.fleet.ParentResolution;
import gc.mda.kcg.domain.fleet.repository.ParentResolutionRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 어구 그룹/상관관계 직접 DB 조회 서비스.
* kcg.group_polygon_snapshots, kcg.gear_correlation_scores,
* kcg.correlation_param_models JdbcTemplate으로 직접 쿼리.
* ParentResolution 합성은 JPA를 통해 수행.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class VesselAnalysisGroupService {
private final JdbcTemplate jdbc;
private final ParentResolutionRepository parentResolutionRepo;
private final ObjectMapper objectMapper;
/**
* 그룹 목록 (최신 스냅샷 per group_key+sub_cluster_id) + parentResolution 합성.
* 목록에서는 polygon/members를 제외하여 응답 크기를 최소화.
* polygon·members는 detail API에서만 반환.
*
* @param groupType null이면 전체, "GEAR" GEAR_IN_ZONE+GEAR_OUT_ZONE만
*/
public Map<String, Object> getGroups(String groupType) {
List<Map<String, Object>> rows;
// LATERAL JOIN으로 최신 스냅샷만 빠르게 조회 (DISTINCT ON 대비 60x 개선)
String typeFilter = "GEAR".equals(groupType)
? "AND group_type IN ('GEAR_IN_ZONE', 'GEAR_OUT_ZONE')"
: "";
String sql = """
SELECT g.*
FROM (
SELECT DISTINCT group_key, sub_cluster_id
FROM kcg.group_polygon_snapshots
WHERE snapshot_time > NOW() - INTERVAL '1 hour'
%s
) keys
JOIN LATERAL (
SELECT group_type, group_key, group_label, sub_cluster_id, snapshot_time,
ST_AsGeoJSON(polygon)::text AS polygon_geojson,
ST_Y(center_point) AS center_lat, ST_X(center_point) AS center_lon,
area_sq_nm, member_count, zone_id, zone_name, members::text, color
FROM kcg.group_polygon_snapshots
WHERE group_key = keys.group_key AND sub_cluster_id = keys.sub_cluster_id
ORDER BY snapshot_time DESC LIMIT 1
) g ON true
""".formatted(typeFilter);
try {
rows = jdbc.queryForList(sql);
} catch (Exception e) {
log.warn("group_polygon_snapshots 조회 실패: {}", e.getMessage());
return Map.of("serviceAvailable", false, "items", List.of(), "count", 0);
}
// parentResolution 인덱싱
Map<String, ParentResolution> resolutionByKey = new HashMap<>();
for (ParentResolution r : parentResolutionRepo.findAll()) {
resolutionByKey.put(r.getGroupKey() + "::" + r.getSubClusterId(), r);
}
// correlation_scores 실시간 최고 점수 + 후보 일괄 조회
Map<String, Object[]> corrTopByGroup = new HashMap<>();
try {
List<Map<String, Object>> corrRows = jdbc.queryForList("""
SELECT group_key, sub_cluster_id,
MAX(current_score) AS max_score,
COUNT(*) FILTER (WHERE current_score > 0.3) AS candidate_count
FROM kcg.gear_correlation_scores
WHERE model_id = (SELECT id FROM kcg.correlation_param_models WHERE is_default = true LIMIT 1)
AND freeze_state = 'ACTIVE'
GROUP BY group_key, sub_cluster_id
""");
for (Map<String, Object> cr : corrRows) {
String ck = cr.get("group_key") + "::" + cr.get("sub_cluster_id");
corrTopByGroup.put(ck, new Object[]{cr.get("max_score"), cr.get("candidate_count")});
}
} catch (Exception e) {
log.debug("correlation top score 조회 실패: {}", e.getMessage());
}
List<Map<String, Object>> items = new ArrayList<>();
for (Map<String, Object> row : rows) {
Map<String, Object> item = buildGroupItem(row);
String groupKey = String.valueOf(row.get("group_key"));
Object subRaw = row.get("sub_cluster_id");
Integer sub = subRaw == null ? null : Integer.valueOf(subRaw.toString());
String compositeKey = groupKey + "::" + sub;
ParentResolution res = resolutionByKey.get(compositeKey);
// 실시간 최고 점수 (correlation_scores)
Object[] corrTop = corrTopByGroup.get(compositeKey);
if (corrTop != null) {
item.put("liveTopScore", corrTop[0]);
item.put("candidateCount", corrTop[1]);
}
if (res != null) {
Map<String, Object> resolution = new LinkedHashMap<>();
resolution.put("status", res.getStatus());
resolution.put("selectedParentMmsi", res.getSelectedParentMmsi());
resolution.put("selectedParentName", res.getSelectedParentName());
resolution.put("topScore", res.getTopScore());
resolution.put("confidence", res.getConfidence());
resolution.put("secondScore", res.getSecondScore());
resolution.put("scoreMargin", res.getScoreMargin());
resolution.put("decisionSource", res.getDecisionSource());
resolution.put("stableCycles", res.getStableCycles());
resolution.put("approvedAt", res.getApprovedAt());
resolution.put("manualComment", res.getManualComment());
item.put("resolution", resolution);
} else {
item.put("resolution", null);
}
items.add(item);
}
return Map.of("serviceAvailable", true, "count", items.size(), "items", items);
}
/**
* 단일 그룹 상세 (최신 스냅샷 + 24시간 이력).
*/
public Map<String, Object> getGroupDetail(String groupKey) {
String latestSql = """
SELECT group_type, group_key, group_label, sub_cluster_id, snapshot_time,
ST_AsGeoJSON(polygon)::text AS polygon_geojson,
ST_Y(center_point) AS center_lat, ST_X(center_point) AS center_lon,
area_sq_nm, member_count, zone_id, zone_name, members::text, color
FROM kcg.group_polygon_snapshots
WHERE group_key = ?
ORDER BY snapshot_time DESC
LIMIT 1
""";
String historySql = """
SELECT snapshot_time,
ST_Y(center_point) AS center_lat, ST_X(center_point) AS center_lon,
member_count,
ST_AsGeoJSON(polygon)::text AS polygon_geojson,
members::text, sub_cluster_id, area_sq_nm
FROM kcg.group_polygon_snapshots
WHERE group_key = ? AND snapshot_time > NOW() - INTERVAL '24 hours'
ORDER BY snapshot_time ASC
""";
try {
List<Map<String, Object>> latestRows = jdbc.queryForList(latestSql, groupKey);
if (latestRows.isEmpty()) {
return Map.of("serviceAvailable", false, "groupKey", groupKey,
"message", "그룹을 찾을 수 없습니다.");
}
Map<String, Object> latest = buildGroupItem(latestRows.get(0));
List<Map<String, Object>> historyRows = jdbc.queryForList(historySql, groupKey);
List<Map<String, Object>> history = new ArrayList<>();
for (Map<String, Object> row : historyRows) {
Map<String, Object> entry = new LinkedHashMap<>();
entry.put("snapshotTime", row.get("snapshot_time"));
entry.put("centerLat", row.get("center_lat"));
entry.put("centerLon", row.get("center_lon"));
entry.put("memberCount", row.get("member_count"));
entry.put("polygon", parseGeoJson((String) row.get("polygon_geojson")));
entry.put("members", parseJsonArray((String) row.get("members")));
entry.put("subClusterId", row.get("sub_cluster_id"));
entry.put("areaSqNm", row.get("area_sq_nm"));
history.add(entry);
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("groupKey", groupKey);
result.put("latest", latest);
result.put("history", history);
return result;
} catch (Exception e) {
log.warn("getGroupDetail 조회 실패 groupKey={}: {}", groupKey, e.getMessage());
return Map.of("serviceAvailable", false, "groupKey", groupKey);
}
}
/**
* 그룹별 상관관계 점수 목록 (활성 모델 기준).
*/
public Map<String, Object> getGroupCorrelations(String groupKey, Double minScore) {
String sql = """
SELECT s.target_mmsi, s.target_type, s.target_name,
s.current_score AS score, s.streak_count AS streak,
s.observation_count AS observations, s.freeze_state,
0 AS shadow_bonus, s.sub_cluster_id,
s.proximity_ratio, s.visit_score, s.heading_coherence,
m.id AS model_id, m.name AS model_name, m.is_default
FROM kcg.gear_correlation_scores s
JOIN kcg.correlation_param_models m ON s.model_id = m.id
WHERE s.group_key = ? AND m.is_active = true
AND (? IS NULL OR s.current_score >= ?)
ORDER BY s.current_score DESC
""";
try {
List<Map<String, Object>> rows = jdbc.queryForList(
sql, groupKey, minScore, minScore);
List<Map<String, Object>> items = new ArrayList<>();
for (Map<String, Object> row : rows) {
Map<String, Object> item = new LinkedHashMap<>();
item.put("targetMmsi", row.get("target_mmsi"));
item.put("targetType", row.get("target_type"));
item.put("targetName", row.get("target_name"));
item.put("score", row.get("score"));
item.put("streak", row.get("streak"));
item.put("observations", row.get("observations"));
item.put("freezeState", row.get("freeze_state"));
item.put("subClusterId", row.get("sub_cluster_id"));
item.put("proximityRatio", row.get("proximity_ratio"));
item.put("visitScore", row.get("visit_score"));
item.put("headingCoherence", row.get("heading_coherence"));
item.put("modelId", row.get("model_id"));
item.put("modelName", row.get("model_name"));
item.put("isDefault", row.get("is_default"));
items.add(item);
}
return Map.of("groupKey", groupKey, "count", items.size(), "items", items);
} catch (Exception e) {
log.warn("getGroupCorrelations 조회 실패 groupKey={}: {}", groupKey, e.getMessage());
return Map.of("serviceAvailable", false, "groupKey", groupKey, "items", List.of(), "count", 0);
}
}
/**
* 후보 상세 raw metrics (최근 N건).
*/
public Map<String, Object> getCandidateMetrics(String groupKey, String targetMmsi) {
String sql = """
SELECT observed_at, proximity_ratio, visit_score, activity_sync,
dtw_similarity, speed_correlation, heading_coherence, drift_similarity,
shadow_stay, shadow_return, gear_group_active_ratio
FROM kcg.gear_correlation_raw_metrics
WHERE group_key = ? AND target_mmsi = ?
ORDER BY observed_at DESC LIMIT 20
""";
try {
List<Map<String, Object>> rows = jdbc.queryForList(sql, groupKey, targetMmsi);
List<Map<String, Object>> items = new ArrayList<>();
for (Map<String, Object> row : rows) {
Map<String, Object> item = new LinkedHashMap<>();
item.put("observedAt", row.get("observed_at"));
item.put("proximityRatio", row.get("proximity_ratio"));
item.put("visitScore", row.get("visit_score"));
item.put("activitySync", row.get("activity_sync"));
item.put("dtwSimilarity", row.get("dtw_similarity"));
item.put("speedCorrelation", row.get("speed_correlation"));
item.put("headingCoherence", row.get("heading_coherence"));
item.put("driftSimilarity", row.get("drift_similarity"));
item.put("shadowStay", row.get("shadow_stay"));
item.put("shadowReturn", row.get("shadow_return"));
item.put("gearGroupActiveRatio", row.get("gear_group_active_ratio"));
items.add(item);
}
return Map.of("groupKey", groupKey, "targetMmsi", targetMmsi, "count", items.size(), "items", items);
} catch (Exception e) {
log.warn("getCandidateMetrics 실패: {}", e.getMessage());
return Map.of("groupKey", groupKey, "targetMmsi", targetMmsi, "items", List.of(), "count", 0);
}
}
/**
* 모선 확정/제외 처리.
*/
@Auditable(action = "PARENT_RESOLVE", resourceType = "GEAR_GROUP")
public Map<String, Object> resolveParent(String groupKey, String action, String targetMmsi, String comment) {
try {
// 먼저 resolution 존재 확인
List<Map<String, Object>> existing = jdbc.queryForList(
"SELECT id, sub_cluster_id FROM kcg.gear_group_parent_resolution WHERE group_key = ? LIMIT 1",
groupKey);
if (existing.isEmpty()) {
return Map.of("ok", false, "message", "resolution을 찾을 수 없습니다.");
}
Long id = ((Number) existing.get(0).get("id")).longValue();
if ("confirm".equals(action)) {
jdbc.update("""
UPDATE kcg.gear_group_parent_resolution
SET status = 'MANUAL_CONFIRMED', selected_parent_mmsi = ?,
manual_comment = ?, approved_at = NOW(), updated_at = NOW()
WHERE id = ?
""", targetMmsi, comment, id);
} else if ("reject".equals(action)) {
jdbc.update("""
UPDATE kcg.gear_group_parent_resolution
SET rejected_candidate_mmsi = ?, manual_comment = ?,
rejected_at = NOW(), updated_at = NOW()
WHERE id = ?
""", targetMmsi, comment, id);
} else {
return Map.of("ok", false, "message", "알 수 없는 액션: " + action);
}
return Map.of("ok", true, "action", action, "groupKey", groupKey, "targetMmsi", targetMmsi);
} catch (Exception e) {
log.warn("resolveParent 실패: {}", e.getMessage());
return Map.of("ok", false, "message", e.getMessage());
}
}
// 내부 헬퍼
private Map<String, Object> buildGroupItem(Map<String, Object> row) {
Map<String, Object> item = new LinkedHashMap<>();
item.put("groupType", row.get("group_type"));
item.put("groupKey", row.get("group_key"));
item.put("groupLabel", row.get("group_label"));
item.put("subClusterId", row.get("sub_cluster_id"));
item.put("snapshotTime", row.get("snapshot_time"));
item.put("polygon", parseGeoJson((String) row.get("polygon_geojson")));
item.put("centerLat", row.get("center_lat"));
item.put("centerLon", row.get("center_lon"));
item.put("areaSqNm", row.get("area_sq_nm"));
item.put("memberCount", row.get("member_count"));
item.put("zoneId", row.get("zone_id"));
item.put("zoneName", row.get("zone_name"));
item.put("members", parseJsonArray((String) row.get("members")));
item.put("color", row.get("color"));
item.put("resolution", null);
item.put("candidateCount", null);
return item;
}
private Map<String, Object> parseGeoJson(String geoJson) {
if (geoJson == null || geoJson.isBlank()) return null;
try {
return objectMapper.readValue(geoJson, new TypeReference<Map<String, Object>>() {});
} catch (Exception e) {
log.debug("GeoJSON 파싱 실패: {}", e.getMessage());
return null;
}
}
private List<Object> parseJsonArray(String json) {
if (json == null || json.isBlank()) return List.of();
try {
return objectMapper.readValue(json, new TypeReference<List<Object>>() {});
} catch (Exception e) {
log.debug("JSON 배열 파싱 실패: {}", e.getMessage());
return List.of();
}
}
}

파일 보기

@ -1,70 +1,109 @@
package gc.mda.kcg.domain.analysis;
import gc.mda.kcg.domain.fleet.ParentResolution;
import gc.mda.kcg.domain.fleet.repository.ParentResolutionRepository;
import gc.mda.kcg.permission.annotation.RequirePermission;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClientException;
import java.util.List;
import java.util.Map;
import java.util.*;
/**
* 분석 데이터 API group_polygon_snapshots / gear_correlation_scores 직접 DB 조회
* + signal-batch 선박 항적 프록시.
* iran 백엔드 분석 데이터 프록시 + 자체 DB 운영자 결정 합성 (HYBRID).
*
* 라우팅:
* GET /api/vessel-analysis 전체 분석결과 + 통계 (단순 프록시)
* GET /api/vessel-analysis/groups 어구/선단 그룹 + parentResolution 합성
* GET /api/vessel-analysis/groups/{key}/detail 단일 그룹 상세 + 24h 이력
* GET /api/vessel-analysis/groups/{key}/detail 단일 그룹 상세
* GET /api/vessel-analysis/groups/{key}/correlations 상관관계 점수
*
* 권한: detection:gear-detection (READ)
* 권한: detection / detection:gear-detection (READ)
*/
@Slf4j
@RestController
@RequestMapping("/api/vessel-analysis")
@RequiredArgsConstructor
public class VesselAnalysisProxyController {
private final VesselAnalysisGroupService groupService;
@Qualifier("signalBatchRestClient")
private final RestClient signalBatchClient;
private final IranBackendClient iranClient;
private final ParentResolutionRepository resolutionRepository;
@GetMapping
@RequirePermission(resource = "detection:dark-vessel", operation = "READ")
@RequirePermission(resource = "detection", operation = "READ")
public ResponseEntity<?> getVesselAnalysis() {
// vessel_analysis_results 직접 조회는 /api/analysis/vessels 사용.
// 엔드포인트는 하위 호환을 위해 구조 반환.
Map<String, Object> data = iranClient.getJson("/api/vessel-analysis");
if (data == null) {
return ResponseEntity.ok(Map.of(
"serviceAvailable", true,
"message", "vessel_analysis_results는 /api/analysis/vessels 에서 제공됩니다.",
"serviceAvailable", false,
"message", "iran 백엔드 미연결",
"items", List.of(),
"stats", Map.of(),
"count", 0
));
}
// 통과 + 메타데이터 추가
Map<String, Object> enriched = new LinkedHashMap<>(data);
enriched.put("serviceAvailable", true);
return ResponseEntity.ok(enriched);
}
/**
* 그룹 목록 + 자체 DB의 parentResolution 합성.
* 그룹에 resolution 필드 추가.
*/
@GetMapping("/groups")
@RequirePermission(resource = "detection:gear-detection", operation = "READ")
public ResponseEntity<?> getGroups(
@RequestParam(required = false) String groupType
) {
Map<String, Object> result = groupService.getGroups(groupType);
public ResponseEntity<?> getGroups() {
Map<String, Object> data = iranClient.getJson("/api/vessel-analysis/groups");
if (data == null) {
return ResponseEntity.ok(Map.of(
"serviceAvailable", false,
"items", List.of(),
"count", 0
));
}
@SuppressWarnings("unchecked")
List<Map<String, Object>> items = (List<Map<String, Object>>) data.getOrDefault("items", List.of());
// 자체 DB의 모든 resolution을 group_key로 인덱싱
Map<String, ParentResolution> resolutionByKey = new HashMap<>();
for (ParentResolution r : resolutionRepository.findAll()) {
resolutionByKey.put(r.getGroupKey() + "::" + r.getSubClusterId(), r);
}
// 그룹에 합성
for (Map<String, Object> item : items) {
String groupKey = String.valueOf(item.get("groupKey"));
Object subRaw = item.get("subClusterId");
Integer sub = subRaw == null ? null : Integer.valueOf(subRaw.toString());
ParentResolution res = resolutionByKey.get(groupKey + "::" + sub);
if (res != null) {
Map<String, Object> resolution = new LinkedHashMap<>();
resolution.put("status", res.getStatus());
resolution.put("selectedParentMmsi", res.getSelectedParentMmsi());
resolution.put("approvedAt", res.getApprovedAt());
resolution.put("manualComment", res.getManualComment());
item.put("resolution", resolution);
} else {
item.put("resolution", null);
}
}
Map<String, Object> result = new LinkedHashMap<>(data);
result.put("items", items);
result.put("serviceAvailable", true);
return ResponseEntity.ok(result);
}
@GetMapping("/groups/{groupKey}/detail")
@RequirePermission(resource = "detection:gear-detection", operation = "READ")
public ResponseEntity<?> getGroupDetail(@PathVariable String groupKey) {
Map<String, Object> result = groupService.getGroupDetail(groupKey);
return ResponseEntity.ok(result);
Map<String, Object> data = iranClient.getJson("/api/vessel-analysis/groups/" + groupKey + "/detail");
if (data == null) {
return ResponseEntity.ok(Map.of("serviceAvailable", false, "groupKey", groupKey));
}
return ResponseEntity.ok(data);
}
@GetMapping("/groups/{groupKey}/correlations")
@ -73,57 +112,12 @@ public class VesselAnalysisProxyController {
@PathVariable String groupKey,
@RequestParam(required = false) Double minScore
) {
Map<String, Object> result = groupService.getGroupCorrelations(groupKey, minScore);
return ResponseEntity.ok(result);
}
/**
* 후보 상세 raw metrics (최근 20건 관측 이력).
*/
@GetMapping("/groups/{groupKey}/candidates/{targetMmsi}/metrics")
@RequirePermission(resource = "detection:gear-detection", operation = "READ")
public ResponseEntity<?> getCandidateMetrics(
@PathVariable String groupKey,
@PathVariable String targetMmsi
) {
return ResponseEntity.ok(groupService.getCandidateMetrics(groupKey, targetMmsi));
}
/**
* 모선 확정/제외.
* POST body: { "action": "confirm"|"reject", "targetMmsi": "...", "comment": "..." }
*/
@PostMapping("/groups/{groupKey}/resolve")
@RequirePermission(resource = "detection:gear-detection", operation = "UPDATE")
public ResponseEntity<?> resolveParent(
@PathVariable String groupKey,
@RequestBody Map<String, String> body
) {
String action = body.getOrDefault("action", "");
String targetMmsi = body.getOrDefault("targetMmsi", "");
String comment = body.getOrDefault("comment", "");
return ResponseEntity.ok(groupService.resolveParent(groupKey, action, targetMmsi, comment));
}
/**
* 선박 항적 일괄 조회 (signal-batch 프록시).
* POST /api/vessel-analysis/tracks signal-batch /api/v2/tracks/vessels
*/
@PostMapping("/tracks")
@RequirePermission(resource = "detection:gear-detection", operation = "READ")
public ResponseEntity<?> vesselTracks(@RequestBody Map<String, Object> body) {
try {
String json = signalBatchClient.post()
.uri("/api/v2/tracks/vessels")
.body(body)
.retrieve()
.body(String.class);
return ResponseEntity.ok()
.header("Content-Type", "application/json")
.body(json != null ? json : "[]");
} catch (RestClientException e) {
log.warn("signal-batch 항적 조회 실패: {}", e.getMessage());
return ResponseEntity.ok("[]");
String path = "/api/vessel-analysis/groups/" + groupKey + "/correlations";
if (minScore != null) path += "?minScore=" + minScore;
Map<String, Object> data = iranClient.getJson(path);
if (data == null) {
return ResponseEntity.ok(Map.of("serviceAvailable", false, "groupKey", groupKey));
}
return ResponseEntity.ok(data);
}
}

파일 보기

@ -1,118 +0,0 @@
package gc.mda.kcg.domain.analysis;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/**
* vessel_analysis_results 읽기 전용 Repository.
*/
public interface VesselAnalysisRepository
extends JpaRepository<VesselAnalysisResult, Long>, JpaSpecificationExecutor<VesselAnalysisResult> {
/**
* 특정 선박의 최신 분석 결과.
*/
Optional<VesselAnalysisResult> findTopByMmsiOrderByAnalyzedAtDesc(String mmsi);
/**
* 특정 선박의 분석 이력 (시간 범위).
*/
List<VesselAnalysisResult> findByMmsiAndAnalyzedAtAfterOrderByAnalyzedAtDesc(
String mmsi, OffsetDateTime after);
/**
* 다크 베셀 목록 (최근 분석 결과, MMSI 중복 제거).
*/
@Query("""
SELECT v FROM VesselAnalysisResult v
WHERE v.isDark = true AND v.analyzedAt > :after
AND v.analyzedAt = (
SELECT MAX(v2.analyzedAt) FROM VesselAnalysisResult v2
WHERE v2.mmsi = v.mmsi AND v2.analyzedAt > :after
)
ORDER BY v.riskScore DESC
""")
Page<VesselAnalysisResult> findLatestDarkVessels(
@Param("after") OffsetDateTime after, Pageable pageable);
/**
* 환적 의심 목록 (최근 분석 결과, MMSI 중복 제거).
*/
@Query("""
SELECT v FROM VesselAnalysisResult v
WHERE v.transshipSuspect = true AND v.analyzedAt > :after
AND v.analyzedAt = (
SELECT MAX(v2.analyzedAt) FROM VesselAnalysisResult v2
WHERE v2.mmsi = v.mmsi AND v2.analyzedAt > :after
)
ORDER BY v.riskScore DESC
""")
Page<VesselAnalysisResult> 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<VesselAnalysisResult> 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<String, Object> aggregateStats(
@Param("after") OffsetDateTime after,
@Param("mmsiPrefix") String mmsiPrefix);
}

파일 보기

@ -1,95 +0,0 @@
package gc.mda.kcg.domain.analysis;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Map;
/**
* vessel_analysis_results 응답 DTO.
* 프론트엔드에서 필요한 핵심 필드만 포함.
*/
public record VesselAnalysisResponse(
Long id,
String mmsi,
OffsetDateTime analyzedAt,
// 분류
String vesselType,
BigDecimal confidence,
BigDecimal fishingPct,
Integer clusterId,
String season,
// 위치
Double lat,
Double lon,
String zoneCode,
BigDecimal distToBaselineNm,
// 행동
String activityState,
BigDecimal ucafScore,
BigDecimal ucftScore,
// 위협
Boolean isDark,
Integer gapDurationMin,
String darkPattern,
BigDecimal spoofingScore,
BigDecimal bd09OffsetM,
Integer speedJumpCount,
// 환적
Boolean transshipSuspect,
String transshipPairMmsi,
Integer transshipDurationMin,
// 선단
Integer fleetClusterId,
String fleetRole,
Boolean fleetIsLeader,
// 위험도
Integer riskScore,
String riskLevel,
// 확장
String gearCode,
String gearJudgment,
String permitStatus,
List<String> violationCategories,
// features
Map<String, Object> features
) {
public static VesselAnalysisResponse from(VesselAnalysisResult e) {
return new VesselAnalysisResponse(
e.getId(),
e.getMmsi(),
e.getAnalyzedAt(),
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(),
e.getTransshipDurationMin(),
e.getFleetClusterId(),
e.getFleetRole(),
e.getFleetIsLeader(),
e.getRiskScore(),
e.getRiskLevel(),
e.getGearCode(),
e.getGearJudgment(),
e.getPermitStatus(),
e.getViolationCategories(),
e.getFeatures()
);
}
}

파일 보기

@ -1,140 +0,0 @@
package gc.mda.kcg.domain.analysis;
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.List;
import java.util.Map;
/**
* vessel_analysis_results 읽기 전용 Entity.
* prediction 엔진이 5분 주기로 INSERT, 백엔드는 READ만 수행.
*
* DB PK는 (id, analyzed_at) 복합키(파티션)이지만,
* BIGSERIAL id가 전역 유니크이므로 JPA에서는 id만 @Id로 매핑.
*/
@Entity
@Table(name = "vessel_analysis_results", schema = "kcg")
@Getter @NoArgsConstructor(access = AccessLevel.PROTECTED)
public class VesselAnalysisResult {
@Id
private Long id;
@Column(name = "mmsi", nullable = false, length = 20)
private String mmsi;
@Column(name = "analyzed_at", nullable = false)
private OffsetDateTime analyzedAt;
// 분류
@Column(name = "vessel_type", length = 30)
private String vesselType;
@Column(name = "confidence", precision = 5, scale = 4)
private BigDecimal confidence;
@Column(name = "fishing_pct", precision = 5, scale = 4)
private BigDecimal fishingPct;
@Column(name = "cluster_id")
private Integer clusterId;
@Column(name = "season", length = 20)
private String season;
// 위치
@Column(name = "lat")
private Double lat;
@Column(name = "lon")
private Double lon;
@Column(name = "zone_code", length = 30)
private String zoneCode;
@Column(name = "dist_to_baseline_nm", precision = 8, scale = 2)
private BigDecimal distToBaselineNm;
// 행동 분석
@Column(name = "activity_state", length = 20)
private String activityState;
@Column(name = "ucaf_score", precision = 5, scale = 4)
private BigDecimal ucafScore;
@Column(name = "ucft_score", precision = 5, scale = 4)
private BigDecimal ucftScore;
// 위협 탐지
@Column(name = "is_dark")
private Boolean isDark;
@Column(name = "gap_duration_min")
private Integer gapDurationMin;
@Column(name = "dark_pattern", length = 30)
private String darkPattern;
@Column(name = "spoofing_score", precision = 5, scale = 4)
private BigDecimal spoofingScore;
@Column(name = "bd09_offset_m", precision = 8, scale = 2)
private BigDecimal bd09OffsetM;
@Column(name = "speed_jump_count")
private Integer speedJumpCount;
// 환적
@Column(name = "transship_suspect")
private Boolean transshipSuspect;
@Column(name = "transship_pair_mmsi", length = 20)
private String transshipPairMmsi;
@Column(name = "transship_duration_min")
private Integer transshipDurationMin;
// 선단
@Column(name = "fleet_cluster_id")
private Integer fleetClusterId;
@Column(name = "fleet_role", length = 20)
private String fleetRole;
@Column(name = "fleet_is_leader")
private Boolean fleetIsLeader;
// 위험도
@Column(name = "risk_score")
private Integer riskScore;
@Column(name = "risk_level", length = 20)
private String riskLevel;
// 확장
@Column(name = "gear_code", length = 20)
private String gearCode;
@Column(name = "gear_judgment", length = 30)
private String gearJudgment;
@Column(name = "permit_status", length = 20)
private String permitStatus;
@JdbcTypeCode(SqlTypes.ARRAY)
@Column(name = "violation_categories", columnDefinition = "text[]")
private List<String> violationCategories;
// features JSONB
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "features", columnDefinition = "jsonb")
private Map<String, Object> features;
@Column(name = "created_at")
private OffsetDateTime createdAt;
}

파일 보기

@ -1,146 +0,0 @@
package gc.mda.kcg.domain.analysis;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
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 직접 조회 서비스.
* prediction이 write한 분석 결과를 프론트엔드에 제공.
*/
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class VesselAnalysisService {
private final VesselAnalysisRepository repository;
/**
* 분석 결과 목록 조회 (동적 필터).
*/
public Page<VesselAnalysisResult> getAnalysisResults(
String mmsi, String zoneCode, String riskLevel, Boolean isDark,
String mmsiPrefix, Integer minRiskScore, BigDecimal minFishingPct,
OffsetDateTime after, Pageable pageable
) {
Specification<VesselAnalysisResult> spec = (root, query, cb) -> cb.conjunction();
if (after != null) {
spec = spec.and((root, query, cb) -> cb.greaterThan(root.get("analyzedAt"), after));
}
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));
}
if (riskLevel != null && !riskLevel.isBlank()) {
spec = spec.and((root, query, cb) -> cb.equal(root.get("riskLevel"), riskLevel));
}
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<String, Object> 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<VesselAnalysisResult> 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<String, Object> row, String key) {
Object v = row.get(key);
if (v == null) return 0L;
return ((Number) v).longValue();
}
private static BigDecimal bigDecimalOf(Map<String, Object> 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());
}
/**
* 특정 선박 최신 분석 결과.
*/
public VesselAnalysisResult getLatestByMmsi(String mmsi) {
return repository.findTopByMmsiOrderByAnalyzedAtDesc(mmsi)
.orElseThrow(() -> new IllegalArgumentException("ANALYSIS_NOT_FOUND: " + mmsi));
}
/**
* 특정 선박 분석 이력 (시간 범위).
*/
public List<VesselAnalysisResult> getHistory(String mmsi, int hours) {
OffsetDateTime after = OffsetDateTime.now().minusHours(hours);
return repository.findByMmsiAndAnalyzedAtAfterOrderByAnalyzedAtDesc(mmsi, after);
}
/**
* 다크 베셀 목록 (최신 분석, MMSI 중복 제거).
*/
public Page<VesselAnalysisResult> getDarkVessels(int hours, Pageable pageable) {
OffsetDateTime after = OffsetDateTime.now().minusHours(hours);
return repository.findLatestDarkVessels(after, pageable);
}
/**
* 환적 의심 목록 (최신 분석, MMSI 중복 제거).
*/
public Page<VesselAnalysisResult> getTransshipSuspects(int hours, Pageable pageable) {
OffsetDateTime after = OffsetDateTime.now().minusHours(hours);
return repository.findLatestTransshipSuspects(after, pageable);
}
}

파일 보기

@ -32,10 +32,9 @@ public class EnforcementController {
@RequirePermission(resource = "enforcement:enforcement-history", operation = "READ")
public Page<EnforcementRecord> listRecords(
@RequestParam(required = false) String violationType,
@RequestParam(required = false) String vesselMmsi,
Pageable pageable
) {
return service.listRecords(violationType, vesselMmsi, pageable);
return service.listRecords(violationType, pageable);
}
/**

파일 보기

@ -1,6 +1,5 @@
package gc.mda.kcg.domain.enforcement;
import gc.mda.kcg.audit.annotation.Auditable;
import gc.mda.kcg.domain.enforcement.dto.CreatePlanRequest;
import gc.mda.kcg.domain.enforcement.dto.CreateRecordRequest;
import gc.mda.kcg.domain.enforcement.dto.UpdateRecordRequest;
@ -33,10 +32,7 @@ public class EnforcementService {
// 단속 이력
// ========================================================================
public Page<EnforcementRecord> listRecords(String violationType, String vesselMmsi, Pageable pageable) {
if (vesselMmsi != null && !vesselMmsi.isBlank()) {
return recordRepository.findByVesselMmsiOrderByEnforcedAtDesc(vesselMmsi, pageable);
}
public Page<EnforcementRecord> listRecords(String violationType, Pageable pageable) {
if (violationType != null && !violationType.isBlank()) {
return recordRepository.findByViolationType(violationType, pageable);
}
@ -49,7 +45,6 @@ public class EnforcementService {
}
@Transactional
@Auditable(action = "ENFORCEMENT_CREATE", resourceType = "ENFORCEMENT")
public EnforcementRecord createRecord(CreateRecordRequest req) {
EnforcementRecord record = EnforcementRecord.builder()
.enfUid(generateEnfUid())
@ -89,7 +84,6 @@ public class EnforcementService {
}
@Transactional
@Auditable(action = "ENFORCEMENT_UPDATE", resourceType = "ENFORCEMENT")
public EnforcementRecord updateRecord(Long id, UpdateRecordRequest req) {
EnforcementRecord record = getRecord(id);
if (req.result() != null) record.setResult(req.result());
@ -110,7 +104,6 @@ public class EnforcementService {
}
@Transactional
@Auditable(action = "ENFORCEMENT_PLAN_CREATE", resourceType = "ENFORCEMENT")
public EnforcementPlan createPlan(CreatePlanRequest req) {
EnforcementPlan plan = EnforcementPlan.builder()
.planUid("PLN-" + LocalDate.now().format(UID_DATE_FMT) + "-" + UUID.randomUUID().toString().substring(0, 4).toUpperCase())

파일 보기

@ -8,5 +8,4 @@ import org.springframework.data.jpa.repository.JpaRepository;
public interface EnforcementRecordRepository extends JpaRepository<EnforcementRecord, Long> {
Page<EnforcementRecord> findAllByOrderByEnforcedAtDesc(Pageable pageable);
Page<EnforcementRecord> findByViolationType(String violationType, Pageable pageable);
Page<EnforcementRecord> findByVesselMmsiOrderByEnforcedAtDesc(String vesselMmsi, Pageable pageable);
}

파일 보기

@ -2,9 +2,12 @@ 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, 푸시 ) 이력을 제공.
@ -14,7 +17,7 @@ import org.springframework.web.bind.annotation.*;
@RequiredArgsConstructor
public class AlertController {
private final AlertService alertService;
private final PredictionAlertRepository alertRepository;
/**
* 알림 목록 조회 (페이징). eventId 파라미터로 특정 이벤트의 알림만 필터 가능.
@ -27,8 +30,10 @@ public class AlertController {
@RequestParam(defaultValue = "20") int size
) {
if (eventId != null) {
return alertService.findByEventId(eventId);
return alertRepository.findByEventIdOrderBySentAtDesc(eventId);
}
return alertService.findAll(PageRequest.of(page, size));
return alertRepository.findAllByOrderBySentAtDesc(
PageRequest.of(page, size)
);
}
}

파일 보기

@ -1,29 +0,0 @@
package gc.mda.kcg.domain.event;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
/**
* 예측 알림 조회 서비스.
* 이벤트에 대해 발송된 알림(SMS/푸시 ) 이력을 조회한다.
*/
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class AlertService {
private final PredictionAlertRepository alertRepository;
public List<PredictionAlert> findByEventId(Long eventId) {
return alertRepository.findByEventIdOrderBySentAtDesc(eventId);
}
public Page<PredictionAlert> findAll(Pageable pageable) {
return alertRepository.findAllByOrderBySentAtDesc(pageable);
}
}

파일 보기

@ -5,8 +5,6 @@ import lombok.*;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
import com.fasterxml.jackson.annotation.JsonIgnore;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
import java.util.Map;
@ -46,7 +44,6 @@ public class PredictionAlert {
@Column(columnDefinition = "jsonb")
private Map<String, Object> metadata;
@JsonIgnore
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "event_id", insertable = false, updatable = false)
private PredictionEvent event;

파일 보기

@ -7,7 +7,6 @@ import org.hibernate.type.SqlTypes;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
import java.util.Map;
import java.util.UUID;
/**
@ -94,10 +93,6 @@ public class PredictionEvent {
@Column(name = "dedup_key", length = 200)
private String dedupKey;
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "features", columnDefinition = "jsonb")
private Map<String, Object> features;
@Column(name = "created_at", nullable = false)
private OffsetDateTime createdAt;

파일 보기

@ -16,8 +16,8 @@ import java.time.OffsetDateTime;
import java.util.List;
/**
* 모선 워크플로우 핵심 서비스.
* - 후보 데이터: prediction이 kcgaidb 저장한 분석 결과를 참조
* 모선 워크플로우 핵심 서비스 (HYBRID).
* - 후보 데이터: iran 백엔드 API 호출 (현재 stub)
* - 운영자 결정: 자체 DB (gear_group_parent_resolution )
*
* 모든 쓰기 액션은 @Auditable로 감사로그 자동 기록.

파일 보기

@ -10,7 +10,7 @@ import java.util.UUID;
/**
* 모선 확정 결과 (운영자 의사결정).
* prediction이 생성한 후보 데이터 별도로 운영자 결정만 자체 DB에 저장.
* iran 백엔드의 후보 데이터(prediction이 생성) 별도로 운영자 결정만 자체 DB에 저장.
*/
@Entity
@Table(name = "gear_group_parent_resolution", schema = "kcg",
@ -34,27 +34,6 @@ public class ParentResolution {
@Column(name = "selected_parent_mmsi", length = 20)
private String selectedParentMmsi;
@Column(name = "selected_parent_name", length = 200)
private String selectedParentName;
@Column(name = "confidence", columnDefinition = "numeric(7,4)")
private java.math.BigDecimal confidence;
@Column(name = "top_score", columnDefinition = "numeric(7,4)")
private java.math.BigDecimal topScore;
@Column(name = "second_score", columnDefinition = "numeric(7,4)")
private java.math.BigDecimal secondScore;
@Column(name = "score_margin", columnDefinition = "numeric(7,4)")
private java.math.BigDecimal scoreMargin;
@Column(name = "decision_source", length = 30)
private String decisionSource;
@Column(name = "stable_cycles", columnDefinition = "integer default 0")
private Integer stableCycles;
@Column(name = "rejected_candidate_mmsi", length = 20)
private String rejectedCandidateMmsi;

파일 보기

@ -27,7 +27,7 @@ public class StatsController {
* 실시간 KPI 전체 목록 조회
*/
@GetMapping("/kpi")
@RequirePermission(resource = "statistics:statistics", operation = "READ")
@RequirePermission(resource = "statistics", operation = "READ")
public List<PredictionKpi> getKpi() {
return kpiRepository.findAll();
}
@ -38,7 +38,7 @@ public class StatsController {
* @param to 종료 (: 2026-04)
*/
@GetMapping("/monthly")
@RequirePermission(resource = "statistics:statistics", operation = "READ")
@RequirePermission(resource = "statistics", operation = "READ")
public List<PredictionStatsMonthly> getMonthly(
@RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate from,
@RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate to
@ -52,7 +52,7 @@ public class StatsController {
* @param to 종료 날짜 (: 2026-04-07)
*/
@GetMapping("/daily")
@RequirePermission(resource = "statistics:statistics", operation = "READ")
@RequirePermission(resource = "statistics", operation = "READ")
public List<PredictionStatsDaily> getDaily(
@RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate from,
@RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate to
@ -65,7 +65,7 @@ public class StatsController {
* @param hours 조회 시간 범위 (기본 24시간)
*/
@GetMapping("/hourly")
@RequirePermission(resource = "statistics:statistics", operation = "READ")
@RequirePermission(resource = "statistics", operation = "READ")
public List<PredictionStatsHourly> getHourly(
@RequestParam(defaultValue = "24") int hours
) {

파일 보기

@ -4,7 +4,9 @@ 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.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
@ -16,7 +18,10 @@ import java.util.List;
@RequiredArgsConstructor
public class MasterDataController {
private final MasterDataService masterDataService;
private final CodeMasterRepository codeMasterRepository;
private final GearTypeRepository gearTypeRepository;
private final PatrolShipRepository patrolShipRepository;
private final VesselPermitRepository vesselPermitRepository;
// ========================================================================
// 코드 마스터 (인증만, 권한 불필요)
@ -24,12 +29,12 @@ public class MasterDataController {
@GetMapping("/api/codes")
public List<CodeMaster> listCodes(@RequestParam String group) {
return masterDataService.listCodes(group);
return codeMasterRepository.findByGroupCodeAndIsActiveTrueOrderBySortOrder(group);
}
@GetMapping("/api/codes/{codeId}/children")
public List<CodeMaster> listChildren(@PathVariable String codeId) {
return masterDataService.listChildren(codeId);
return codeMasterRepository.findByParentIdOrderBySortOrder(codeId);
}
// ========================================================================
@ -38,24 +43,35 @@ public class MasterDataController {
@GetMapping("/api/gear-types")
public List<GearType> listGearTypes() {
return masterDataService.listGearTypes();
return gearTypeRepository.findByIsActiveTrueOrderByDisplayOrder();
}
@GetMapping("/api/gear-types/{gearCode}")
public GearType getGearType(@PathVariable String gearCode) {
return masterDataService.getGearType(gearCode);
return gearTypeRepository.findById(gearCode)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND,
"어구 유형을 찾을 수 없습니다: " + gearCode));
}
@PostMapping("/api/gear-types")
@RequirePermission(resource = "admin:system-config", operation = "CREATE")
public GearType createGearType(@RequestBody GearType gearType) {
return masterDataService.createGearType(gearType);
if (gearTypeRepository.existsById(gearType.getGearCode())) {
throw new ResponseStatusException(HttpStatus.CONFLICT,
"이미 존재하는 어구 코드입니다: " + gearType.getGearCode());
}
return gearTypeRepository.save(gearType);
}
@PutMapping("/api/gear-types/{gearCode}")
@RequirePermission(resource = "admin:system-config", operation = "UPDATE")
public GearType updateGearType(@PathVariable String gearCode, @RequestBody GearType gearType) {
return masterDataService.updateGearType(gearCode, gearType);
if (!gearTypeRepository.existsById(gearCode)) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND,
"어구 유형을 찾을 수 없습니다: " + gearCode);
}
gearType.setGearCode(gearCode);
return gearTypeRepository.save(gearType);
}
// ========================================================================
@ -63,39 +79,58 @@ public class MasterDataController {
// ========================================================================
@GetMapping("/api/patrol-ships")
@RequirePermission(resource = "patrol:patrol-route", operation = "READ")
@RequirePermission(resource = "patrol", operation = "READ")
public List<PatrolShip> listPatrolShips() {
return masterDataService.listPatrolShips();
return patrolShipRepository.findByIsActiveTrueOrderByShipCode();
}
@PatchMapping("/api/patrol-ships/{id}/status")
@RequirePermission(resource = "patrol:patrol-route", operation = "UPDATE")
@RequirePermission(resource = "patrol", operation = "UPDATE")
public PatrolShip updatePatrolShipStatus(
@PathVariable Long id,
@RequestBody PatrolShipStatusRequest request
) {
return masterDataService.updatePatrolShipStatus(id, new MasterDataService.PatrolShipStatusCommand(
request.status(), request.lat(), request.lon(), request.zoneCode(), request.fuelPct()
));
PatrolShip ship = patrolShipRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND,
"함정을 찾을 수 없습니다: " + id));
if (request.status() != null) ship.setCurrentStatus(request.status());
if (request.lat() != null) ship.setCurrentLat(request.lat());
if (request.lon() != null) ship.setCurrentLon(request.lon());
if (request.zoneCode() != null) ship.setCurrentZoneCode(request.zoneCode());
if (request.fuelPct() != null) ship.setFuelPct(request.fuelPct());
return patrolShipRepository.save(ship);
}
// ========================================================================
// 선박 허가 (인증만, 공통 마스터 데이터)
// 선박 허가 (vessel 권한)
// ========================================================================
@GetMapping("/api/vessel-permits")
@RequirePermission(resource = "vessel", operation = "READ")
public Page<VesselPermit> listVesselPermits(
@RequestParam(required = false) String flag,
@RequestParam(required = false) String permitStatus,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size
) {
return masterDataService.listVesselPermits(flag, permitStatus, PageRequest.of(page, size));
PageRequest pageable = PageRequest.of(page, size);
if (flag != null) {
return vesselPermitRepository.findByFlagCountry(flag, pageable);
}
if (permitStatus != null) {
return vesselPermitRepository.findByPermitStatus(permitStatus, pageable);
}
return vesselPermitRepository.findAll(pageable);
}
@GetMapping("/api/vessel-permits/{mmsi}")
@RequirePermission(resource = "vessel", operation = "READ")
public VesselPermit getVesselPermit(@PathVariable String mmsi) {
return masterDataService.getVesselPermit(mmsi);
return vesselPermitRepository.findByMmsi(mmsi)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND,
"선박 허가 정보를 찾을 수 없습니다: " + mmsi));
}
// ========================================================================

파일 보기

@ -1,115 +0,0 @@
package gc.mda.kcg.master;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
/**
* 마스터 데이터(코드/어구/함정/선박허가) 조회 관리 서비스.
*/
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class MasterDataService {
private final CodeMasterRepository codeMasterRepository;
private final GearTypeRepository gearTypeRepository;
private final PatrolShipRepository patrolShipRepository;
private final VesselPermitRepository vesselPermitRepository;
// 코드 마스터
public List<CodeMaster> listCodes(String groupCode) {
return codeMasterRepository.findByGroupCodeAndIsActiveTrueOrderBySortOrder(groupCode);
}
public List<CodeMaster> listChildren(String parentId) {
return codeMasterRepository.findByParentIdOrderBySortOrder(parentId);
}
// 어구 유형
public List<GearType> listGearTypes() {
return gearTypeRepository.findByIsActiveTrueOrderByDisplayOrder();
}
public GearType getGearType(String gearCode) {
return gearTypeRepository.findById(gearCode)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND,
"어구 유형을 찾을 수 없습니다: " + gearCode));
}
@Transactional
public GearType createGearType(GearType gearType) {
if (gearTypeRepository.existsById(gearType.getGearCode())) {
throw new ResponseStatusException(HttpStatus.CONFLICT,
"이미 존재하는 어구 코드입니다: " + gearType.getGearCode());
}
return gearTypeRepository.save(gearType);
}
@Transactional
public GearType updateGearType(String gearCode, GearType gearType) {
if (!gearTypeRepository.existsById(gearCode)) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND,
"어구 유형을 찾을 수 없습니다: " + gearCode);
}
gearType.setGearCode(gearCode);
return gearTypeRepository.save(gearType);
}
// 함정
public List<PatrolShip> listPatrolShips() {
return patrolShipRepository.findByIsActiveTrueOrderByShipCode();
}
@Transactional
public PatrolShip updatePatrolShipStatus(Long id, PatrolShipStatusCommand command) {
PatrolShip ship = patrolShipRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND,
"함정을 찾을 수 없습니다: " + id));
if (command.status() != null) ship.setCurrentStatus(command.status());
if (command.lat() != null) ship.setCurrentLat(command.lat());
if (command.lon() != null) ship.setCurrentLon(command.lon());
if (command.zoneCode() != null) ship.setCurrentZoneCode(command.zoneCode());
if (command.fuelPct() != null) ship.setFuelPct(command.fuelPct());
return patrolShipRepository.save(ship);
}
// 선박 허가
public Page<VesselPermit> listVesselPermits(String flag, String permitStatus, Pageable pageable) {
if (flag != null) {
return vesselPermitRepository.findByFlagCountry(flag, pageable);
}
if (permitStatus != null) {
return vesselPermitRepository.findByPermitStatus(permitStatus, pageable);
}
return vesselPermitRepository.findAll(pageable);
}
public VesselPermit getVesselPermit(String mmsi) {
return vesselPermitRepository.findByMmsi(mmsi)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND,
"선박 허가 정보를 찾을 수 없습니다: " + mmsi));
}
// Command DTO
public record PatrolShipStatusCommand(
String status,
Double lat,
Double lon,
String zoneCode,
Integer fuelPct
) {}
}

파일 보기

@ -1,21 +0,0 @@
package gc.mda.kcg.menu;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* 메뉴 설정 API auth_perm_tree 기반.
*/
@RestController
@RequiredArgsConstructor
public class MenuConfigController {
private final MenuConfigService menuConfigService;
@GetMapping("/api/menu-config")
public List<MenuConfigDto> getMenuConfig() {
return menuConfigService.getActiveMenuConfig();
}
}

파일 보기

@ -1,19 +0,0 @@
package gc.mda.kcg.menu;
import java.util.Map;
public record MenuConfigDto(
String menuCd,
String parentMenuCd,
String menuType,
String urlPath,
String rsrcCd,
String componentKey,
String icon,
String labelKey,
String dividerLabel,
int menuLevel,
int sortOrd,
String useYn,
Map<String, String> labels
) {}

파일 보기

@ -1,115 +0,0 @@
package gc.mda.kcg.menu;
import gc.mda.kcg.permission.PermTree;
import gc.mda.kcg.permission.PermTreeRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.*;
/**
* auth_perm_tree를 메뉴 SSOT로 사용하여 MenuConfigDto 목록을 생성.
* 디바이더는 nav_sub_group 변경 시점에 동적 삽입.
*/
@Service
@RequiredArgsConstructor
public class MenuConfigService {
private final PermTreeRepository permTreeRepository;
// 메뉴 그룹으로 동작하는 Level-0 노드 rsrc_cd
private static final Set<String> GROUP_NODES = Set.of(
"field-ops", "parent-inference-workflow", "admin"
);
@Cacheable("menuConfig")
@Transactional(readOnly = true)
public List<MenuConfigDto> getActiveMenuConfig() {
List<PermTree> all = permTreeRepository.findByUseYn("Y");
List<MenuConfigDto> result = new ArrayList<>();
// 1) 최상위 ITEM (nav_sort > 0, nav_group IS NULL, url_path IS NOT NULL)
List<PermTree> topItems = all.stream()
.filter(n -> n.getNavSort() > 0 && n.getNavGroup() == null && n.getUrlPath() != null)
.sorted(Comparator.comparingInt(PermTree::getNavSort))
.toList();
for (PermTree n : topItems) {
result.add(toDto(n, null, "ITEM", 0));
}
// 2) 그룹 헤더 + 자식 (nav_sort > 0 GROUP_NODES)
List<PermTree> groups = all.stream()
.filter(n -> GROUP_NODES.contains(n.getRsrcCd()) && n.getNavSort() > 0)
.sorted(Comparator.comparingInt(PermTree::getNavSort))
.toList();
for (PermTree g : groups) {
result.add(toDto(g, null, "GROUP", 0));
// 그룹의 자식들
List<PermTree> children = all.stream()
.filter(n -> g.getRsrcCd().equals(n.getNavGroup()) && n.getUrlPath() != null)
.sorted(Comparator.comparingInt(PermTree::getNavSort))
.toList();
// 디바이더 삽입: nav_sub_group 변경 시점마다
String currentSubGroup = null;
int dividerSeq = 0;
for (PermTree c : children) {
String sub = c.getNavSubGroup();
if (sub != null && !sub.equals(currentSubGroup)) {
currentSubGroup = sub;
dividerSeq++;
result.add(new MenuConfigDto(
g.getRsrcCd() + ".div-" + dividerSeq,
g.getRsrcCd(), "DIVIDER",
null, null, null, null, null,
sub, 1, c.getNavSort() - 1, "Y",
Map.of()
));
}
result.add(toDto(c, g.getRsrcCd(), "ITEM", 1));
}
}
// 3) 숨김 라우트 (nav_sort = 0, url_path IS NOT NULL)
List<PermTree> hidden = all.stream()
.filter(n -> n.getNavSort() == 0 && n.getUrlPath() != null && !GROUP_NODES.contains(n.getRsrcCd()))
.toList();
for (PermTree h : hidden) {
result.add(new MenuConfigDto(
h.getRsrcCd(), null, "ITEM",
h.getUrlPath(), h.getRsrcCd(), h.getComponentKey(),
h.getIcon(), h.getLabelKey(), null,
0, 9999, "H",
h.getLabels() != null ? h.getLabels() : Map.of()
));
}
return result;
}
@CacheEvict(value = "menuConfig", allEntries = true)
public void evictCache() {
}
private MenuConfigDto toDto(PermTree n, String parentMenuCd, String menuType, int menuLevel) {
return new MenuConfigDto(
n.getRsrcCd(),
parentMenuCd,
menuType,
n.getUrlPath(),
n.getRsrcCd(),
n.getComponentKey(),
n.getIcon(),
n.getLabelKey(),
n.getNavSubGroup(),
menuLevel,
n.getNavSort(),
n.getUseYn(),
n.getLabels() != null ? n.getLabels() : Map.of()
);
}
}

파일 보기

@ -2,8 +2,6 @@ package gc.mda.kcg.permission;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
import java.time.OffsetDateTime;
@ -41,29 +39,6 @@ public class PermTree {
@Column(name = "use_yn", nullable = false, length = 1)
private String useYn;
// 메뉴 SSOT 컬럼 (V021)
@Column(name = "url_path", length = 200)
private String urlPath;
@Column(name = "label_key", length = 100)
private String labelKey;
@Column(name = "component_key", length = 150)
private String componentKey;
@Column(name = "nav_group", length = 100)
private String navGroup;
@Column(name = "nav_sub_group", length = 100)
private String navSubGroup;
@Column(name = "nav_sort", nullable = false)
private Integer navSort;
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "labels", columnDefinition = "jsonb")
private java.util.Map<String, String> labels;
@Column(name = "created_at", nullable = false)
private OffsetDateTime createdAt;
@ -78,7 +53,6 @@ public class PermTree {
if (useYn == null) useYn = "Y";
if (sortOrd == null) sortOrd = 0;
if (rsrcLevel == null) rsrcLevel = 0;
if (navSort == null) navSort = 0;
}
@PreUpdate

파일 보기

@ -47,16 +47,15 @@ public class PermTreeController {
List<Role> roles = roleRepository.findAllByOrderByRoleSnAsc();
return roles.stream().<Map<String, Object>>map(r -> {
List<Perm> perms = permRepository.findByRoleSn(r.getRoleSn());
java.util.Map<String, Object> result = new java.util.LinkedHashMap<>();
result.put("roleSn", r.getRoleSn());
result.put("roleCd", r.getRoleCd());
result.put("roleNm", r.getRoleNm());
result.put("roleDc", r.getRoleDc() == null ? "" : r.getRoleDc());
result.put("colorHex", r.getColorHex());
result.put("dfltYn", r.getDfltYn());
result.put("builtinYn", r.getBuiltinYn());
result.put("permissions", perms);
return result;
return Map.of(
"roleSn", r.getRoleSn(),
"roleCd", r.getRoleCd(),
"roleNm", r.getRoleNm(),
"roleDc", r.getRoleDc() == null ? "" : r.getRoleDc(),
"dfltYn", r.getDfltYn(),
"builtinYn", r.getBuiltinYn(),
"permissions", perms
);
}).toList();
}

파일 보기

@ -28,10 +28,6 @@ public class Role {
@Column(name = "role_dc", columnDefinition = "text")
private String roleDc;
/** 역할 UI 표기 색상 (#RRGGBB). NULL 가능 — 프론트가 기본 팔레트 사용. */
@Column(name = "color_hex", length = 7)
private String colorHex;
@Column(name = "dflt_yn", nullable = false, length = 1)
private String dfltYn;

파일 보기

@ -38,7 +38,6 @@ public class RoleManagementService {
.roleCd(req.roleCd().toUpperCase())
.roleNm(req.roleNm())
.roleDc(req.roleDc())
.colorHex(req.colorHex())
.dfltYn("Y".equalsIgnoreCase(req.dfltYn()) ? "Y" : "N")
.builtinYn("N")
.build();
@ -71,7 +70,6 @@ public class RoleManagementService {
}
if (req.roleNm() != null) role.setRoleNm(req.roleNm());
if (req.roleDc() != null) role.setRoleDc(req.roleDc());
if (req.colorHex() != null) role.setColorHex(req.colorHex());
if (req.dfltYn() != null) role.setDfltYn("Y".equalsIgnoreCase(req.dfltYn()) ? "Y" : "N");
return roleRepository.save(role);
}

파일 보기

@ -6,6 +6,5 @@ public record RoleCreateRequest(
@NotBlank String roleCd,
@NotBlank String roleNm,
String roleDc,
String colorHex,
String dfltYn
) {}

파일 보기

@ -3,6 +3,5 @@ package gc.mda.kcg.permission.dto;
public record RoleUpdateRequest(
String roleNm,
String roleDc,
String colorHex,
String dfltYn
) {}

파일 보기

@ -1,6 +0,0 @@
config.stopBubbling = true
# @RequiredArgsConstructor 가 생성하는 constructor parameter 에 필드의 @Qualifier 를 복사한다.
# Spring 6.1+ 의 bean 이름 기반 fallback 은 parameter-level annotation 을 요구하므로,
# 필수 처리하지 않으면 여러 bean 중 모호성이 발생해 기동이 실패한다.
lombok.copyableAnnotations += org.springframework.beans.factory.annotation.Qualifier

파일 보기

@ -33,17 +33,13 @@ spring:
cache:
type: caffeine
cache-names: permissions,users,menuConfig
cache-names: permissions,users
caffeine:
spec: maximumSize=1000,expireAfterWrite=10m
server:
port: 8080
forward-headers-strategy: framework
compression:
enabled: true
min-response-size: 1024
mime-types: application/json,application/xml,text/html,text/plain
management:
endpoints:
@ -64,8 +60,9 @@ logging:
app:
prediction:
base-url: ${PREDICTION_BASE_URL:http://localhost:8001}
signal-batch:
base-url: ${SIGNAL_BATCH_BASE_URL:http://192.168.1.18:18090/signal-batch}
iran-backend:
# 운영 환경: https://kcg.gc-si.dev (Spring Boot + Prediction 통합)
base-url: ${IRAN_BACKEND_BASE_URL:https://kcg.gc-si.dev}
cors:
allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:5173,http://localhost:5174}
jwt:

파일 보기

@ -1,18 +0,0 @@
-- ============================================================================
-- V017: auth_role.color_hex 컬럼 추가
-- ============================================================================
-- 역할별 UI 표기 색상을 DB에서 관리.
-- 프론트엔드는 RoleResponse.colorHex로 받아 ColorPicker 카탈로그에 적용.
-- ----------------------------------------------------------------------------
ALTER TABLE kcg.auth_role
ADD COLUMN IF NOT EXISTS color_hex VARCHAR(7);
COMMENT ON COLUMN kcg.auth_role.color_hex IS '역할 표기 색상 (#RRGGBB hex). NULL이면 프론트 기본 팔레트에서 결정.';
-- 빌트인 5개 역할의 기본 색상 (기존 프론트 ROLE_COLORS 정책과 매칭)
UPDATE kcg.auth_role SET color_hex = '#ef4444' WHERE role_cd = 'ADMIN';
UPDATE kcg.auth_role SET color_hex = '#3b82f6' WHERE role_cd = 'OPERATOR';
UPDATE kcg.auth_role SET color_hex = '#a855f7' WHERE role_cd = 'ANALYST';
UPDATE kcg.auth_role SET color_hex = '#22c55e' WHERE role_cd = 'FIELD';
UPDATE kcg.auth_role SET color_hex = '#eab308' WHERE role_cd = 'VIEWER';

파일 보기

@ -1,11 +0,0 @@
-- ============================================================
-- V018: prediction_events에 features JSONB 컬럼 추가
-- event_generator가 분석 결과의 핵심 특성(dark_tier, transship_score 등)을
-- 이벤트와 함께 저장하여 프론트엔드에서 직접 활용할 수 있도록 한다.
-- ============================================================
ALTER TABLE kcg.prediction_events
ADD COLUMN IF NOT EXISTS features JSONB;
COMMENT ON COLUMN kcg.prediction_events.features IS
'분석 결과 핵심 특성 (dark_tier, dark_suspicion_score, transship_tier, transship_score 등)';

파일 보기

@ -1,16 +0,0 @@
-- ============================================================
-- V019: LLM 운영 페이지 권한 트리 항목 추가
-- PR #22에서 추가된 /llm-ops 페이지에 대응하는 권한 리소스
-- ============================================================
INSERT INTO kcg.auth_perm_tree(rsrc_cd, parent_cd, rsrc_nm, rsrc_level, sort_ord)
VALUES ('ai-operations:llm-ops', 'ai-operations', 'LLM 운영', 1, 35)
ON CONFLICT (rsrc_cd) DO NOTHING;
-- ADMIN 역할에 자동 부여
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
SELECT r.role_sn, 'ai-operations:llm-ops', 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;

파일 보기

@ -1,97 +0,0 @@
-- ============================================================================
-- V020: 메뉴 설정 SSOT 테이블
-- 프론트엔드 사이드바 + 라우팅 + 권한 매핑의 단일 진실 공급원
-- ============================================================================
CREATE TABLE kcg.menu_config (
menu_cd VARCHAR(100) PRIMARY KEY,
parent_menu_cd VARCHAR(100) REFERENCES kcg.menu_config(menu_cd) ON DELETE CASCADE,
menu_type VARCHAR(10) NOT NULL DEFAULT 'ITEM',
url_path VARCHAR(200),
rsrc_cd VARCHAR(100) REFERENCES kcg.auth_perm_tree(rsrc_cd),
component_key VARCHAR(150),
icon VARCHAR(50),
label_key VARCHAR(100),
divider_label VARCHAR(100),
menu_level INT NOT NULL DEFAULT 0,
sort_ord INT NOT NULL DEFAULT 0,
use_yn VARCHAR(1) NOT NULL DEFAULT 'Y',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
COMMENT ON TABLE kcg.menu_config IS '메뉴 레이아웃 SSOT — 사이드바, 라우팅, 경로→권한 매핑 구동';
COMMENT ON COLUMN kcg.menu_config.menu_type IS 'ITEM(네비게이션) | GROUP(접기/펼치기) | DIVIDER(서브그룹 라벨)';
COMMENT ON COLUMN kcg.menu_config.rsrc_cd IS 'auth_perm_tree FK. 권한 체크용. GROUP/DIVIDER는 NULL';
COMMENT ON COLUMN kcg.menu_config.component_key IS '프론트 COMPONENT_REGISTRY 키. GROUP/DIVIDER는 NULL';
COMMENT ON COLUMN kcg.menu_config.use_yn IS 'Y=사이드바+라우트, H=라우트만(숨김), N=비활성';
CREATE INDEX idx_menu_config_parent ON kcg.menu_config(parent_menu_cd);
CREATE INDEX idx_menu_config_sort ON kcg.menu_config(menu_level, sort_ord);
CREATE INDEX idx_menu_config_rsrc ON kcg.menu_config(rsrc_cd);
-- ============================================================================
-- 시드 데이터: 현재 NAV_ENTRIES + Route 정의 기반 35건
-- sort_ord 100 간격 (삽입 여유)
-- ============================================================================
-- ─── Top-level ITEM (13개) ─────────────────────────────────────────────────
INSERT INTO kcg.menu_config(menu_cd, parent_menu_cd, menu_type, url_path, rsrc_cd, component_key, icon, label_key, menu_level, sort_ord) VALUES
('dashboard', NULL, 'ITEM', '/dashboard', 'dashboard', 'features/dashboard/Dashboard', 'LayoutDashboard', 'nav.dashboard', 0, 100),
('monitoring', NULL, 'ITEM', '/monitoring', 'monitoring', 'features/monitoring/MonitoringDashboard', 'Activity', 'nav.monitoring', 0, 200),
('events', NULL, 'ITEM', '/events', 'surveillance:live-map', 'features/surveillance/LiveMapView', 'Radar', 'nav.realtimeEvent', 0, 300),
('map-control', NULL, 'ITEM', '/map-control', 'surveillance:map-control', 'features/surveillance/MapControl', 'Map', 'nav.mapControl', 0, 400),
('risk-map', NULL, 'ITEM', '/risk-map', 'risk-assessment:risk-map', 'features/risk-assessment/RiskMap', 'Layers', 'nav.riskMap', 0, 500),
('enforcement-plan', NULL, 'ITEM', '/enforcement-plan', 'risk-assessment:enforcement-plan', 'features/risk-assessment/EnforcementPlan', 'Shield', 'nav.enforcementPlan', 0, 600),
('dark-vessel', NULL, 'ITEM', '/dark-vessel', 'detection:dark-vessel', 'features/detection/DarkVesselDetection', 'EyeOff', 'nav.darkVessel', 0, 700),
('gear-detection', NULL, 'ITEM', '/gear-detection', 'detection:gear-detection', 'features/detection/GearDetection', 'Anchor', 'nav.gearDetection', 0, 800),
('china-fishing', NULL, 'ITEM', '/china-fishing', 'detection:china-fishing', 'features/detection/ChinaFishing', 'Ship', 'nav.chinaFishing', 0, 900),
('enforcement-history', NULL, 'ITEM', '/enforcement-history', 'enforcement:enforcement-history', 'features/enforcement/EnforcementHistory', 'FileText', 'nav.enforcementHistory', 0, 1000),
('event-list', NULL, 'ITEM', '/event-list', 'enforcement:event-list', 'features/enforcement/EventList', 'List', 'nav.eventList', 0, 1100),
('statistics', NULL, 'ITEM', '/statistics', 'statistics:statistics', 'features/statistics/Statistics', 'BarChart3', 'nav.statistics', 0, 1200),
('reports', NULL, 'ITEM', '/reports', 'statistics:statistics', 'features/statistics/ReportManagement', 'FileText', 'nav.reports', 0, 1300);
-- ─── GROUP: 현장작전 ───────────────────────────────────────────────────────
INSERT INTO kcg.menu_config(menu_cd, parent_menu_cd, menu_type, url_path, rsrc_cd, component_key, icon, label_key, menu_level, sort_ord) VALUES
('field-ops', NULL, 'GROUP', NULL, NULL, NULL, 'Ship', 'group.fieldOps', 0, 1400),
('field-ops.patrol', 'field-ops', 'ITEM', '/patrol-route', 'patrol:patrol-route', 'features/patrol/PatrolRoute', 'Navigation', 'nav.patrolRoute', 1, 100),
('field-ops.fleet', 'field-ops', 'ITEM', '/fleet-optimization', 'patrol:fleet-optimization', 'features/patrol/FleetOptimization', 'Users', 'nav.fleetOptimization', 1, 200),
('field-ops.alert', 'field-ops', 'ITEM', '/ai-alert', 'field-ops:ai-alert', 'features/field-ops/AIAlert', 'Send', 'nav.aiAlert', 1, 300),
('field-ops.mobile', 'field-ops', 'ITEM', '/mobile-service', 'field-ops:mobile-service', 'features/field-ops/MobileService', 'Smartphone', 'nav.mobileService', 1, 400),
('field-ops.ship', 'field-ops', 'ITEM', '/ship-agent', 'field-ops:ship-agent', 'features/field-ops/ShipAgent', 'Monitor', 'nav.shipAgent', 1, 500);
-- ─── GROUP: 모선 워크플로우 ────────────────────────────────────────────────
INSERT INTO kcg.menu_config(menu_cd, parent_menu_cd, menu_type, url_path, rsrc_cd, component_key, icon, label_key, menu_level, sort_ord) VALUES
('parent-inference', NULL, 'GROUP', NULL, NULL, NULL, 'GitBranch', 'group.parentInference', 0, 1500),
('parent-inference.review', 'parent-inference', 'ITEM', '/parent-inference/review', 'parent-inference-workflow:parent-review', 'features/parent-inference/ParentReview', 'CheckSquare', 'nav.parentReview', 1, 100),
('parent-inference.exclusion', 'parent-inference', 'ITEM', '/parent-inference/exclusion', 'parent-inference-workflow:parent-exclusion', 'features/parent-inference/ParentExclusion', 'Ban', 'nav.parentExclusion', 1, 200),
('parent-inference.label', 'parent-inference', 'ITEM', '/parent-inference/label-session', 'parent-inference-workflow:label-session', 'features/parent-inference/LabelSession', 'Tag', 'nav.labelSession', 1, 300);
-- ─── GROUP: 관리자 ─────────────────────────────────────────────────────────
INSERT INTO kcg.menu_config(menu_cd, parent_menu_cd, menu_type, url_path, rsrc_cd, component_key, icon, label_key, divider_label, menu_level, sort_ord) VALUES
('admin-group', NULL, 'GROUP', NULL, NULL, NULL, 'Settings', 'group.admin', NULL, 0, 1600),
-- AI 플랫폼
('admin-group.div-ai', 'admin-group', 'DIVIDER', NULL, NULL, NULL, NULL, NULL, 'AI 플랫폼', 1, 100),
('admin-group.ai-model', 'admin-group', 'ITEM', '/ai-model', 'ai-operations:ai-model', 'features/ai-operations/AIModelManagement', 'Brain', 'nav.aiModel', NULL, 1, 200),
('admin-group.mlops', 'admin-group', 'ITEM', '/mlops', 'ai-operations:mlops', 'features/ai-operations/MLOpsPage', 'Cpu', 'nav.mlops', NULL, 1, 300),
('admin-group.llm-ops', 'admin-group', 'ITEM', '/llm-ops', 'ai-operations:llm-ops', 'features/ai-operations/LLMOpsPage', 'Brain', 'nav.llmOps', NULL, 1, 400),
('admin-group.ai-assistant', 'admin-group', 'ITEM', '/ai-assistant', 'ai-operations:ai-assistant', 'features/ai-operations/AIAssistant', 'MessageSquare', 'nav.aiAssistant', NULL, 1, 500),
-- 시스템 운영
('admin-group.div-sys', 'admin-group', 'DIVIDER', NULL, NULL, NULL, NULL, NULL, '시스템 운영', 1, 600),
('admin-group.system-config', 'admin-group', 'ITEM', '/system-config', 'admin:system-config', 'features/admin/SystemConfig', 'Database', 'nav.systemConfig',NULL, 1, 700),
('admin-group.data-hub', 'admin-group', 'ITEM', '/data-hub', 'admin:system-config', 'features/admin/DataHub', 'Wifi', 'nav.dataHub', NULL, 1, 800),
('admin-group.external', 'admin-group', 'ITEM', '/external-service', 'statistics:external-service', 'features/statistics/ExternalService', 'Globe', 'nav.externalService', NULL, 1, 900),
-- 사용자 관리
('admin-group.div-user', 'admin-group', 'DIVIDER', NULL, NULL, NULL, NULL, NULL, '사용자 관리', 1, 1000),
('admin-group.admin', 'admin-group', 'ITEM', '/admin', 'admin', 'features/admin/AdminPanel', 'Settings', 'nav.admin', NULL, 1, 1100),
('admin-group.access', 'admin-group', 'ITEM', '/access-control', 'admin:permission-management', 'features/admin/AccessControl', 'Fingerprint', 'nav.accessControl', NULL, 1, 1200),
('admin-group.notices', 'admin-group', 'ITEM', '/notices', 'admin', 'features/admin/NoticeManagement', 'Megaphone', 'nav.notices', NULL, 1, 1300),
-- 감사·보안
('admin-group.div-audit', 'admin-group', 'DIVIDER', NULL, NULL, NULL, NULL, NULL, '감사·보안', 1, 1400),
('admin-group.audit-logs', 'admin-group', 'ITEM', '/admin/audit-logs', 'admin:audit-logs', 'features/admin/AuditLogs', 'ScrollText', 'nav.auditLogs', NULL, 1, 1500),
('admin-group.access-logs', 'admin-group', 'ITEM', '/admin/access-logs', 'admin:access-logs', 'features/admin/AccessLogs', 'History', 'nav.accessLogs', NULL, 1, 1600),
('admin-group.login-history', 'admin-group', 'ITEM', '/admin/login-history', 'admin:login-history', 'features/admin/LoginHistoryView', 'KeyRound', 'nav.loginHistory',NULL, 1, 1700);
-- ─── 숨김 라우트 (사이드바 미표시, 라우팅만) ───────────────────────────────
INSERT INTO kcg.menu_config(menu_cd, parent_menu_cd, menu_type, url_path, rsrc_cd, component_key, icon, label_key, menu_level, sort_ord, use_yn) VALUES
('vessel-detail', NULL, 'ITEM', '/vessel/:id', 'vessel:vessel-detail', 'features/vessel/VesselDetail', NULL, NULL, 0, 9999, 'H');

파일 보기

@ -1,115 +0,0 @@
-- ============================================================================
-- V021: auth_perm_tree를 메뉴 SSOT로 확장 + menu_config 테이블 폐기
-- 메뉴·권한·감사가 동일 레코드를 참조하여 완전 동기화
-- ============================================================================
-- ──────────────────────────────────────────────────────────────────
-- 1. auth_perm_tree에 메뉴 컬럼 추가
-- ──────────────────────────────────────────────────────────────────
ALTER TABLE kcg.auth_perm_tree ADD COLUMN url_path VARCHAR(200);
ALTER TABLE kcg.auth_perm_tree ADD COLUMN label_key VARCHAR(100);
ALTER TABLE kcg.auth_perm_tree ADD COLUMN component_key VARCHAR(150);
ALTER TABLE kcg.auth_perm_tree ADD COLUMN nav_group VARCHAR(100); -- 소속 메뉴 그룹 (NULL=최상위)
ALTER TABLE kcg.auth_perm_tree ADD COLUMN nav_sub_group VARCHAR(100); -- 디바이더 라벨 (admin 서브그룹)
ALTER TABLE kcg.auth_perm_tree ADD COLUMN nav_sort INT NOT NULL DEFAULT 0; -- 메뉴 정렬 (0=미표시)
-- ──────────────────────────────────────────────────────────────────
-- 2. 공유 리소스 분리 — 1메뉴=1노드 보장
-- ──────────────────────────────────────────────────────────────────
INSERT INTO kcg.auth_perm_tree(rsrc_cd, parent_cd, rsrc_nm, rsrc_level, sort_ord)
VALUES ('statistics:reports', 'statistics', '보고서 관리', 1, 30)
ON CONFLICT (rsrc_cd) DO NOTHING;
INSERT INTO kcg.auth_perm_tree(rsrc_cd, parent_cd, rsrc_nm, rsrc_level, sort_ord)
VALUES ('admin:data-hub', 'admin', '데이터 허브', 1, 85)
ON CONFLICT (rsrc_cd) DO NOTHING;
INSERT INTO kcg.auth_perm_tree(rsrc_cd, parent_cd, rsrc_nm, rsrc_level, sort_ord)
VALUES ('admin:notices', 'admin', '공지사항', 1, 45)
ON CONFLICT (rsrc_cd) DO NOTHING;
-- 신규 노드에 ADMIN 전체 권한 부여
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
SELECT r.role_sn, n.rsrc_cd, op.oper_cd, 'Y'
FROM kcg.auth_role r
CROSS JOIN (VALUES ('statistics:reports'), ('admin:data-hub'), ('admin:notices')) AS n(rsrc_cd)
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;
-- 기존 역할에도 READ 부여 (statistics:reports → VIEWER 이상, admin:* → ADMIN 전용)
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
SELECT r.role_sn, 'statistics:reports', 'READ', 'Y'
FROM kcg.auth_role r WHERE r.role_cd IN ('VIEWER', 'ANALYST', 'OPERATOR', 'FIELD')
ON CONFLICT (role_sn, rsrc_cd, oper_cd) DO NOTHING;
-- ──────────────────────────────────────────────────────────────────
-- 3. 메뉴 데이터 채우기 — 최상위 ITEM (13개)
-- ──────────────────────────────────────────────────────────────────
UPDATE kcg.auth_perm_tree SET url_path='/dashboard', label_key='nav.dashboard', component_key='features/dashboard/Dashboard', nav_sort=100 WHERE rsrc_cd='dashboard';
UPDATE kcg.auth_perm_tree SET url_path='/monitoring', label_key='nav.monitoring', component_key='features/monitoring/MonitoringDashboard', nav_sort=200 WHERE rsrc_cd='monitoring';
UPDATE kcg.auth_perm_tree SET url_path='/events', label_key='nav.realtimeEvent', component_key='features/surveillance/LiveMapView', nav_sort=300 WHERE rsrc_cd='surveillance:live-map';
UPDATE kcg.auth_perm_tree SET url_path='/map-control', label_key='nav.mapControl', component_key='features/surveillance/MapControl', nav_sort=400 WHERE rsrc_cd='surveillance:map-control';
UPDATE kcg.auth_perm_tree SET url_path='/risk-map', label_key='nav.riskMap', component_key='features/risk-assessment/RiskMap', nav_sort=500 WHERE rsrc_cd='risk-assessment:risk-map';
UPDATE kcg.auth_perm_tree SET url_path='/enforcement-plan', label_key='nav.enforcementPlan', component_key='features/risk-assessment/EnforcementPlan', nav_sort=600 WHERE rsrc_cd='risk-assessment:enforcement-plan';
UPDATE kcg.auth_perm_tree SET url_path='/dark-vessel', label_key='nav.darkVessel', component_key='features/detection/DarkVesselDetection', nav_sort=700 WHERE rsrc_cd='detection:dark-vessel';
UPDATE kcg.auth_perm_tree SET url_path='/gear-detection', label_key='nav.gearDetection', component_key='features/detection/GearDetection', nav_sort=800 WHERE rsrc_cd='detection:gear-detection';
UPDATE kcg.auth_perm_tree SET url_path='/china-fishing', label_key='nav.chinaFishing', component_key='features/detection/ChinaFishing', nav_sort=900 WHERE rsrc_cd='detection:china-fishing';
UPDATE kcg.auth_perm_tree SET url_path='/enforcement-history',label_key='nav.enforcementHistory', component_key='features/enforcement/EnforcementHistory', nav_sort=1000 WHERE rsrc_cd='enforcement:enforcement-history';
UPDATE kcg.auth_perm_tree SET url_path='/event-list', label_key='nav.eventList', component_key='features/enforcement/EventList', nav_sort=1100 WHERE rsrc_cd='enforcement:event-list';
UPDATE kcg.auth_perm_tree SET url_path='/statistics', label_key='nav.statistics', component_key='features/statistics/Statistics', nav_sort=1200 WHERE rsrc_cd='statistics:statistics';
UPDATE kcg.auth_perm_tree SET url_path='/reports', label_key='nav.reports', component_key='features/statistics/ReportManagement', nav_sort=1300 WHERE rsrc_cd='statistics:reports';
-- ──────────────────────────────────────────────────────────────────
-- 4. 그룹 헤더 (Level 0 노드에 label_key + nav_sort)
-- ──────────────────────────────────────────────────────────────────
UPDATE kcg.auth_perm_tree SET label_key='group.fieldOps', nav_sort=1400 WHERE rsrc_cd='field-ops';
UPDATE kcg.auth_perm_tree SET label_key='group.parentInference', nav_sort=1500 WHERE rsrc_cd='parent-inference-workflow';
UPDATE kcg.auth_perm_tree SET label_key='group.admin', nav_sort=1600 WHERE rsrc_cd='admin';
-- ──────────────────────────────────────────────────────────────────
-- 5. 그룹 자식 — field-ops
-- ──────────────────────────────────────────────────────────────────
UPDATE kcg.auth_perm_tree SET url_path='/patrol-route', label_key='nav.patrolRoute', component_key='features/patrol/PatrolRoute', nav_group='field-ops', nav_sort=100 WHERE rsrc_cd='patrol:patrol-route';
UPDATE kcg.auth_perm_tree SET url_path='/fleet-optimization', label_key='nav.fleetOptimization', component_key='features/patrol/FleetOptimization', nav_group='field-ops', nav_sort=200 WHERE rsrc_cd='patrol:fleet-optimization';
UPDATE kcg.auth_perm_tree SET url_path='/ai-alert', label_key='nav.aiAlert', component_key='features/field-ops/AIAlert', nav_group='field-ops', nav_sort=300 WHERE rsrc_cd='field-ops:ai-alert';
UPDATE kcg.auth_perm_tree SET url_path='/mobile-service', label_key='nav.mobileService', component_key='features/field-ops/MobileService', nav_group='field-ops', nav_sort=400 WHERE rsrc_cd='field-ops:mobile-service';
UPDATE kcg.auth_perm_tree SET url_path='/ship-agent', label_key='nav.shipAgent', component_key='features/field-ops/ShipAgent', nav_group='field-ops', nav_sort=500 WHERE rsrc_cd='field-ops:ship-agent';
-- ──────────────────────────────────────────────────────────────────
-- 6. 그룹 자식 — parent-inference
-- ──────────────────────────────────────────────────────────────────
UPDATE kcg.auth_perm_tree SET url_path='/parent-inference/review', label_key='nav.parentReview', component_key='features/parent-inference/ParentReview', nav_group='parent-inference-workflow', nav_sort=100 WHERE rsrc_cd='parent-inference-workflow:parent-review';
UPDATE kcg.auth_perm_tree SET url_path='/parent-inference/exclusion', label_key='nav.parentExclusion', component_key='features/parent-inference/ParentExclusion', nav_group='parent-inference-workflow', nav_sort=200 WHERE rsrc_cd='parent-inference-workflow:parent-exclusion';
UPDATE kcg.auth_perm_tree SET url_path='/parent-inference/label-session', label_key='nav.labelSession', component_key='features/parent-inference/LabelSession', nav_group='parent-inference-workflow', nav_sort=300 WHERE rsrc_cd='parent-inference-workflow:label-session';
-- ──────────────────────────────────────────────────────────────────
-- 7. 그룹 자식 — admin (서브그룹 포함)
-- ──────────────────────────────────────────────────────────────────
-- AI 플랫폼
UPDATE kcg.auth_perm_tree SET url_path='/ai-model', label_key='nav.aiModel', component_key='features/ai-operations/AIModelManagement', nav_group='admin', nav_sub_group='AI 플랫폼', nav_sort=200 WHERE rsrc_cd='ai-operations:ai-model';
UPDATE kcg.auth_perm_tree SET url_path='/mlops', label_key='nav.mlops', component_key='features/ai-operations/MLOpsPage', nav_group='admin', nav_sub_group='AI 플랫폼', nav_sort=300 WHERE rsrc_cd='ai-operations:mlops';
UPDATE kcg.auth_perm_tree SET url_path='/llm-ops', label_key='nav.llmOps', component_key='features/ai-operations/LLMOpsPage', nav_group='admin', nav_sub_group='AI 플랫폼', nav_sort=400 WHERE rsrc_cd='ai-operations:llm-ops';
UPDATE kcg.auth_perm_tree SET url_path='/ai-assistant', label_key='nav.aiAssistant', component_key='features/ai-operations/AIAssistant', nav_group='admin', nav_sub_group='AI 플랫폼', nav_sort=500 WHERE rsrc_cd='ai-operations:ai-assistant';
-- 시스템 운영
UPDATE kcg.auth_perm_tree SET url_path='/system-config', label_key='nav.systemConfig', component_key='features/admin/SystemConfig', nav_group='admin', nav_sub_group='시스템 운영', nav_sort=700 WHERE rsrc_cd='admin:system-config';
UPDATE kcg.auth_perm_tree SET url_path='/data-hub', label_key='nav.dataHub', component_key='features/admin/DataHub', nav_group='admin', nav_sub_group='시스템 운영', nav_sort=800 WHERE rsrc_cd='admin:data-hub';
UPDATE kcg.auth_perm_tree SET url_path='/external-service',label_key='nav.externalService',component_key='features/statistics/ExternalService', nav_group='admin', nav_sub_group='시스템 운영', nav_sort=900 WHERE rsrc_cd='statistics:external-service';
-- 사용자 관리
UPDATE kcg.auth_perm_tree SET url_path='/admin', label_key='nav.admin', component_key='features/admin/AdminPanel', nav_group='admin', nav_sub_group='사용자 관리', nav_sort=1100 WHERE rsrc_cd='admin:user-management';
UPDATE kcg.auth_perm_tree SET url_path='/access-control', label_key='nav.accessControl', component_key='features/admin/AccessControl', nav_group='admin', nav_sub_group='사용자 관리', nav_sort=1200 WHERE rsrc_cd='admin:permission-management';
UPDATE kcg.auth_perm_tree SET url_path='/notices', label_key='nav.notices', component_key='features/admin/NoticeManagement', nav_group='admin', nav_sub_group='사용자 관리', nav_sort=1300 WHERE rsrc_cd='admin:notices';
-- 감사·보안
UPDATE kcg.auth_perm_tree SET url_path='/admin/audit-logs', label_key='nav.auditLogs', component_key='features/admin/AuditLogs', nav_group='admin', nav_sub_group='감사·보안', nav_sort=1500 WHERE rsrc_cd='admin:audit-logs';
UPDATE kcg.auth_perm_tree SET url_path='/admin/access-logs', label_key='nav.accessLogs', component_key='features/admin/AccessLogs', nav_group='admin', nav_sub_group='감사·보안', nav_sort=1600 WHERE rsrc_cd='admin:access-logs';
UPDATE kcg.auth_perm_tree SET url_path='/admin/login-history',label_key='nav.loginHistory', component_key='features/admin/LoginHistoryView', nav_group='admin', nav_sub_group='감사·보안', nav_sort=1700 WHERE rsrc_cd='admin:login-history';
-- ──────────────────────────────────────────────────────────────────
-- 8. 숨김 라우트 (라우팅만, 사이드바 미표시)
-- ──────────────────────────────────────────────────────────────────
UPDATE kcg.auth_perm_tree SET url_path='/vessel/:id', component_key='features/vessel/VesselDetail' WHERE rsrc_cd='vessel:vessel-detail';
-- ──────────────────────────────────────────────────────────────────
-- 9. menu_config 테이블 폐기
-- ──────────────────────────────────────────────────────────────────
DROP TABLE IF EXISTS kcg.menu_config;

파일 보기

@ -1,83 +0,0 @@
-- ============================================================================
-- V022: auth_perm_tree에 다국어 라벨 JSONB — DB가 i18n SSOT
-- labels = {"ko": "종합 상황판", "en": "Dashboard"} (언어 추가 시 DDL 변경 불필요)
-- ============================================================================
ALTER TABLE kcg.auth_perm_tree ADD COLUMN labels JSONB NOT NULL DEFAULT '{}';
-- ──────────────────────────────────────────────────────────────────
-- 최상위 ITEM
-- ──────────────────────────────────────────────────────────────────
UPDATE kcg.auth_perm_tree SET labels='{"ko":"종합 상황판","en":"Dashboard"}' WHERE rsrc_cd='dashboard';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"경보 현황판","en":"Alert Monitor"}' WHERE rsrc_cd='monitoring';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"실시간 이벤트","en":"Realtime Events"}' WHERE rsrc_cd='surveillance:live-map';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"해역 관리","en":"Zone Management"}' WHERE rsrc_cd='surveillance:map-control';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"위험도 지도","en":"Risk Map"}' WHERE rsrc_cd='risk-assessment:risk-map';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"단속 계획","en":"Enforcement Plan"}' WHERE rsrc_cd='risk-assessment:enforcement-plan';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"다크베셀 탐지","en":"Dark Vessel Detection"}' WHERE rsrc_cd='detection:dark-vessel';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"어구 탐지","en":"Gear Detection"}' WHERE rsrc_cd='detection:gear-detection';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"중국어선 분석","en":"China Fishing"}' WHERE rsrc_cd='detection:china-fishing';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"단속 이력","en":"Enforcement History"}' WHERE rsrc_cd='enforcement:enforcement-history';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"이벤트 목록","en":"Event List"}' WHERE rsrc_cd='enforcement:event-list';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"통계 분석","en":"Statistics"}' WHERE rsrc_cd='statistics:statistics';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"보고서 관리","en":"Report Management"}' WHERE rsrc_cd='statistics:reports';
-- ──────────────────────────────────────────────────────────────────
-- 그룹 헤더
-- ──────────────────────────────────────────────────────────────────
UPDATE kcg.auth_perm_tree SET labels='{"ko":"현장작전","en":"Field Operations"}' WHERE rsrc_cd='field-ops';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"모선 워크플로우","en":"Parent Inference"}' WHERE rsrc_cd='parent-inference-workflow';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"시스템 관리","en":"Administration"}' WHERE rsrc_cd='admin';
-- ──────────────────────────────────────────────────────────────────
-- field-ops 자식
-- ──────────────────────────────────────────────────────────────────
UPDATE kcg.auth_perm_tree SET labels='{"ko":"순찰경로 추천","en":"Patrol Route"}' WHERE rsrc_cd='patrol:patrol-route';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"다함정 최적화","en":"Fleet Optimization"}' WHERE rsrc_cd='patrol:fleet-optimization';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"AI 알림 발송","en":"AI Alert"}' WHERE rsrc_cd='field-ops:ai-alert';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"모바일 서비스","en":"Mobile Service"}' WHERE rsrc_cd='field-ops:mobile-service';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"함정 Agent","en":"Ship Agent"}' WHERE rsrc_cd='field-ops:ship-agent';
-- ──────────────────────────────────────────────────────────────────
-- parent-inference 자식
-- ──────────────────────────────────────────────────────────────────
UPDATE kcg.auth_perm_tree SET labels='{"ko":"모선 확정/거부","en":"Parent Review"}' WHERE rsrc_cd='parent-inference-workflow:parent-review';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"후보 제외","en":"Exclusion Management"}' WHERE rsrc_cd='parent-inference-workflow:parent-exclusion';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"학습 세션","en":"Label Session"}' WHERE rsrc_cd='parent-inference-workflow:label-session';
-- ──────────────────────────────────────────────────────────────────
-- admin 자식
-- ──────────────────────────────────────────────────────────────────
UPDATE kcg.auth_perm_tree SET labels='{"ko":"AI 모델관리","en":"AI Model Management"}' WHERE rsrc_cd='ai-operations:ai-model';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"MLOps","en":"MLOps"}' WHERE rsrc_cd='ai-operations:mlops';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"LLM 운영","en":"LLM Operations"}' WHERE rsrc_cd='ai-operations:llm-ops';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"AI 의사결정 지원","en":"AI Assistant"}' WHERE rsrc_cd='ai-operations:ai-assistant';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"환경설정","en":"System Config"}' WHERE rsrc_cd='admin:system-config';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"데이터 허브","en":"Data Hub"}' WHERE rsrc_cd='admin:data-hub';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"외부 서비스","en":"External Service"}' WHERE rsrc_cd='statistics:external-service';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"시스템 관리","en":"Admin Panel"}' WHERE rsrc_cd='admin:user-management';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"권한 관리","en":"Access Control"}' WHERE rsrc_cd='admin:permission-management';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"공지사항","en":"Notices"}' WHERE rsrc_cd='admin:notices';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"감사 로그","en":"Audit Logs"}' WHERE rsrc_cd='admin:audit-logs';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"접근 이력","en":"Access Logs"}' WHERE rsrc_cd='admin:access-logs';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"로그인 이력","en":"Login History"}' WHERE rsrc_cd='admin:login-history';
-- ──────────────────────────────────────────────────────────────────
-- 메뉴 미표시 권한 노드 (권한 관리 UI에서만 표시)
-- ──────────────────────────────────────────────────────────────────
UPDATE kcg.auth_perm_tree SET labels='{"ko":"감시","en":"Surveillance"}' WHERE rsrc_cd='surveillance';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"탐지","en":"Detection"}' WHERE rsrc_cd='detection';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"선박","en":"Vessel"}' WHERE rsrc_cd='vessel';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"위험평가","en":"Risk Assessment"}' WHERE rsrc_cd='risk-assessment';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"순찰","en":"Patrol"}' WHERE rsrc_cd='patrol';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"단속","en":"Enforcement"}' WHERE rsrc_cd='enforcement';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"AI 운영","en":"AI Operations"}' WHERE rsrc_cd='ai-operations';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"통계","en":"Statistics"}' WHERE rsrc_cd='statistics';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"선박상세","en":"Vessel Detail"}' WHERE rsrc_cd='vessel:vessel-detail';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"전재탐지","en":"Transfer Detection"}' WHERE rsrc_cd='vessel:transfer-detection';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"알림 목록","en":"Alert List"}' WHERE rsrc_cd='monitoring:alert-list';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"KPI 패널","en":"KPI Panel"}' WHERE rsrc_cd='monitoring:kpi-panel';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"어구식별","en":"Gear Identification"}' WHERE rsrc_cd='detection:gear-identification';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"역할 관리","en":"Role Management"}' WHERE rsrc_cd='admin:role-management';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"메뉴 설정","en":"Menu Config"}' WHERE rsrc_cd='admin:menu-management';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"전역 제외 관리","en":"Global Exclusion"}' WHERE rsrc_cd='parent-inference-workflow:exclusion-management';

파일 보기

@ -1,22 +0,0 @@
-- ============================================================================
-- V023: auth_perm_tree Level-0 sort_ord를 좌측 메뉴 순서와 일치
-- 권한 관리 UI와 사이드바 메뉴의 나열 순서를 동일하게 맞춤
-- ============================================================================
-- Level-0 노드 sort_ord를 메뉴 nav_sort 순서 기준으로 재배치
-- 메뉴 자식이 있는 그룹: 자식 nav_sort 최소값 기준으로 부모 위치 결정
UPDATE kcg.auth_perm_tree SET sort_ord = 100 WHERE rsrc_cd = 'dashboard'; -- nav_sort=100
UPDATE kcg.auth_perm_tree SET sort_ord = 200 WHERE rsrc_cd = 'monitoring'; -- nav_sort=200
UPDATE kcg.auth_perm_tree SET sort_ord = 300 WHERE rsrc_cd = 'surveillance'; -- 자식 nav_sort 300~400
UPDATE kcg.auth_perm_tree SET sort_ord = 500 WHERE rsrc_cd = 'risk-assessment'; -- 자식 nav_sort 500~600
UPDATE kcg.auth_perm_tree SET sort_ord = 700 WHERE rsrc_cd = 'detection'; -- 자식 nav_sort 700~900
UPDATE kcg.auth_perm_tree SET sort_ord = 950 WHERE rsrc_cd = 'statistics'; -- 자식 nav_sort 1200~1300
UPDATE kcg.auth_perm_tree SET sort_ord = 1000 WHERE rsrc_cd = 'enforcement'; -- 자식 nav_sort 1000~1100
UPDATE kcg.auth_perm_tree SET sort_ord = 1400 WHERE rsrc_cd = 'field-ops'; -- nav_sort=1400
UPDATE kcg.auth_perm_tree SET sort_ord = 1500 WHERE rsrc_cd = 'parent-inference-workflow'; -- nav_sort=1500
UPDATE kcg.auth_perm_tree SET sort_ord = 1600 WHERE rsrc_cd = 'admin'; -- nav_sort=1600
-- 메뉴 미표시 Level-0 노드: 관련 메뉴 순서 근처에 배치
UPDATE kcg.auth_perm_tree SET sort_ord = 650 WHERE rsrc_cd = 'vessel'; -- detection 뒤, 단속 앞
UPDATE kcg.auth_perm_tree SET sort_ord = 1350 WHERE rsrc_cd = 'patrol'; -- field-ops 바로 앞
UPDATE kcg.auth_perm_tree SET sort_ord = 1550 WHERE rsrc_cd = 'ai-operations'; -- admin 바로 앞

파일 보기

@ -1,78 +0,0 @@
-- ============================================================================
-- V024: 권한 트리 = 메뉴 트리 완전 동기화
-- 보이지 않는 도메인 그룹 8개 삭제, 자식을 메뉴 구조에 맞게 재배치
-- ============================================================================
-- ──────────────────────────────────────────────────────────────────
-- 1. 그룹 레벨 권한 → 개별 자식 권한으로 확장
-- 예: (VIEWER, detection, READ, Y) → 각 detection 자식에 READ Y 복사
-- ──────────────────────────────────────────────────────────────────
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
SELECT ap.role_sn, c.rsrc_cd, ap.oper_cd, ap.grant_yn
FROM kcg.auth_perm ap
JOIN kcg.auth_perm_tree c ON c.parent_cd = ap.rsrc_cd
WHERE ap.rsrc_cd IN (
'surveillance','detection','risk-assessment','enforcement',
'statistics','patrol','ai-operations','vessel'
)
ON CONFLICT (role_sn, rsrc_cd, oper_cd) DO NOTHING;
-- ──────────────────────────────────────────────────────────────────
-- 2. 그룹 권한 행 삭제
-- ──────────────────────────────────────────────────────────────────
DELETE FROM kcg.auth_perm
WHERE rsrc_cd IN (
'surveillance','detection','risk-assessment','enforcement',
'statistics','patrol','ai-operations','vessel'
);
-- ──────────────────────────────────────────────────────────────────
-- 3. 자식 노드 parent_cd 재배치 (그룹 삭제 전에 수행)
-- ──────────────────────────────────────────────────────────────────
-- 최상위 평탄 아이템: parent_cd → NULL
UPDATE kcg.auth_perm_tree SET parent_cd = NULL
WHERE parent_cd IN (
'surveillance','detection','risk-assessment','enforcement','statistics','vessel'
);
-- patrol 자식 → field-ops 메뉴 그룹으로 이동
UPDATE kcg.auth_perm_tree SET parent_cd = 'field-ops'
WHERE parent_cd = 'patrol';
-- ai-operations 자식 → admin 메뉴 그룹으로 이동
UPDATE kcg.auth_perm_tree SET parent_cd = 'admin'
WHERE parent_cd = 'ai-operations';
-- ──────────────────────────────────────────────────────────────────
-- 4. 그룹 노드 삭제 (자식이 모두 이동된 후)
-- ──────────────────────────────────────────────────────────────────
DELETE FROM kcg.auth_perm_tree
WHERE rsrc_cd IN (
'surveillance','detection','risk-assessment','enforcement',
'statistics','patrol','ai-operations','vessel'
);
-- ──────────────────────────────────────────────────────────────────
-- 5. 패널 노드 parent_cd를 실제 소속 페이지로 수정
-- ──────────────────────────────────────────────────────────────────
-- 전역 제외 관리 → 후보 제외 페이지 내부
UPDATE kcg.auth_perm_tree SET parent_cd = 'parent-inference-workflow:parent-exclusion'
WHERE rsrc_cd = 'parent-inference-workflow:exclusion-management';
-- 역할 관리 → 권한 관리 페이지 내부
UPDATE kcg.auth_perm_tree SET parent_cd = 'admin:permission-management'
WHERE rsrc_cd = 'admin:role-management';
-- 메뉴 설정 → 권한 관리 페이지 내부
UPDATE kcg.auth_perm_tree SET parent_cd = 'admin:permission-management'
WHERE rsrc_cd = 'admin:menu-management';
-- 어구식별 → 어구 탐지 페이지 내부
UPDATE kcg.auth_perm_tree SET parent_cd = 'detection:gear-detection'
WHERE rsrc_cd = 'detection:gear-identification';
-- 전재탐지 → 선박상세 페이지 내부
UPDATE kcg.auth_perm_tree SET parent_cd = 'vessel:vessel-detail'
WHERE rsrc_cd = 'vessel:transfer-detection';

파일 보기

@ -1,76 +0,0 @@
-- ============================================================
-- V025: LGCNS MLOps + AI 보안(SER-10) + AI Agent 보안(SER-11) 메뉴 추가
-- 시스템관리 > AI 플랫폼 / 감사·보안 서브그룹
-- ============================================================
-- ──────────────────────────────────────────────────────────────
-- 1. LGCNS MLOps (시스템관리 > AI 플랫폼, MLOps와 LLM 사이)
-- ──────────────────────────────────────────────────────────────
INSERT INTO kcg.auth_perm_tree(rsrc_cd, parent_cd, rsrc_nm, rsrc_level, sort_ord)
VALUES ('admin:lgcns-mlops', 'admin', 'LGCNS MLOps', 1, 36)
ON CONFLICT (rsrc_cd) DO NOTHING;
UPDATE kcg.auth_perm_tree
SET url_path = '/lgcns-mlops',
label_key = 'nav.lgcnsMlops',
component_key = 'features/ai-operations/LGCNSMLOpsPage',
nav_group = 'admin',
nav_sub_group = 'AI 플랫폼',
nav_sort = 350,
labels = '{"ko":"LGCNS MLOps","en":"LGCNS MLOps"}'
WHERE rsrc_cd = 'admin:lgcns-mlops';
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
SELECT r.role_sn, 'admin:lgcns-mlops', 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;
-- ──────────────────────────────────────────────────────────────
-- 2. AI 보안 (SER-10) (시스템관리 > 감사·보안, 로그인 이력 뒤)
-- ──────────────────────────────────────────────────────────────
INSERT INTO kcg.auth_perm_tree(rsrc_cd, parent_cd, rsrc_nm, rsrc_level, sort_ord)
VALUES ('admin:ai-security', 'admin', 'AI 보안', 1, 55)
ON CONFLICT (rsrc_cd) DO NOTHING;
UPDATE kcg.auth_perm_tree
SET url_path = '/admin/ai-security',
label_key = 'nav.aiSecurity',
component_key = 'features/admin/AISecurityPage',
nav_group = 'admin',
nav_sub_group = '감사·보안',
nav_sort = 1800,
labels = '{"ko":"AI 보안","en":"AI Security"}'
WHERE rsrc_cd = 'admin:ai-security';
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
SELECT r.role_sn, 'admin:ai-security', 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;
-- ──────────────────────────────────────────────────────────────
-- 3. AI Agent 보안 (SER-11) (시스템관리 > 감사·보안, AI 보안 뒤)
-- ──────────────────────────────────────────────────────────────
INSERT INTO kcg.auth_perm_tree(rsrc_cd, parent_cd, rsrc_nm, rsrc_level, sort_ord)
VALUES ('admin:ai-agent-security', 'admin', 'AI Agent 보안', 1, 56)
ON CONFLICT (rsrc_cd) DO NOTHING;
UPDATE kcg.auth_perm_tree
SET url_path = '/admin/ai-agent-security',
label_key = 'nav.aiAgentSecurity',
component_key = 'features/admin/AIAgentSecurityPage',
nav_group = 'admin',
nav_sub_group = '감사·보안',
nav_sort = 1900,
labels = '{"ko":"AI Agent 보안","en":"AI Agent Security"}'
WHERE rsrc_cd = 'admin:ai-agent-security';
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
SELECT r.role_sn, 'admin:ai-agent-security', 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;

파일 보기

@ -1,28 +0,0 @@
-- ============================================================
-- V026: 데이터 보관기간 및 파기 정책 (DAR-10) 메뉴 추가
-- 시스템관리 > 감사·보안 서브그룹
-- ============================================================
-- 1. 권한 트리 노드 등록
INSERT INTO kcg.auth_perm_tree(rsrc_cd, parent_cd, rsrc_nm, rsrc_level, sort_ord)
VALUES ('admin:data-retention', 'admin', '데이터 보관·파기', 1, 57)
ON CONFLICT (rsrc_cd) DO NOTHING;
-- 2. 메뉴 메타데이터 갱신
UPDATE kcg.auth_perm_tree
SET url_path = '/admin/data-retention',
label_key = 'nav.dataRetentionPolicy',
component_key = 'features/admin/DataRetentionPolicy',
nav_group = 'admin',
nav_sub_group = '감사·보안',
nav_sort = 2000,
labels = '{"ko":"데이터 보관·파기","en":"Data Retention"}'
WHERE rsrc_cd = 'admin:data-retention';
-- 3. ADMIN 역할에 전체 권한 부여
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
SELECT r.role_sn, 'admin:data-retention', 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;

파일 보기

@ -1,28 +0,0 @@
-- ============================================================
-- V027: 데이터 모델 검증 (DAR-11) 메뉴 추가
-- 시스템관리 > 감사·보안 서브그룹
-- ============================================================
-- 1. 권한 트리 노드 등록
INSERT INTO kcg.auth_perm_tree(rsrc_cd, parent_cd, rsrc_nm, rsrc_level, sort_ord)
VALUES ('admin:data-model-verification', 'admin', '데이터 모델 검증', 1, 58)
ON CONFLICT (rsrc_cd) DO NOTHING;
-- 2. 메뉴 메타데이터 갱신
UPDATE kcg.auth_perm_tree
SET url_path = '/admin/data-model-verification',
label_key = 'nav.dataModelVerification',
component_key = 'features/admin/DataModelVerification',
nav_group = 'admin',
nav_sub_group = '감사·보안',
nav_sort = 2100,
labels = '{"ko":"데이터 모델 검증","en":"Model Verification"}'
WHERE rsrc_cd = 'admin:data-model-verification';
-- 3. ADMIN 역할에 전체 권한 부여
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
SELECT r.role_sn, 'admin:data-model-verification', 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;

파일 보기

@ -1,28 +0,0 @@
-- ============================================================
-- V028: 성능 모니터링 (PER-01~06) 메뉴 추가
-- 시스템관리 > 감사·보안 서브그룹
-- ============================================================
-- 1. 권한 트리 노드 등록
INSERT INTO kcg.auth_perm_tree(rsrc_cd, parent_cd, rsrc_nm, rsrc_level, sort_ord)
VALUES ('admin:performance-monitoring', 'admin', '성능 모니터링', 1, 59)
ON CONFLICT (rsrc_cd) DO NOTHING;
-- 2. 메뉴 메타데이터 갱신
UPDATE kcg.auth_perm_tree
SET url_path = '/admin/performance-monitoring',
label_key = 'nav.performanceMonitoring',
component_key = 'features/admin/PerformanceMonitoring',
nav_group = 'admin',
nav_sub_group = '감사·보안',
nav_sort = 2110,
labels = '{"ko":"성능 모니터링","en":"Performance Monitoring"}'
WHERE rsrc_cd = 'admin:performance-monitoring';
-- 3. ADMIN 역할에 전체 권한 부여
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
SELECT r.role_sn, 'admin:performance-monitoring', 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;

파일 보기

@ -1,72 +0,0 @@
-- V026: 한중어업협정 중국어선 허가현황 원본 테이블 + fleet_vessels 연도 컬럼
-- 출처: docs/중국어선_허가현황_YYYYMMDD.xls (연단위 갱신)
-- ===== 1. fishery_permit_cn : 허가현황 원본 스냅샷 =====
CREATE TABLE IF NOT EXISTS kcg.fishery_permit_cn (
id BIGSERIAL PRIMARY KEY,
permit_year INTEGER NOT NULL,
permit_no VARCHAR(30) NOT NULL,
fishery_type VARCHAR(60), -- 업종 (2척식저인망어업 등)
fishery_code VARCHAR(10) NOT NULL, -- 업종코드 (PT/PT-S/GN/FC/PS/OT)
name_cn VARCHAR(100) NOT NULL,
name_en VARCHAR(200) NOT NULL,
applicant_cn VARCHAR(100),
applicant_en VARCHAR(200),
applicant_addr_cn VARCHAR(300),
applicant_addr_en VARCHAR(300),
registration_no VARCHAR(100),
tonnage NUMERIC(10,2),
port_cn VARCHAR(100),
port_en VARCHAR(200),
callsign VARCHAR(40),
engine_power NUMERIC(10,2),
length_m NUMERIC(6,2),
beam_m NUMERIC(6,2),
depth_m NUMERIC(6,2),
fishing_zones VARCHAR(30), -- Ⅱ,Ⅲ 등
fishing_period_1 VARCHAR(50),
fishing_period_2 VARCHAR(50),
catch_quota_t NUMERIC(10,2),
cumulative_quota_t NUMERIC(10,2),
refrig_hold_count INTEGER,
freezer_hold_count INTEGER,
admin_sanction TEXT,
parent_permit_no VARCHAR(30), -- 부속선(PT-S)이 참조하는 본선 허가번호
volume_enclosed NUMERIC(10,2),
volume_above_deck NUMERIC(10,2),
volume_below_deck NUMERIC(10,2),
volume_excluded NUMERIC(10,2),
raw_data JSONB,
source_file VARCHAR(255),
loaded_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (permit_year, permit_no)
);
CREATE INDEX idx_fishery_permit_cn_name_cn ON kcg.fishery_permit_cn(permit_year, name_cn);
CREATE INDEX idx_fishery_permit_cn_name_en ON kcg.fishery_permit_cn(permit_year, LOWER(name_en));
CREATE INDEX idx_fishery_permit_cn_code ON kcg.fishery_permit_cn(permit_year, fishery_code);
CREATE INDEX idx_fishery_permit_cn_parent ON kcg.fishery_permit_cn(permit_year, parent_permit_no);
COMMENT ON TABLE kcg.fishery_permit_cn IS '한중어업협정 중국어선 허가현황 원본 스냅샷 (연단위 갱신)';
COMMENT ON COLUMN kcg.fishery_permit_cn.permit_year IS '허가 연도 (파일명 YYYY에서 추출)';
COMMENT ON COLUMN kcg.fishery_permit_cn.fishery_code IS 'PT(쌍끌이 본선)/PT-S(부속선)/GN(자망)/FC(운반)/PS(선망)/OT(외끌이)';
COMMENT ON COLUMN kcg.fishery_permit_cn.parent_permit_no IS 'PT-S(부속선)가 소속된 본선의 허가번호';
-- ===== 2. fleet_vessels 확장 : 연도 + 업종코드 추적 =====
ALTER TABLE kcg.fleet_vessels ADD COLUMN IF NOT EXISTS permit_year INTEGER;
ALTER TABLE kcg.fleet_vessels ADD COLUMN IF NOT EXISTS fishery_code VARCHAR(10);
CREATE INDEX IF NOT EXISTS idx_fleet_vessels_permit_year ON kcg.fleet_vessels(permit_year);
CREATE INDEX IF NOT EXISTS idx_fleet_vessels_fishery_code ON kcg.fleet_vessels(fishery_code);
COMMENT ON COLUMN kcg.fleet_vessels.permit_year IS '허가 연도. fleet_tracker는 현재 연도만 조회';
COMMENT ON COLUMN kcg.fleet_vessels.fishery_code IS 'fishery_permit_cn.fishery_code 복제 (PT/PT-S/GN/FC/PS/OT)';
-- ===== 3. V014 데모 seed 제거 =====
-- 기존 6행 데모 vessels 제거 (실제 허가현황만 남김).
-- fleet_companies id=1,2는 vessel_permit_master가 FK로 참조하여 삭제 불가 — 잔존 허용
-- (loader 실행 시 실 허가 신청인 회사가 별도 id로 upsert됨)
DELETE FROM kcg.fleet_vessels WHERE permit_no IN (
'ZY-2024-001','ZY-2024-002','ZY-2024-003',
'ZY-2024-010','ZY-2024-011','ZY-2024-012'
);

파일 보기

@ -1,90 +0,0 @@
-- ============================================================================
-- V030: 어구 정체성 충돌(GEAR_IDENTITY_COLLISION) 탐지 패턴
-- 동일 어구 이름이 서로 다른 MMSI 로 같은 5분 사이클 내 동시 AIS 송출되는
-- 케이스를 독립 탐지 패턴으로 기록. 공존 이력·심각도·운영자 분류 상태를 보존한다.
-- ============================================================================
-- ──────────────────────────────────────────────────────────────────
-- 1. 충돌 이력 테이블
-- ──────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS kcg.gear_identity_collisions (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(200) NOT NULL, -- 동일한 어구 이름
mmsi_lo VARCHAR(20) NOT NULL, -- 정렬된 쌍 (lo < hi)
mmsi_hi VARCHAR(20) NOT NULL,
parent_name VARCHAR(100),
parent_vessel_id BIGINT, -- fleet_vessels.id
first_seen_at TIMESTAMPTZ NOT NULL,
last_seen_at TIMESTAMPTZ NOT NULL,
coexistence_count INT NOT NULL DEFAULT 1, -- 동일 cycle 공존 누적
swap_count INT NOT NULL DEFAULT 0, -- 시간차 스왑 누적(참고)
max_distance_km NUMERIC(8,2), -- 양 위치 최대 거리
last_lat_lo NUMERIC(9,6),
last_lon_lo NUMERIC(10,6),
last_lat_hi NUMERIC(9,6),
last_lon_hi NUMERIC(10,6),
severity VARCHAR(20) NOT NULL DEFAULT 'MEDIUM', -- CRITICAL/HIGH/MEDIUM/LOW
status VARCHAR(30) NOT NULL DEFAULT 'OPEN', -- OPEN/REVIEWED/CONFIRMED_ILLEGAL/FALSE_POSITIVE
resolved_by UUID,
resolved_at TIMESTAMPTZ,
resolution_note TEXT,
evidence JSONB, -- 최근 관측 요약
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT gear_identity_collisions_pair_uk UNIQUE (name, mmsi_lo, mmsi_hi),
CONSTRAINT gear_identity_collisions_pair_ord CHECK (mmsi_lo < mmsi_hi)
);
CREATE INDEX IF NOT EXISTS idx_gic_status
ON kcg.gear_identity_collisions(status, last_seen_at DESC);
CREATE INDEX IF NOT EXISTS idx_gic_severity
ON kcg.gear_identity_collisions(severity, last_seen_at DESC);
CREATE INDEX IF NOT EXISTS idx_gic_parent
ON kcg.gear_identity_collisions(parent_vessel_id)
WHERE parent_vessel_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_gic_name
ON kcg.gear_identity_collisions(name);
CREATE INDEX IF NOT EXISTS idx_gic_last_seen
ON kcg.gear_identity_collisions(last_seen_at DESC);
COMMENT ON TABLE kcg.gear_identity_collisions IS
'동일 어구 이름이 서로 다른 MMSI 로 공존 송출되는 스푸핑 의심 패턴 (GEAR_IDENTITY_COLLISION).';
-- ──────────────────────────────────────────────────────────────────
-- 2. 권한 트리 / 메뉴 슬롯 (V024 이후 detection 그룹은 평탄화됨: parent_cd=NULL)
-- ──────────────────────────────────────────────────────────────────
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:gear-collision', NULL, '어구 정체성 충돌', 1, 40,
'/gear-collision', 'nav.gearCollision',
'features/detection/GearCollisionDetection', 950,
'{"ko":"어구 정체성 충돌","en":"Gear Identity Collision"}'::jsonb)
ON CONFLICT (rsrc_cd) DO NOTHING;
-- ──────────────────────────────────────────────────────────────────
-- 3. 권한 부여
-- ADMIN : 전체 op (READ/CREATE/UPDATE/DELETE/EXPORT)
-- OPERATOR: READ + UPDATE (분류 액션)
-- VIEWER/ANALYST/FIELD: READ
-- ──────────────────────────────────────────────────────────────────
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
SELECT r.role_sn, 'detection:gear-collision', 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:gear-collision', op.oper_cd, 'Y'
FROM kcg.auth_role r
CROSS JOIN (VALUES ('READ'), ('UPDATE')) AS op(oper_cd)
WHERE r.role_cd = 'OPERATOR'
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:gear-collision', 'READ', 'Y'
FROM kcg.auth_role r
WHERE r.role_cd IN ('VIEWER', 'ANALYST', 'FIELD')
ON CONFLICT (role_sn, rsrc_cd, oper_cd) DO NOTHING;

파일 보기

@ -1,6 +1,6 @@
# Database Migrations
> **실제 SQL 파일 위치**: [`backend/src/main/resources/db/migration/`](../../backend/src/main/resources/db/migration/)
> ⚠️ **실제 SQL 파일 위치**: [`backend/src/main/resources/db/migration/`](../../backend/src/main/resources/db/migration/)
>
> Spring Boot Flyway 표준 위치를 따르므로 SQL 파일은 백엔드 모듈 안에 있습니다.
> Spring Boot 기동 시 Flyway가 자동으로 적용합니다.
@ -10,208 +10,37 @@
- **User**: `kcg-app`
- **Schema**: `kcg`
- **Host**: `211.208.115.83:5432`
- **현재 버전**: v022 (2026-04-09)
---
## 적용된 마이그레이션 (V001~V013)
## 마이그레이션 히스토리 (V001~V022)
Flyway 마이그레이션은 **증분 방식** — 각 파일은 이전 버전에 대한 변경(ALTER/INSERT/CREATE)만 포함합니다.
V001이 처음 테이블을 만들고, 이후 파일들이 컬럼 추가·시드 INSERT·신규 테이블 생성 등을 수행합니다.
### 인증/권한/감사 (V001~V007)
### Phase 1~8: 인증/권한/감사 (V001~V007)
| 파일 | 내용 |
|---|---|
| `V001__auth_init.sql` | auth_user, auth_org, auth_role, auth_user_role, auth_login_hist, auth_setting |
| `V002__perm_tree.sql` | auth_perm_tree (권한 트리) + auth_perm (권한 매트릭스) |
| `V003__perm_seed.sql` | 역할 5종 시드 + 트리 노드 47개 + 역할별 권한 매트릭스 |
| `V004__access_logs.sql` | auth_audit_log + auth_access_log |
| `V005__parent_workflow.sql` | gear_group_parent_resolution, review_log, exclusions, label_sessions |
| `V006__demo_accounts.sql` | 데모 계정 5종 (admin/operator/analyst/field/viewer) |
| `V007__perm_tree_label_align.sql` | 트리 노드 명칭 일치 조정 |
| `V001__auth_init.sql` | 인증/조직/역할/사용자-역할/로그인 이력 |
| `V002__perm_tree.sql` | 권한 트리 + 권한 매트릭스 |
| `V003__perm_seed.sql` | 초기 역할 5종 + 트리 노드 45개 + 권한 매트릭스 시드 |
| `V004__access_logs.sql` | 감사로그/접근이력 |
| `V005__parent_workflow.sql` | 모선 워크플로우 (resolution/review_log/exclusions/label_sessions) |
| `V006__demo_accounts.sql` | 데모 계정 5종 |
| `V007__perm_tree_label_align.sql` | 트리 노드 명칭을 사이드바 i18n 라벨과 일치 |
### 마스터 데이터 (V008~V011)
### S1: 마스터 데이터 + Prediction 기반 (V008~V013)
| 파일 | 내용 |
|---|---|
| `V008__code_master.sql` | code_master (계층형 72코드: 위반유형/이벤트/단속 등) |
| `V009__gear_type_master.sql` | gear_type_master 6종 (어구 분류 룰 + 합법성 기준) |
| `V010__zone_polygon_master.sql` | zone_polygon_master (PostGIS, 8개 해역 시드) |
| `V011__vessel_permit_patrol.sql` | vessel_permit_master(9척) + patrol_ship_master(6척) + fleet_companies(2개) |
### Prediction 분석 (V012~V015)
| 파일 | 내용 |
|---|---|
| `V012__prediction_events_stats.sql` | vessel_analysis_results(파티션) + prediction_events + alerts + stats(시/일/월) + KPI + risk_grid + label_input |
| `V013__enforcement_operations.sql` | enforcement_records + plans + patrol_assignments + ai_model_versions + metrics |
| `V014__fleet_prediction_tables.sql` | fleet_vessels/tracking_snapshot + gear_identity_log + correlation_scores/raw_metrics + correlation_param_models + group_polygon_snapshots + gear_group_episodes/episode_snapshots + parent_candidate_snapshots + label_tracking_cycles + system_config |
| `V015__fix_numeric_precision.sql` | NUMERIC 정밀도 확대 (점수/비율 컬럼) |
### 모선 워크플로우 확장 + 기능 추가 (V016~V019)
| 파일 | 내용 |
|---|---|
| `V016__parent_workflow_columns.sql` | gear_group_parent_resolution 확장 (confidence, decision_source, episode_id 등) |
| `V017__role_color_hex.sql` | auth_role.color_hex 컬럼 추가 |
| `V018__prediction_event_features.sql` | prediction_events.features JSONB 컬럼 추가 |
| `V019__llm_ops_perm.sql` | ai-operations:llm-ops 권한 트리 노드 + ADMIN 권한 |
### 메뉴 DB SSOT (V020~V022)
| 파일 | 내용 |
|---|---|
| `V020__menu_config.sql` | menu_config 테이블 생성 + 시드 (V021에서 통합 후 폐기) |
| `V021__menu_into_perm_tree.sql` | auth_perm_tree에 메뉴 컬럼 추가 (url_path, label_key, component_key, nav_group, nav_sub_group, nav_sort) + 공유 리소스 분리 (statistics:reports, admin:data-hub, admin:notices) + menu_config DROP |
| `V022__perm_tree_i18n_labels.sql` | auth_perm_tree.labels JSONB 추가 — DB가 i18n SSOT (`{"ko":"...", "en":"..."}`) |
---
## 테이블 목록 (49개, flyway_schema_history 포함)
### 인증/권한 (8 테이블)
| 테이블 | PK | 설명 | 주요 컬럼 |
|---|---|---|---|
| `auth_user` | user_id (UUID) | 사용자 | user_acnt(UQ), pswd_hash, user_nm, rnkp_nm, email, org_sn(FK→auth_org), user_stts_cd, fail_cnt, auth_provider |
| `auth_org` | org_sn (BIGSERIAL) | 조직 | org_nm, org_abbr_nm, org_tp_cd, upper_org_sn(FK 자기참조) |
| `auth_role` | role_sn (BIGSERIAL) | 역할 | role_cd(UQ), role_nm, role_dc, dflt_yn, builtin_yn, color_hex |
| `auth_user_role` | (user_id, role_sn) | 사용자-역할 매핑 | granted_at, granted_by |
| `auth_perm_tree` | rsrc_cd (VARCHAR 100) | 권한 트리 + **메뉴 SSOT** | parent_cd(FK 자기참조), rsrc_nm, icon, rsrc_level, sort_ord, **url_path, label_key, component_key, nav_group, nav_sub_group, nav_sort, labels(JSONB)** |
| `auth_perm` | perm_sn (BIGSERIAL) | 권한 매트릭스 | role_sn(FK→auth_role), rsrc_cd(FK→auth_perm_tree), oper_cd, grant_yn, UQ(role_sn,rsrc_cd,oper_cd) |
| `auth_setting` | setting_key (VARCHAR 50) | 시스템 설정 | setting_val(JSONB) |
| `auth_login_hist` | hist_sn (BIGSERIAL) | 로그인 이력 | user_id, user_acnt, login_dtm, login_ip, result, fail_reason, auth_provider |
### 감사 (2 테이블)
| 테이블 | PK | 설명 | 주요 컬럼 |
|---|---|---|---|
| `auth_audit_log` | audit_sn (BIGSERIAL) | 감사 로그 | user_id, action_cd, resource_type, resource_id, detail(JSONB), ip_address, result |
| `auth_access_log` | access_sn (BIGSERIAL) | API 접근 이력 | user_id, http_method, request_path, status_code, duration_ms, ip_address |
### 모선 워크플로우 (7 테이블)
| 테이블 | PK | 설명 |
|---|---|---|
| `gear_group_parent_resolution` | id (BIGSERIAL), UQ(group_key, sub_cluster_id) | 모선 확정/거부 결과 (status, selected_parent_mmsi, confidence, decision_source, scores, episode_id) |
| `gear_group_parent_review_log` | id (BIGSERIAL) | 운영자 리뷰 이력 (action, actor, comment) |
| `gear_parent_candidate_exclusions` | id (BIGSERIAL) | 후보 제외 관리 (scope_type, excluded_mmsi, reason, active_from/until) |
| `gear_parent_label_sessions` | id (BIGSERIAL) | 학습 세션 (label_parent_mmsi, status, duration_days, anchor_snapshot) |
| `gear_parent_label_tracking_cycles` | (label_session_id, observed_at) | 학습 추적 사이클 (top_candidate, labeled_candidate 비교) |
| `gear_group_episodes` | episode_id (VARCHAR 50) | 어구 그룹 에피소드 (lineage_key, status, member_mmsis, center_point) |
| `gear_group_episode_snapshots` | (episode_id, observed_at) | 에피소드 스냅샷 |
### 마스터 데이터 (5 테이블)
| 테이블 | PK | 설명 | 시드 |
|---|---|---|---|
| `code_master` | code_id (VARCHAR 100) | 계층형 코드 | 12그룹, 72코드 |
| `gear_type_master` | gear_code (VARCHAR 20) | 어구 유형 | 6종 |
| `zone_polygon_master` | zone_code (VARCHAR 30) | 해역 폴리곤 (PostGIS GEOMETRY 4326) | 8해역 |
| `vessel_permit_master` | mmsi (VARCHAR 20) | 어선 허가 | 9척 |
| `patrol_ship_master` | ship_id (BIGSERIAL), UQ(ship_code) | 함정 | 6척 |
### Prediction 이벤트/통계 (8 테이블)
| 테이블 | PK | 설명 |
|---|---|---|
| `vessel_analysis_results` | (id, analyzed_at) 파티션 | 선박 분석 결과 (35컬럼: mmsi, risk_score, is_dark, transship_suspect, features JSONB 등) |
| `vessel_analysis_results_default` | — | 기본 파티션 |
| `prediction_events` | id (BIGSERIAL), UQ(event_uid) | 탐지 이벤트 (level, category, vessel_mmsi, status, features JSONB) |
| `prediction_alerts` | id (BIGSERIAL) | 경보 발송 (event_id FK, channel, delivery_status) |
| `event_workflow` | id (BIGSERIAL) | 이벤트 상태 변경 이력 (prev/new_status, actor) |
| `prediction_stats_hourly` | stat_hour (TIMESTAMPTZ) | 시간별 통계 (by_category/by_zone JSONB) |
| `prediction_stats_daily` | stat_date (DATE) | 일별 통계 |
| `prediction_stats_monthly` | stat_month (DATE) | 월별 통계 |
### Prediction 보조 (7 테이블)
| 테이블 | PK | 설명 |
|---|---|---|
| `prediction_kpi_realtime` | kpi_key (VARCHAR 50) | 실시간 KPI (value, trend, delta_pct) |
| `prediction_risk_grid` | (cell_id, stat_hour) | 위험도 격자 |
| `prediction_label_input` | id (BIGSERIAL) | 학습 피드백 입력 |
| `gear_correlation_scores` | (model_id, group_key, sub_cluster_id, target_mmsi) | 어구-선박 상관 점수 |
| `gear_correlation_raw_metrics` | id (BIGSERIAL) | 상관 원시 지표 |
| `correlation_param_models` | id (BIGSERIAL) | 상관 모델 파라미터 |
| `group_polygon_snapshots` | id (BIGSERIAL) | 그룹 폴리곤 스냅샷 (PostGIS) |
### Prediction 후보 (1 테이블)
| 테이블 | PK | 설명 |
|---|---|---|
| `gear_group_parent_candidate_snapshots` | id (BIGSERIAL) | 모선 후보 스냅샷 (25컬럼: 점수 분해, evidence JSONB) |
### 단속/작전 (3 테이블)
| 테이블 | PK | 설명 |
|---|---|---|
| `enforcement_records` | id (BIGSERIAL), UQ(enf_uid) | 단속 이력 (event_id FK, vessel_mmsi, action, result) |
| `enforcement_plans` | id (BIGSERIAL), UQ(plan_uid) | 단속 계획 (planned_date, risk_level, status) |
| `patrol_assignments` | id (BIGSERIAL) | 함정 배치 (ship_id FK, plan_id FK, waypoints JSONB) |
### AI 모델 (2 테이블)
| 테이블 | PK | 설명 |
|---|---|---|
| `ai_model_versions` | id (BIGSERIAL) | AI 모델 버전 (accuracy, status, train_config JSONB) |
| `ai_model_metrics` | id (BIGSERIAL) | 모델 메트릭 (model_id FK, metric_name, metric_value) |
### Fleet (3 테이블)
| 테이블 | PK | 설명 |
|---|---|---|
| `fleet_companies` | id (BIGSERIAL) | 선단 업체 (name_cn/en/ko, country) |
| `fleet_vessels` | id (BIGSERIAL) | 선단 선박 (company_id FK, mmsi, gear_code, fleet_role) |
| `fleet_tracking_snapshot` | id (BIGSERIAL) | 선단 추적 스냅샷 (company_id FK) |
### 기타 (2 테이블)
| 테이블 | PK | 설명 |
|---|---|---|
| `gear_identity_log` | id (BIGSERIAL) | 어구 식별 로그 (mmsi, name, parent_mmsi, match_method) |
| `system_config` | key (VARCHAR 100) | 시스템 설정 (value JSONB) |
---
## 인덱스 현황 (149개)
주요 패턴:
- **시계열 DESC**: `(occurred_at DESC)`, `(created_at DESC)`, `(analyzed_at DESC)` — 최신 데이터 우선 조회
- **복합 키**: `(group_key, sub_cluster_id, observed_at DESC)` — 어구 그룹 시계열
- **GiST 공간**: `polygon`, `polygon_geom` — PostGIS 공간 검색
- **GIN 배열**: `violation_categories` — 위반 카테고리 배열 검색
- **부분 인덱스**: `(released_at) WHERE released_at IS NULL` — 활성 제외만, `(is_dark) WHERE is_dark = true` — dark vessel만
## FK 관계 (21개)
```
auth_user ─→ auth_org (org_sn)
auth_user_role ─→ auth_user (user_id), auth_role (role_sn)
auth_perm ─→ auth_role (role_sn), auth_perm_tree (rsrc_cd)
auth_perm_tree ─→ auth_perm_tree (parent_cd, 자기참조)
code_master ─→ code_master (parent_id, 자기참조)
zone_polygon_master ─→ zone_polygon_master (parent_zone_code, 자기참조)
auth_org ─→ auth_org (upper_org_sn, 자기참조)
enforcement_records ─→ prediction_events (event_id), patrol_ship_master (patrol_ship_id)
event_workflow ─→ prediction_events (event_id)
prediction_alerts ─→ prediction_events (event_id)
patrol_assignments ─→ patrol_ship_master (ship_id), enforcement_plans (plan_id)
ai_model_metrics ─→ ai_model_versions (model_id)
gear_correlation_scores ─→ correlation_param_models (model_id)
gear_parent_label_tracking_cycles ─→ gear_parent_label_sessions (label_session_id)
fleet_tracking_snapshot ─→ fleet_companies (company_id)
fleet_vessels ─→ fleet_companies (company_id)
vessel_permit_master ─→ fleet_companies (company_id)
```
---
| `V008__code_master.sql` | 계층형 코드 마스터 (12그룹, 72코드: 위반유형/이벤트/단속/허가/함정 등) |
| `V009__gear_type_master.sql` | 어구 유형 마스터 6종 (분류 룰 + 합법성 기준) |
| `V010__zone_polygon_master.sql` | 해역 폴리곤 마스터 (PostGIS GEOMETRY, 8개 해역 시드) |
| `V011__vessel_permit_patrol.sql` | 어선 허가 마스터 + 함정 마스터 + fleet_companies (선박 9척, 함정 6척) |
| `V012__prediction_events_stats.sql` | vessel_analysis_results(파티션) + 이벤트 허브 + 알림 + 통계(시/일/월) + KPI + 위험격자 + 학습피드백 |
| `V013__enforcement_operations.sql` | 단속 이력/계획 + 함정 배치 + AI모델 버전/메트릭 (시드 포함) |
## 실행 방법
### 최초 1회 - DB/사용자 생성 (관리자 권한 필요)
```sql
-- snp 관리자 계정으로 접속
psql -h 211.208.115.83 -U snp -d postgres
CREATE DATABASE kcgaidb;
@ -232,11 +61,7 @@ cd backend && ./mvnw spring-boot:run
### 수동 적용
```bash
cd backend && ./mvnw flyway:migrate \
-Dflyway.url=jdbc:postgresql://211.208.115.83:5432/kcgaidb \
-Dflyway.user=kcg-app \
-Dflyway.password=Kcg2026ai \
-Dflyway.schemas=kcg
cd backend && ./mvnw flyway:migrate -Dflyway.url=jdbc:postgresql://211.208.115.83:5432/kcgaidb -Dflyway.user=kcg-app -Dflyway.password=Kcg2026ai -Dflyway.schemas=kcg
```
### Checksum 불일치 시 (마이그레이션 파일 수정 후)
@ -245,18 +70,4 @@ cd backend && ./mvnw flyway:repair -Dflyway.url=... (위와 동일)
```
## 신규 마이그레이션 추가
[`backend/src/main/resources/db/migration/`](../../backend/src/main/resources/db/migration/)에 `V0NN__설명.sql` 형식으로 추가하면 다음 기동 시 자동 적용됩니다.
### 메뉴 추가 시 필수 포함 사항
auth_perm_tree에 INSERT 시 메뉴 SSOT 컬럼도 함께 지정:
```sql
INSERT INTO kcg.auth_perm_tree(
rsrc_cd, parent_cd, rsrc_nm, rsrc_level, sort_ord, icon,
url_path, label_key, component_key, nav_group, nav_sort,
labels
) VALUES (
'new-feature:sub', 'new-feature', '새 기능', 1, 10, 'Sparkles',
'/new-feature/sub', 'nav.newFeatureSub', 'features/new-feature/SubPage', NULL, 1400,
'{"ko":"새 기능 서브","en":"New Feature Sub"}'
);
```
[`backend/src/main/resources/db/migration/`](../../backend/src/main/resources/db/migration/)에 `V00N__설명.sql` 형식으로 추가하면 다음 기동 시 자동 적용됩니다.

파일 보기

@ -31,7 +31,7 @@
| 서비스 | systemd | 포트 | 로그 |
|---|---|---|---|
| kcg-ai-prediction | `kcg-ai-prediction.service` | 18092 | `journalctl -u kcg-ai-prediction -f` |
| kcg-prediction (레거시) | `kcg-prediction.service` | 8001 | `journalctl -u kcg-prediction -f` |
| kcg-prediction (기존 iran) | `kcg-prediction.service` | 8001 | `journalctl -u kcg-prediction -f` |
| kcg-prediction-lab | `kcg-prediction-lab.service` | 18091 | `journalctl -u kcg-prediction-lab -f` |
## 디렉토리 구조
@ -166,7 +166,7 @@ PGPASSWORD='Kcg2026ai' psql -h 211.208.115.83 -U kcg-app -d kcgaidb
| 443 | nginx (HTTPS) | rocky-211 |
| 18080 | kcg-ai-backend (Spring Boot) | rocky-211 |
| 18092 | kcg-ai-prediction (FastAPI) | redis-211 |
| 8001 | kcg-prediction (레거시) | redis-211 |
| 8001 | kcg-prediction (기존 iran) | redis-211 |
| 18091 | kcg-prediction-lab | redis-211 |
| 5432 | PostgreSQL (kcgaidb, snpdb) | 211.208.115.83 |
| 6379 | Redis | redis-211 |
@ -226,5 +226,5 @@ ssh redis-211 "systemctl restart kcg-ai-prediction"
| `/home/apps/kcg-ai-prediction/.env` | prediction 환경변수 |
| `/home/apps/kcg-ai-prediction/venv/` | Python 3.9 가상환경 |
| `/etc/systemd/system/kcg-ai-prediction.service` | prediction systemd 서비스 |
| `/home/apps/kcg-prediction/` | 레거시 prediction (포트 8001) |
| `/home/apps/kcg-prediction/` | 기존 iran prediction (포트 8001) |
| `/home/apps/kcg-prediction-lab/` | 기존 lab prediction (포트 18091) |

파일 보기

@ -4,339 +4,13 @@
## [Unreleased]
## [2026-04-17.4]
### 수정
- **백엔드 RestClient bean 모호성으로 기동 실패 해소** — rocky-211 `kcg-ai-backend` 가 restart 시 `No qualifying bean of type RestClient, but 2 were found: predictionRestClient, signalBatchRestClient` 로 크래시 루프 진입하던 문제. PR #A(2026-04-17) 의 RestClientConfig 도입 이후 잠복해 있던 버그로, `@RequiredArgsConstructor` 가 생성한 constructor parameter 에 필드의 `@Qualifier` 가 복사되지 않아 Spring 6.1 의 parameter-level annotation 기반 주입이 실패한 것. 수정: `backend/pom.xml``maven-compiler-plugin` 실행 설정에 `<parameters>true</parameters>` 명시 + `backend/src/main/java/lombok.config` 신설해 `lombok.copyableAnnotations += org.springframework.beans.factory.annotation.Qualifier` 등록. 재빌드 후 bytecode `RuntimeVisibleParameterAnnotations``@Qualifier` 복사 확인, 운영 기동 `Started KcgAiApplication in 7.333 seconds` 복구
## [2026-04-17.3]
### 문서
- **절대 지침 섹션 추가** — CLAUDE.md 최상단에 "절대 지침(Absolute Rules)" 섹션 신설. (1) 신규 브랜치 생성 전 `git fetch``origin/develop` 대비 뒤처지면 사용자 확인 → `git pull --ff-only` → 분기하는 동기화 절차 명시, (2) `frontend/` 작업 시 `design-system.html` 쇼케이스 규칙 전면 준수(공통 컴포넌트 우선 사용, 인라인/하드코딩 Tailwind 색상·`!important` 금지, 접근성 필수 체크리스트) 요약. 하단 기존 "디자인 시스템 (필수 준수)" 상세 섹션과 연결
## [2026-04-17.2]
### 추가
- **어구 정체성 충돌(GEAR_IDENTITY_COLLISION) 탐지 패턴** — 동일 어구 이름이 서로 다른 MMSI 로 같은 5분 사이클에 동시 AIS 송출되는 스푸핑/복제 의심 패턴을 신규 탐지. prediction `fleet_tracker.track_gear_identity()` 가 공존(simultaneous) / 교체(sequential) 경로를 분리해 공존 쌍은 `gear_identity_collisions` 에 UPSERT (누적 공존 횟수, 최대 거리, 양측 좌표, evidence JSONB append). 심각도는 거리/누적/스왑 기반으로 CRITICAL/HIGH/MEDIUM/LOW 자동 재계산, 운영자 확정 상태(CONFIRMED_ILLEGAL/FALSE_POSITIVE)는 보존. CRITICAL/HIGH 승격 시 `prediction_events` 허브에 `GEAR_IDENTITY_COLLISION` 카테고리 등록(dedup 367분). `/api/analysis/gear-collisions` READ + resolve 액션(REVIEWED/CONFIRMED_ILLEGAL/FALSE_POSITIVE/REOPEN, `@Auditable GEAR_COLLISION_RESOLVE`). 좌측 메뉴 "어구 정체성 충돌" 자동 노출(nav_sort=950, detection:gear-collision)
- **gearCollisionStatuses 카탈로그**`shared/constants/gearCollisionStatuses.ts` + `catalogRegistry` 등록으로 design-system 쇼케이스 자동 노출. OPEN/REVIEWED/CONFIRMED_ILLEGAL/FALSE_POSITIVE 4단계 Badge intent 매핑
### 변경
- **prediction 5분 사이클 안정화**`gear_correlation_scores_pkey` 충돌이 매 사이클 `InFailedSqlTransaction` 을 유발해 이벤트 생성·분석 결과 upsert 가 전부 스킵되던 문제 해소. `gear_correlation_scores``target_mmsi` 이전 쿼리를 SAVEPOINT 로 격리해 PK 충돌 시 트랜잭션 유지. 공존 경로는 이전 시도 자체를 하지 않아 재발 방지
### 문서
- **프로젝트 산출문서 2026-04-17 기준 정비**`architecture.md` shared/components/ui 9개·i18n 네임스페이스 갱신, `sfr-traceability.md` v3.0 전면 재작성(운영 상태 기반 531라인), `sfr-user-guide.md` 헤더 + SFR-01/02/09/10/11/12/13/17 구현 현황 갱신, stale 3건(`data-sharing-analysis.md` / `next-refactoring.md` / `page-workflow.md`) 제거
## [2026-04-17]
### 변경
- **디자인 시스템 SSOT 일괄 준수 (30파일)**`frontend/design-system.html` 쇼케이스의 공통 컴포넌트와 `shared/constants/` 카탈로그를 우회하던 하드코딩 UI 를 전영역 치환. raw `<button>``<Button variant>` / raw `<input>``<Input>` / raw `<select>``<Select>` / 커스텀 탭 → `<TabBar>` + `<TabButton>` / raw checkbox → `<Checkbox>`. `text-red-400` 같은 다크 전용 색상을 `text-red-600 dark:text-red-400` 쌍으로 라이트 모드 대응. StatBox `color: string` prop 을 `intent: BadgeIntent` + `INTENT_TEXT_CLASS` 매핑으로 재설계. 에러 메시지도 `t('error.errorPrefix', { msg })` 로 통일. 영역: detection(6) / detection/components(4) / enforcement / surveillance(2) / admin(7) / parent-inference(3) / statistics / ai-operations(3) / dashboard / field-ops(2) / auth
- **i18n 하드코딩 한글 제거 (alert/confirm/aria-label 우선순위)**`common.json``aria` / `error` / `dialog` / `success` / `message` 네임스페이스 추가 (ko/en 대칭, 52개 키). 운영자 노출 `alert('실패: ' + msg)` 11건과 접근성 위반 `aria-label="역할 코드"` 등 40+건을 `t('aria.*')` / `t('error.*')` / `t('dialog.*')` 로 일괄 치환. parent-inference / admin / detection / enforcement / vessel / statistics / ai-operations 전 영역. MainLayout 언어 토글은 `title={t('message.switchToEnglish')}` + `aria-label={t('aria.languageToggle')}` 로 정비
- **iran 백엔드 프록시 잔재 제거**`IranBackendClient` dead class 삭제, `application.yml``iran-backend:` 블록 + `AppProperties.IranBackend` inner class 정리. prediction 이 kcgaidb 에 직접 write 하는 현 아키텍처에 맞춰 CLAUDE.md 시스템 구성 다이어그램 최신화. Frontend UI 라벨 `iran 백엔드 (분석)``AI 분석 엔진` 로 교체, system-flow manifest `external.iran_backend` 노드는 `status: deprecated` 마킹(노드 ID 안정성 원칙 준수, 1~2 릴리즈 후 삭제 예정)
- **백엔드 계층 분리** — AlertController/MasterDataController/AdminStatsController 에서 repository·JdbcTemplate 직접 주입 패턴 제거. `AlertService` · `MasterDataService` · `AdminStatsService` 신규 계층 도입 + `@Transactional(readOnly=true)` 적용. 공통 `RestClientConfig @Configuration` 으로 `predictionRestClient` / `signalBatchRestClient` Bean 통합 → Proxy controller 들의 `@PostConstruct` ad-hoc 생성 제거
- **감사 로그 보강**`EnforcementService` 의 createRecord / updateRecord / createPlan 에 `@Auditable` 추가 (ENFORCEMENT_CREATE/UPDATE/PLAN_CREATE). `VesselAnalysisGroupService.resolveParent``PARENT_RESOLVE` 액션 기록. 모든 쓰기 액션이 `auth_audit_log` 에 자동 수집
- **alertLevels 카탈로그 확장** — 8개 화면의 `level === 'CRITICAL' ? ... : 'HIGH' ? ...` 식 직접 분기를 제거하기 위해 `isValidAlertLevel` (타입 가드) / `isHighSeverity` / `getAlertLevelOrder` / `ALERT_LEVEL_MARKER_OPACITY` / `ALERT_LEVEL_MARKER_RADIUS` / `ALERT_LEVEL_TIER_SCORE` 헬퍼·상수 신설. LiveMapView 마커 시각 매핑, DarkVesselDetection tier→점수, GearIdentification 타입 가드, vesselAnomaly 패널 severity 할당 헬퍼로 치환
### 추가
- **performanceStatus 카탈로그 등록** — 이미 존재하던 `shared/constants/performanceStatus.ts` (good/normal/warning/critical/running/passed/failed/active/scheduled/archived 10종) 를 `catalogRegistry` 에 등록. design-system 쇼케이스 자동 노출 + admin 성능/보관/검증 페이지 SSOT 일원화
## [2026-04-16.7]
### 변경
- **경량 분석 riskScore 해상도 개선**`compute_lightweight_risk_score``dark_suspicion_score`(0~100 패턴 기반 의심도) / `dist_from_baseline_nm`(EEZ 외 기선 근접도 12·24NM 차등) / `dark_history_24h`(반복 이력) 반영. 허가·반복 이중계산 방지 축소 로직. 배포 후 실측: 45점 60.8% 고정 수렴 → **0%** (11~40 구간 고르게 분산)
- **vessel_type 매핑** — fleet_vessels 등록선 `fishery_code` (PT/PT-S/OT/GN/PS/FC) 를 `TRAWL/GILLNET/PURSE/CARGO` 로 매핑하는 `vessel_type_mapping.py` 신설. 경량 경로의 `vessel_type='UNKNOWN'` 하드코딩 제거. 실측: UNKNOWN 98.6% → **89.1%** (886척이 구체 유형으로 전환)
- **VesselType 값 확장** — 기존 TRAWL/PURSE/LONGLINE/TRAP/UNKNOWN 에 `GILLNET`(유자망) / `CARGO`(운반선) 2종 추가
- **중국 선박 분석 그리드 정합성** — Tab 1 상단 `RealAllVessels` 편의 export 를 `mmsiPrefix='412'` 로 고정 + 제목 "중국 선박 전체 분석 결과 (실시간)" 로 변경. 상단/하단 모두 중국 선박 기준으로 일관 표시
### 추가
- **선박 유형 한글 카탈로그**`shared/constants/vesselTypes.ts` 신설. 저인망/선망/유자망/연승/통발/운반선/미분류 한글 라벨 + Badge intent. 기존 `alertLevels` 패턴 답습, `catalogRegistry` 등록으로 design-system 쇼케이스 자동 노출
## [2026-04-16.6]
### 추가
- **중국어선 감시 화면 실데이터 연동 (3개 탭)** — deprecated iran proxy `/api/vessel-analysis` → 자체 백엔드 `/api/analysis/*` 전환. AI 감시 대시보드·환적접촉탐지·어구/어망 판별 모두 prediction 5분 사이클 결과 실시간 반영. 관심영역/VIIRS/기상/VTS 카드는 "데모 데이터" 뱃지, 비허가/제재/관심 선박 탭은 "준비중" 뱃지로 데이터 소스 미연동 항목 명시
- **특이운항 미니맵 + 판별 구간 패널** — AI 감시 대시보드 선박 리스트 클릭 → 24h AIS 항적(MapLibre + deck.gl) + Dark/Spoofing/환적/어구위반/고위험 신호를 시간순 segment 로 병합해 지도 하이라이트(CRITICAL/WARNING/INFO 3단계). 판별 패널에 시작~종료·지속·N회 연속 감지·카테고리·설명 표시. 어구/어망 판별 탭 최하단 자동탐지 결과 row 클릭 시 상단 입력 폼 프리필
- **`/api/analysis/stats`** — MMSI별 최신 row 기준 단일 쿼리 COUNT FILTER 집계(total/dark/spoofing/transship/risk 분포/zone 분포/fishing/avgRiskScore + windowStart/End). 선택적 `mmsiPrefix` 필터(중국 선박 '412' 등)
- **`/api/analysis/gear-detections`** — gear_code/judgment NOT NULL row MMSI 중복 제거 목록. 자동 탐지 결과 섹션 연동용
- **`/api/analysis/vessels` 필터 확장** — `mmsiPrefix` / `minRiskScore` / `minFishingPct` 쿼리 파라미터 추가
- **VesselAnalysisResponse 필드 확장**`violationCategories` / `bd09OffsetM` / `ucafScore` / `ucftScore` / `clusterId` 5개 필드 노출
- **prediction 분석 시점 좌표 저장**`AnalysisResult` + `to_db_tuple` + `upsert_results` SQL 에 `lat/lon` 추가. 분류 파이프라인(last_row) / 경량 분석(all_positions) 두 경로 주입. 기존 `vessel_analysis_results.lat/lon` 컬럼이 항상 NULL 이던 구조적 누락 해결 (첫 사이클 8173/8173 non-null 확인)
## [2026-04-16.5]
### 변경
- **Admin 11개 페이지 디자인 시스템 하드코딩 색상 제거 (Phase 1-B)** — 129건 Tailwind 색상 → 시맨틱 토큰(text-label/text-heading/text-hint) + Badge intent 치환. raw `<button>``<Button>` 컴포넌트 교체. 미사용 import 정리
## [2026-04-16.4]
### 추가
- **G-02 금어기 조업 탐지**`fishery_permit_cn.fishing_period_1/2` 파싱(YYYY/MM/DD 범위) + `classify_gear_violations()` 에 permit_periods/observation_ts 인자 추가. 허가기간 밖 조업 시 `CLOSED_SEASON_FISHING` judgment
- **G-03 미등록/허가외 어구 탐지** — 감지 어구와 `fishery_code` 허용 목록 대조(PT→trawl, GN→gillnet, FC→금지 등). 불일치 시 `UNREGISTERED_GEAR` judgment
- **NAME_FUZZY 매칭** — 선박명 정규화(공백/대소문자/'NO.' 마커 통일, 선박번호 유지) + name_en 기반 fuzzy lookup. 동명이 중복 방지. 매칭률 9.1% → 53.1%
- **서버 스크립트 tier/G-02/G-03/match_method 추적** — diagnostic(5분) + hourly(1시간) 에 pair_tier 분포, reject 사유 카운터, fishery_code×match_method 교차, G-02/G-03 상세 섹션
### 변경
- **pair_trawl tier 분류** — AND 게이트(스펙 100% 2시간) 대신 STRONG/PROBABLE/SUSPECT 3단계. 완화 임계(800m/SOG 1.5-5/sog_delta 1.0/cog 20°)로 실제 공조 신호 포착. G-06 판정은 STRONG/PROBABLE 만
- **pair_trawl join key** — raw AIS timestamp → `time_bucket`(5분 리샘플). sog/cog on-demand 계산(`_ensure_sog_cog`)으로 vessel_store._tracks 직접 사용
- **pair base 확장** — classification 500척 → 전체 중국 MID(412/413/414) 조업 속력대 선박. candidates 61→1,668, detected 0→57
- **match_ais_to_registry 대상 확장** — vessel_dfs(500척) → vessel_store._tracks 전체 중국 선박(8k+)
### 수정
- **violation_classifier**`CLOSED_SEASON_FISHING`, `UNREGISTERED_GEAR` judgment → `ILLEGAL_GEAR` 카테고리 매핑 추가
## [2026-04-16.3]
### 변경
- **Admin 3개 페이지 디자인 시스템 준수 리팩토링 (Phase 1-A)** — PerformanceMonitoring/DataRetentionPolicy/DataModelVerification 자체 탭 네비 → `TabBar/TabButton` 공통 컴포넌트, 원시 `<button>``TabButton`, PerformanceMonitoring 정적 hex 9건 → `performanceStatus` 카탈로그 경유
- **신규 카탈로그** `shared/constants/performanceStatus.ts` — PerformanceStatus(good/warning/critical/running/passed/failed/active/scheduled/archived) → {intent, hex, label} + utilizationStatus(ratio) 헬퍼
- **RBAC skeleton** — 3개 페이지 최상단 `useAuth().hasPermission('admin:{resource}', 'OP')` 호출 배치 (Phase 3 액션 버튼 추가 시 가드로 연결)
## [2026-04-16.2]
### 추가
- **성능 모니터링(PER-01~06) 메뉴** — 시스템관리 > 감사·보안에 성능 모니터링 페이지 추가 (PerformanceMonitoring 컴포넌트 + V028 메뉴 마이그레이션)
- **데이터 모델 검증(DAR-11) 메뉴** — DataModelVerification 페이지 + V027 메뉴
- **데이터 보관·파기 정책(DAR-10) 메뉴** — DataRetentionPolicy 페이지 + V026 메뉴
- **DAR-03 5종 어구 구조 비교** — AI 모델관리 어구 탐지 탭에 저층 트롤 / 스토우넷 / 자망 / 통발 / 쌍끌이 이미지 및 설명 추가
- **단속 계획 탭 확장** — 단일 함정 순찰 작전 / 다함정 순찰 작전 탭 추가 (EnforcementPlan)
## [2026-04-16]
### 추가
- **한중어업협정 중국어선 허가현황 레지스트리**`kcg.fishery_permit_cn` 신규 테이블(29컬럼, 연단위 스냅샷). V029 마이그레이션 + `fleet_vessels.permit_year/fishery_code` 컬럼 추가. `load_fishery_permit_cn.py`로 연도별 XLS → DB 적재(906척/497 신청인사)
- **페어 탐색 재설계**`find_pair_candidates()` bbox 1차(인접 9 cell) + 궤적 유사도 2차(location/sog_corr/cog_alignment). 동종 어선 페어도 허용, role 가점(PT_REGISTERED/COOP_FISHING/TRANSSHIP_LIKE)
- **fleet_tracker API 3개**`get_pt_registered_mmsis` / `get_gear_episodes` / `get_gear_positions`
### 수정
- **DAR-03 G-04/G-05/G-06 Dead code 해결**`classify_gear_violations()` scheduler 호출 연결. `if 'pair_results' in dir()` 버그 제거. 사이클당 G-05 303건 / G-04 1건 탐지 시작
- **spoofing 산식** — 24h 희석 버그 → 최근 1h 윈도우 + teleport 절대 가점(건당 0.20) + extreme(>50kn) 단독 발견 시 score=max(0.6) 확정
- **gear_code DB write 경로**`AnalysisResult.gear_code` 필드 + `kcgdb.upsert_results()` INSERT/UPDATE + scheduler 두 경로에서 `fleet_tracker.get_vessel_gear_code()` 호출
### 변경
- **transshipment 선종 완화**`_CARRIER_HINTS`(cargo/tanker/supply/carrier/reefer) 부분일치 + 412* 중국어선 FISHING 간주
- **gear drift 임계** — 750m → **500m** (DAR-03 스펙 정합)
- **fleet_tracker 현재 연도 필터**`WHERE permit_year = EXTRACT(YEAR FROM now())::int OR permit_year IS NULL`
### 기타
- cron 스크립트 신규 섹션: hourly P1~P5(허가/매칭/gear_code/fleet_role) + D3.5(pair_type) / diagnostic PART 7.5 + 4-5.5
## [2026-04-15]
### 추가
- **DAR-03 G-code 위반 분류** — prediction에 G-01(수역×어구 위반)/G-04(MMSI 사이클링)/G-05(고정어구 표류)/G-06(쌍끌이 공조) 4개 위반 유형 자동 분류 + 점수 합산
- **쌍끌이 공조 탐지 알고리즘** — pair_trawl.py 신규 (cell-key 파티션 O(n) 스캔, 500m 근접·0.5kn 속도차·10° COG 일치·2h 지속 임계값)
- **모선 검토 워크플로우** — 어구 판정 상세 패널에 후보 검토 UI 추가 (관측 지표 7종 평균 + 보정 지표 + 모선 확정/제외 버튼). 별도 화면 진입 없이 어구 탐지 페이지 내에서 의사결정
- **24시간 궤적 리플레이** — TripsLayer fade trail 애니메이션, 멤버별 개별 타임라인 보간(빈 구간 자연 연속), convex hull 폴리곤 실시간 생성, 후보 선박 항적 동시 재생 (signal-batch /api/v2/tracks/vessels 프록시 연동)
- **어구 탐지 그리드 UX** — 다중 선택 필터 패널(설치 해역/판정/위험도/모선 상태/허가/멤버 수 슬라이더, localStorage 영속화), 행 클릭 시 지도 flyTo, 후보 일치율 칼럼 + 정렬
### 변경
- **그리드 후보 일치율 정확도** — resolution.top_score(평가 시점 고정) 대신 correlation_scores.current_score(실시간 갱신)의 최댓값 사용 → 최신 점수 반영
- **어구 그룹 칼럼 표시** — 모선 후보 MMSI가 그룹명 자리에 표시되던 버그 수정 (groupLabel/groupKey 우선 표시)
- **ParentResolution Entity 확장** — top_score/confidence/score_margin/decision_source/stable_cycles 등 점수 근거 7개 필드 추가
- **백엔드 correlation API 응답 정규화** — snake_case 컬럼을 camelCase로 명시 매핑 (프론트 매핑 누락 방지)
### 수정
- **궤적 리플레이 깜박임** — useMapLayers와 useGearReplayLayers가 동시에 overlay.setProps()를 호출하던 경쟁 조건 제거. 리플레이 활성 시 useMapLayers 비활성화 (단일 렌더링 경로)
- **멤버-중심 연결선 제거** — 어구 그룹 선택 모드에서 불필요하게 그려지던 dashed 연결선 코드 삭제
### 기타
- **루트 .venv/ gitignore 추가**
## [2026-04-14]
### 추가
- **DarkVesselDetection 판정 상세 패널** — 테이블 행 클릭 시 점수 산출 내역(P1~P11), GAP 상세, 7일 이력 차트 사이드 패널 표시
- **ScoreBreakdown 공통 컴포넌트** — 가점/감점 분리 점수 내역 시각화
- **darkVesselPatterns 카탈로그 확장** — prediction 실제 판정 패턴 18종 한국어 라벨+점수+설명 + buildScoreBreakdown() 유틸
- **TransferDetection 환적 운영 화면** — 5단계 파이프라인 기반 재구성 (KPI, 쌍 목록, 쌍 상세, 감시영역 지도, 탐지 조건 시각화)
- **GearDetection 모선 추론 연동** — 모선 상태(DIRECT_MATCH/AUTO_PROMOTED/REVIEW_REQUIRED), 추정 모선 MMSI, 후보 수 컬럼
### 변경
- **DarkVesselDetection 위치 표시 수정** — lat/lon null 시 features.gap_start_lat/lon fallback, 클릭 시 지도 하이라이트
- **EnforcementPlan 탐지 기반 단속 대상** — CRITICAL 이벤트를 카테고리별(다크베셀/환적/EEZ침범/고위험) 아이콘+라벨로 통합 표시
- **LGCNS 3개 페이지 디자인 시스템 전환** — LGCNSMLOps/AISecurityPage/AIAgentSecurityPage 공통 구조 적용
## [2026-04-13]
### 추가
- **LGCNS MLOps 메뉴** — 시스템관리 > AI 플랫폼 하위, 모델 레지스트리/학습 파이프라인/서빙 현황/모델 모니터링 탭 구성
- **AI 보안(SER-10) 메뉴** — 시스템관리 > 감사·보안 하위, AI 모델 보안 감사/Adversarial 공격 탐지/데이터 무결성 검증/보안 이벤트 타임라인
- **AI Agent 보안(SER-11) 메뉴** — 시스템관리 > 감사·보안 하위, 에이전트 실행 로그/정책 위반 탐지/자원 사용 모니터링/신뢰도 대시보드
- **V025 마이그레이션** — auth_perm_tree에 admin:lgcns-mlops, admin:ai-security, admin:ai-agent-security 노드 + ADMIN 역할 CRUD 권한 시드
- **prediction 알고리즘 재설계** — dark_vessel 의심 점수화(8패턴 0~100), transshipment 베테랑 재설계, vessel_store/scheduler 개선
- **프론트엔드 지도 레이어 구조 정리** — BaseMap, useMapLayers, static layers 리팩토링
### 변경
- **NoticeManagement CRUD 권한 가드** — admin:notices CREATE/UPDATE/DELETE 체크 추가 (disabled + 툴팁)
- **EventList CRUD 권한 가드** — enforcement:event-list UPDATE + enforcement:enforcement-history CREATE 체크 추가 (disabled + 툴팁)
## [2026-04-09]
### 추가
- **워크플로우 연결 5단계** — 탐지→단속 관통 워크플로우 구현
- **VesselAnalysis 직접 조회 API 5개** (`/api/analysis/*`) — iran proxy 없이 prediction DB 직접 조회
- vessels 목록 (필터: mmsi, zone, riskLevel, isDark)
- vessels/{mmsi} 최신 분석 (features JSONB 포함)
- vessels/{mmsi}/history 분석 이력 (24h)
- dark 베셀 목록 (MMSI 중복 제거)
- transship 의심 목록
- **EventList 인라인 액션 4종** — 확인(ACK)/선박상세/단속등록/오탐 처리
- **MMSI → VesselDetail 링크** — EventList, DarkVessel, EnforcementHistory 3개 화면
- **VesselDetail 전면 개편** — prediction 직접 API 전환, dark 패턴 시각화(tier/score/patterns), 환적 분석, 24h AIS 수신 타임라인, 단속 이력 탭
- **DarkVesselDetection prediction 전환** — iran proxy 제거, tier 기반 KPI/필터/정렬
- **EnforcementHistory eventId 역추적** — 단속→이벤트 역링크
- **EnforcementPlan 미배정 CRITICAL 이벤트 패널** — NEW 상태 CRITICAL 이벤트 표시
- **모선추론 자동 연결** — CONFIRM→LabelSession, REJECT→Exclusion 자동 호출
- **30초 자동 갱신** — EventList, DarkVessel (silentRefresh 패턴, 깜박임 없음)
- **admin 메뉴 4개 서브그룹** — AI 플랫폼/시스템 운영/사용자 관리/감사·보안
- **V018 마이그레이션** — prediction_events.features JSONB 컬럼
- **V019 마이그레이션** — ai-operations:llm-ops 권한 트리 항목
- **analysisApi.ts** 프론트 서비스 (직접 조회 API 5개 연동)
- **PredictionEvent.features** 타입 확장 (dark_tier, transship_score 등)
- **메뉴 DB SSOT 구조화** — auth_perm_tree 기반 메뉴·권한·i18n 통합
- auth_perm_tree에 메뉴 컬럼 추가 (url_path, label_key, component_key, nav_group, nav_sort)
- labels JSONB 다국어 지원 (`{"ko":"종합 상황판", "en":"Dashboard"}`) — DB가 i18n SSOT
- 보이지 않는 도메인 그룹 8개 삭제 (surveillance, detection 등) → 권한 트리 = 메뉴 트리 완전 동기화
- 패널 노드 parent_cd 실제 소속 페이지로 수정 (어구식별→어구탐지, 전역제외→후보제외)
- vessel:vessel-detail 권한 노드 제거 (드릴다운 전용, 인증만 체크)
- 공유 리소스 분리: statistics:reports, admin:data-hub, admin:notices 독립 노드 생성
- V020~V024 마이그레이션 5건
- **프론트엔드 동적 메뉴/라우팅** — DB 기반 자동 구성
- menuStore(Zustand) + componentRegistry(lazy loading) + iconRegistry
- NAV_ENTRIES/PATH_TO_RESOURCE 하드코딩 제거
- App.tsx DynamicRoutes: DB menuConfig에서 Route 자동 생성
- MainLayout: DB menuConfig에서 사이드바 자동 렌더링
- **PermissionsPanel 개선** — DB labels 기반 표시명 + 페이지/패널 아이콘 구분 + 메뉴 순서 정렬
- **DB migration README.md 전면 재작성** — V001~V024, 49테이블, 149인덱스 실측 문서화
- **Dark Vessel 의심 점수화** — 기존 "gap≥30분→dark" 이분법에서 8가지 패턴 기반 0~100점 점수 산출 + CRITICAL/HIGH/WATCH/NONE 등급 분류
- P1 이동 중 OFF / P2 민감 수역 / P3 반복 이력(7일) / P4 거리 비정상 / P5 주간 조업 OFF / P6 직전 이상행동 / P7 무허가 / P8 장기 gap
- 한국 AIS 수신 커버리지 밖은 자연 gap 가능성으로 감점
- 어구(gear) AIS, 한국 선박(440/441) dark 판정 완전 제외
- `features` JSONB에 `dark_suspicion_score`, `dark_patterns`, `dark_tier`, `dark_history_7d` 등 저장
- **Transshipment 베테랑 관점 재설계** — 점수 기반 40~130점 판정 (CRITICAL/HIGH/WATCH)
- SOG 2.0→1.0kn, 근접 110→77m, 지속 60→45분 + gap tolerance 2사이클
- 한국 EEZ 관할 수역 이내 필수, 어구/여객/군함/유조 제외
- 야간·무허가·COG편차·장기지속·영해위치 가점
- pair_history 구조 확장: `{'first_seen', 'last_seen', 'miss_count'}` (GPS 노이즈 내성)
### 변경
- **event_generator.py** INSERT에 features JSONB 추가 (이벤트에 분석 핵심 특성 저장)
- **@RequirePermission 12곳 수정** — 삭제된 그룹 rsrc_cd → 구체적 자식 리소스
- **EnforcementController** vesselMmsi 필터 파라미터 추가
- **enforcement.ts** getEnforcementRecords에 vesselMmsi 파라미터 추가
- **stats_aggregator hourly**: UTC→KST hour boundary 전환, `by_category`/`by_zone` JSONB 집계 추가
- **event_generator 룰 전면 재편**:
- EEZ_INTRUSION: 실측 zone_code(TERRITORIAL_SEA/CONTIGUOUS_ZONE/ZONE_*) 기반 신규 3룰
- HIGH_RISK_VESSEL: risk.py CRITICAL 임계값과 동일(70점) 연동, 50~69점 MEDIUM 분리
- DARK_VESSEL: features.dark_tier 기반 CRITICAL/HIGH 룰 (기존 gap>60 룰 교체)
- ILLEGAL_TRANSSHIP: features.transship_tier 기반 CRITICAL/HIGH 룰 (기존 단순 룰 교체)
- break 제거 → mmsi당 복수 카테고리 동시 매칭 가능
- dedup 윈도우 prime 값 분산 (60/120/360→67/127/367 등, 정시 일제 만료 회피)
- **lightweight path 신호 보강**: vessel_store 24h 궤적으로 dark/spoofing/speed_jump 산출
- `compute_lightweight_risk_score`에 dark gap + spoofing 가점 추가 (max 60→100)
- `_gear_re` 중복 제거 → `fleet_tracker.GEAR_PATTERN` SSOT 통합
- `AnalysisResult.to_db_tuple` features sanitize: 중첩 dict/list 지원
### 수정
- `/map-control` labelKey 중복 해소 (nav.riskMap → nav.mapControl, "해역 관리")
- system-flow 08-frontend.json 누락 노드 14개 추가
- `prediction_stats_hourly.by_category`/`by_zone` 영구 NULL → 채움
- `prediction_stats_hourly.critical_count` 영구 0 → CRITICAL 이벤트 수 반영
- `prediction_events` 카테고리 2종(ZONE_DEPARTURE/ILLEGAL_TRANSSHIP)만 → 6종 이상
- KPI `dark_vessel`/`eez_violation` 영구 0 → 정상 집계
- 이벤트 홀수/짝수시 4~22배 진폭 → dedup prime 분산으로 완화
- dark 과다 판정 해소: 핫픽스(한국 수신 영역 필터) + 2차(의심 점수화)
- transship 과다 판정 해소: 사이클당 2,400~12,600 → CRITICAL/HIGH/WATCH 점수 기반
### 문서
- i18n darkTier/transshipTier/adminSubGroup/mapControl 키 추가 (ko/en)
## [2026-04-08]
### 추가
- **디자인 시스템 쇼케이스** (`/design-system.html`) — UI 단일 진실 공급원(SSOT)
- 별도 Vite entry, 메인 SPA와 분리 (designSystem-*.js 54KB)
- 10개 섹션: Intro / Token / Typography / Badge / Button / Form / Card / Layout / Catalog / Guide
- 추적 ID 체계 `TRK-<카테고리>-<슬러그>` (예: `TRK-BADGE-critical-sm`)
- 호버 시 툴팁, "ID 복사 모드", URL 해시 딥링크 `#trk=...`
- 단축키 `A`로 다크/라이트 토글
- 한글/영문 라벨 병기로 카탈로그 검토 용이
- **신규 공통 컴포넌트**:
- `Button` (5 variant × 3 size = 15) `@shared/components/ui/button`
- `Input` / `Select` / `Textarea` / `Checkbox` / `Radio` `@shared/components/ui/`
- `TabBar` / `TabButton` (underline / pill / segmented 3 variant) `@shared/components/ui/tabs`
- `PageContainer` (size sm/md/lg + fullBleed) `@shared/components/layout/PageContainer`
- `PageHeader` (icon + title + description + demo + actions) `@shared/components/layout/PageHeader`
- `Section` (Card 단축) `@shared/components/layout/Section`
- **중앙 레지스트리**:
- `catalogRegistry.ts` — 23개 카탈로그 메타 (id/title/description/source/items 자동 enumerate)
- `variantMeta.ts` — Badge intent 8종 + Button variant 5종 의미 가이드
- `statusIntent.ts` — 한글/영문 ad-hoc 상태 → BadgeIntent + getRiskIntent(0~100)
- **4 catalog에 intent 필드 추가**: eventStatuses / enforcementResults / enforcementActions / patrolStatuses + getXxxIntent() 헬퍼
- **UI 공통 카탈로그 19종** (`frontend/src/shared/constants/`) — 백엔드 enum/code_master 기반 SSOT
- violation/alert/event/enforcement/patrol/engine/userRole/device/parentResolution/
modelDeployment/gearGroup/darkVessel/httpStatus/userAccount/loginResult/permission/
vesselAnalysis/connection/trainingZone + kpiUiMap
- 표준 API: `get{Cat}Intent(code)`, `get{Cat}Label(code, t, lang)`, `get{Cat}Classes/Hex`
- **시맨틱 텍스트 토큰** (`theme.css @layer utilities`) — Tailwind v4 복합 이름 매핑 실패 대응
- `text-heading/label/hint/on-vivid/on-bright`, `bg-surface-raised/overlay` 직접 정의
- **Badge 시스템 재구축** — CVA 기반 8 intent × 4 size (rem), !important 제거
- **cn() 유틸** (`lib/utils/cn.ts`) — clsx + tailwind-merge, 시맨틱 토큰 classGroup 등록
- **ColorPicker 컴포넌트** — 팔레트 grid + native color + hex 입력
- **Role.colorHex** (백엔드 V017 migration) — auth_role.color_hex VARCHAR(7), 빌트인 5개 역할 기본 색상 시드
- System Flow 뷰어 (`/system-flow.html`) — 시스템 전체 데이터 흐름 시각화
- 102 노드 + 133 엣지, 10개 카테고리 매니페스트
- stage/menu 두 가지 그룹화 토글, 검색/필터/딥링크 지원
- 포커스 모드 (1-hop 연결 노드만 활성화, 나머지 dim)
- 메인 SPA와 분리된 별도 entry, 산출문서 노드 ID 참조용
- `/version` 스킬 사후 처리로 manifest version 자동 동기화
- CI/CD에서 버전별 스냅샷을 서버 archive에 영구 보존
- 백엔드 `GET /api/stats/hourly?hours=24` — 시간별 통계 조회 (PredictionStatsHourly)
- V014 prediction 보조 테이블 12개 (fleet_vessels, gear_correlation_scores 등)
- V015 NUMERIC precision 일괄 확대 (score→7,4, pct→12,2)
- V016 parent workflow 누락 컬럼 일괄 추가 (17+ 컬럼, candidate_mmsi generated column)
### 변경
- **35+ feature 페이지 PageContainer/PageHeader 마이그레이션** — admin/detection/enforcement/field-ops/patrol/statistics/ai-operations/parent-inference/dashboard/monitoring/surveillance/vessel/risk-assessment 전체
- **VesselDetail `-m-4` negative margin 해킹 → `<PageContainer fullBleed>`** 정리
- **LiveMapView fullBleed 패턴** 적용
- **Badge intent 팔레트 테마 분리**: 라이트(파스텔 `bg-X-100 text-X-900`) + 다크(translucent `bg-X-500/20 text-X-400`)
- **40+ 페이지 Badge/시맨틱 토큰 마이그레이션**
- Badge className 직접 작성 → intent/size prop 변환
- 컬러풀 액션 버튼 → `text-on-vivid` (흰색), 검색/필터 버튼 → `bg-blue-400 text-on-bright`
- ROLE_COLORS 4곳 중복 제거 → `getRoleBadgeStyle()` 공통 호출
- PermissionsPanel 역할 생성/수정에 ColorPicker 통합
- DataTable `width` 의미 변경: 고정 → 선호 최소 너비 (minWidth), 컨텐츠 자동 확장 + truncate
- `dateFormat.ts` sv-SE 로케일로 `YYYY-MM-DD HH:mm:ss` 일관된 KST 출력
- MonitoringDashboard `PagePagination` 제거 (데이터 페이지네이션 오해 해소)
### 수정
- **접근성 (WCAG 2.1 Level A)** — axe DevTools 위반 전수 처리:
- `<Select>` 컴포넌트 TypeScript union 타입으로 `aria-label`/`aria-labelledby`/`title` 컴파일 강제
- 네이티브 `<select>` 5곳 aria-label
- 아이콘 전용 `<button>` 16곳 aria-label
- `<input>`/`<textarea>` 28곳 aria-label (placeholder 자동 복제 포함)
- AIModelManagement 토글 → `role="switch"` + `aria-checked`
- **Badge className 위반 37건 전수 제거**`<Badge intent="...">` 패턴으로 통일
- **하드코딩 `bg-X-500/20 text-X-400` 56곳 제거** — 카탈로그 API + intent 사용
- **인라인 `<button>` type 누락 86곳 보정**
- **CSS Safari 호환성**: `backdrop-filter` `-webkit-` prefix 추가 (디자인 쇼케이스)
- `trk-pulse` keyframe outline-color → opacity (composite-only 최적화)
- Dashboard RiskBar 단위 버그 (0~100 정수를 `*100` 하던 코드 → 범위 감지)
- ReportManagement, TransferDetection `p-5 space-y-4` padding 복구
- EnforcementHistory 그리드 minmax 적용으로 컬럼 잘림 해소
- timeline 시간 `formatDateTime` 적용 (ISO `T` 구분자 처리)
- **prediction e2e 5가지 이슈 수정** (2026-04-08)
- gear_correlation: psycopg2 Decimal × float TypeError → `_load_all_scores()` float 변환
- violation_classifier: `(mmsi, analyzed_at)` 기준 UPDATE + 중국선박 EEZ 판정 로직
- kpi_writer / stats_aggregator: UTC → KST 날짜 경계 통일
- parent workflow 스키마 ↔ 코드 불일치 → V016로 일괄 해결
- DemoQuickLogin hostname 기반 노출 (Gitea CI `.env` 차단 대응)
- 프론트 전수 mock 정리: eventStore.alerts, enforcementStore.plans, transferStore 완전 제거
- Dashboard/MonitoringDashboard/Statistics 하드코딩 → 실 API 전환
- UTC → KST 시간 표시 통일 (`@shared/utils/dateFormat.ts` 공통 유틸)
- i18n `group.parentInference` JSON 중복키 제거
- RiskMap Math.random() 격자 제거, MTIS 라벨 + "AI 분석 데이터 수집 중" 안내
- 12개 mock 화면에 "데모 데이터" 노란 배지 추가
## [2026-04-07]

파일 보기

@ -50,7 +50,6 @@ src/
│ ├── i18n/ # 10 NS (common, dashboard, detection, patrol, enforcement, statistics, ai, fieldOps, admin, auth)
│ │ ├── config.ts # i18next 초기화 (ko 기본, en 폴백)
│ │ └── locales/ # ko/*.json, en/*.json (10파일 x 2언어)
│ │ # 2026-04-17: common.json 에 aria(36)/error(7)/dialog(4)/success(2)/message(5) 네임스페이스 추가
│ └── theme/ # tokens, colors, variants (CVA)
│ ├── tokens.ts # CSS 변수 매핑 + resolved 색상값
│ ├── colors.ts # 시맨틱 팔레트 (risk, alert, vessel, status, chartSeries)
@ -90,28 +89,20 @@ src/
│ ├── ws.ts # connectWs (STOMP 스텁, 미구현)
│ └── index.ts # 배럴 export
├── shared/components/ # 공유 UI 컴포넌트 (design-system.html SSOT)
│ ├── ui/ # 9개 공통 컴포넌트 (2026-04-17 모든 화면 SSOT 준수 완료)
│ │ ├── card.tsx # Card(CVA variant), CardHeader, CardTitle, CardContent (4 variant)
│ │ ├── badge.tsx # Badge(CVA intent 8종 × size 4단계, LEGACY_MAP 변형 호환)
│ │ ├── button.tsx # Button (variant 5종 × size 3단계, icon/trailingIcon prop)
│ │ ├── input.tsx # Input (size/state, forwardRef)
│ │ ├── select.tsx # Select (aria-label|aria-labelledby|title TS union 강제)
│ │ ├── textarea.tsx # Textarea
│ │ ├── checkbox.tsx # Checkbox (native input 래퍼)
│ │ ├── radio.tsx # Radio
│ │ └── tabs.tsx # TabBar + TabButton (underline/pill/segmented 3 variant)
│ ├── layout/ # PageContainer / PageHeader / Section (표준 페이지 루트)
├── shared/components/ # 공유 UI 컴포넌트
│ ├── ui/
│ │ ├── card.tsx # Card(CVA variant), CardHeader, CardTitle, CardContent
│ │ └── badge.tsx # Badge(CVA intent/size)
│ └── common/
│ ├── DataTable.tsx # 범용 테이블 (가변너비, 검색, 정렬, 페이징, 엑셀, 출력)
│ ├── Pagination.tsx # 페이지네이션
│ ├── SearchInput.tsx # 검색 입력 (i18n 통합)
│ ├── SearchInput.tsx # 검색 입력
│ ├── ExcelExport.tsx # 엑셀 다운로드
│ ├── FileUpload.tsx # 파일 업로드
│ ├── PageToolbar.tsx # 페이지 상단 툴바
│ ├── PrintButton.tsx # 인쇄 버튼
│ ├── SaveButton.tsx # 저장 버튼
│ └── NotificationBanner.tsx # 알림 배너 (common.aria.closeNotification)
│ └── NotificationBanner.tsx # 알림 배너
├── features/ # 13 도메인 그룹 (31 페이지)
│ ├── dashboard/ # 종합 대시보드 (Dashboard)

파일 보기

@ -0,0 +1,252 @@
# Mock 데이터 공유 현황 분석 및 통합 결과
> 최초 작성일: 2026-04-06
> 마지막 업데이트: 2026-04-06
> 대상: `kcg-ai-monitoring` 프론트엔드 코드베이스 전체 (31개 페이지)
> 상태: **통합 완료**
---
## 1. 선박 데이터 교차참조
현재 동일한 선박 데이터가 여러 컴포넌트에 독립적으로 하드코딩되어 있다. 각 파일마다 동일 선박의 속성(위험도, 위치, 상태 등)이 서로 다른 형식과 값으로 중복 정의되어 있어 데이터 일관성 문제가 발생한다.
| 선박명 | 등장 파일 수 | 파일 목록 |
|---|---|---|
| 鲁荣渔56555 | 7+ | Dashboard, MobileService, LiveMapView, MonitoringDashboard, EventList, EnforcementHistory, ChinaFishing |
| 浙甬渔60651 | 4 | Dashboard, LiveMapView, EventList, DarkVesselDetection |
| 冀黄港渔05001 | 6 | MobileService, LiveMapView, Dashboard, TransferDetection, EventList, GearDetection |
| 3001함 | 6+ | ShipAgent, MobileService, LiveMapView, Dashboard, PatrolRoute, FleetOptimization |
| 3009함 | 6+ | ShipAgent, MobileService, Dashboard, PatrolRoute, FleetOptimization, AIAlert |
| 미상선박-A | 5 | MobileService, Dashboard, LiveMapView, MonitoringDashboard, EventList |
### 문제점
- 하나의 선박이 평균 5~7개 파일에 중복 정의됨
- 선박 속성(이름, MMSI, 위치, 위험도, 상태)이 파일마다 미세하게 다를 수 있음
- 새 선박 추가/수정 시 모든 관련 파일을 일일이 찾아 수정해야 함
---
## 2. 위험도 스케일 불일치
동일한 선박의 위험도가 페이지마다 서로 다른 스케일로 표현되고 있다.
| 선박명 | Dashboard (risk) | DarkVesselDetection (risk) | MonitoringDashboard |
|---|---|---|---|
| 鲁荣渔56555 | **0.96** (0~1 스케일) | - | **CRITICAL** (레벨 문자열) |
| 浙甬渔60651 | **0.85** (0~1 스케일) | **94** (0~100 정수) | - |
| 미상선박-A | **0.94** (0~1 스케일) | **96** (0~100 정수) | - |
### 원인 분석
- Dashboard는 `risk: 0.96` 형식 (0~1 소수)
- DarkVesselDetection은 `risk: 96` 형식 (0~100 정수)
- MonitoringDashboard는 `'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW'` 레벨 문자열
- LiveMapView는 `risk: 0.94` 형식 (0~1 소수)
- EventList는 레벨 문자열 (`AlertLevel`)
### 통합 방안
위험도를 **0~100 정수** 스케일로 통일하되, 레벨 문자열은 구간별 자동 매핑 유틸로 변환한다.
```
0~30: LOW | 31~60: MEDIUM | 61~85: HIGH | 86~100: CRITICAL
```
---
## 3. KPI 수치 중복
Dashboard와 MonitoringDashboard가 **완전히 동일한 KPI 수치**를 독립적으로 정의하고 있다.
| 지표 | Dashboard `KPI_DATA` | MonitoringDashboard `KPI` |
|---|---|---|
| 실시간 탐지 | 47 | 47 |
| EEZ 침범 | 18 | 18 |
| 다크베셀 | 12 | 12 |
| 불법환적 의심 | 8 | 8 |
| 추적 중 | 15 | 15 |
| 나포/검문(금일 단속) | 3 | 3 |
### 문제점
- 6개 KPI 수치가 두 파일에 100% 동일하게 하드코딩
- 수치 변경 시 양쪽 모두 수정해야 함
- Dashboard에는 `prev` 필드(전일 비교)가 추가로 있으나, Monitoring에는 없음
---
## 4. 이벤트 타임라인 중복
08:47~06:12 시계열 이벤트가 최소 4개 파일에 각각 정의되어 있다.
| 시각 | Dashboard | Monitoring | MobileService | EventList |
|---|---|---|---|---|
| 08:47 | EEZ 침범 (鲁荣渔56555) | EEZ 침범 (鲁荣渔56555 외 2척) | [긴급] EEZ 침범 탐지 | EVT-0001 EEZ 침범 |
| 08:32 | 다크베셀 출현 | 다크베셀 출현 | 다크베셀 출현 | EVT-0002 다크베셀 |
| 08:15 | 선단 밀집 경보 | 선단 밀집 경보 | - | EVT-0003 선단밀집 |
| 07:58 | 불법환적 의심 | 불법환적 의심 | 환적 의심 | EVT-0004 불법환적 |
| 07:41 | MMSI 변조 탐지 | MMSI 변조 탐지 | - | EVT-0005 MMSI 변조 |
| 07:23 | 함정 검문 완료 | 함정 검문 완료 | - | EVT-0006 검문 완료 |
| 06:12 | 속력 이상 탐지 | - | - | EVT-0010 속력 이상 |
### 문제점
- 동일 이벤트의 description이 파일마다 미세하게 다름 (예: "鲁荣渔56555" vs "鲁荣渔56555 외 2척")
- EventList에는 ID가 있으나(EVT-xxxx), 다른 파일에는 없음
- Dashboard에는 10개, Monitoring에는 6개, EventList에는 15개로 **건수도 불일치**
---
## 5. 환적 데이터 100% 중복
`TransferDetection.tsx``ChinaFishing.tsx`에 **TR-001~TR-003 환적 데이터가 완전히 동일**하게 정의되어 있다.
```
TransferDetection.tsx:
const transferData = [
{ id: 'TR-001', time: '2026-01-20 13:42:11', a: {name:'장저우8호'}, b: {name:'黑江9호'}, ... },
{ id: 'TR-002', time: '2026-01-20 11:15:33', ... },
{ id: 'TR-003', time: '2026-01-20 09:23:45', ... },
];
ChinaFishing.tsx:
const TRANSFER_DATA = [
{ id: 'TR-001', time: '2026-01-20 13:42:11', a: {name:'장저우8호'}, b: {name:'黑江9호'}, ... },
{ id: 'TR-002', time: '2026-01-20 11:15:33', ... },
{ id: 'TR-003', time: '2026-01-20 09:23:45', ... },
];
```
### 문제점
- 변수명만 다르고 (`transferData` vs `TRANSFER_DATA`) 데이터 구조와 값이 100% 동일
- 한쪽만 수정하면 다른 쪽과 불일치 발생
---
## 6. 함정 상태 불일치
동일 함정의 상태가 페이지마다 모순되는 경우가 확인되었다.
| 함정 | ShipAgent | Dashboard | PatrolRoute | FleetOptimization |
|---|---|---|---|---|
| 5001함 | **오프라인** (`status: '오프라인'`) | **가용** (PATROL_SHIPS에 대기로 표시) | **가용** (`status: '가용'`) | **가용** (`status: '가용'`) |
| 3009함 | **온라인** (동기화 중) | **검문 중** | **출동중** | **출동중** |
| 1503함 | **미배포** | - | - | **정비중** |
### 문제점
- 5001함이 ShipAgent에서는 오프라인이지만, Dashboard/PatrolRoute/FleetOptimization에서는 가용으로 표시됨 -- **직접적 모순**
- 3009함의 상태가 "온라인", "검문 중", "출동중"으로 파일마다 다름
- 실제 운영 시 혼란을 초래할 수 있는 시나리오 불일치
---
## 7. 현재 상태: 통합 완료
아래 분석에서 식별한 모든 중복/불일치 문제를 해소하기 위해, 7개 공유 Mock 모듈 + 7개 Zustand 스토어 체계로 통합이 **완료**되었다.
### 7.1 완료된 아키텍처: mock -> store -> page
```
┌─────────────────────────────────────────────────────────────────────────┐
│ src/data/mock/ (7개 공유 모듈) │
├───────────┬──────────┬──────────┬────────┬───────────┬────────┬────────┤
│ vessels │ patrols │ events │ kpi │ transfers │ gear │enforce-│
│ .ts │ .ts │ .ts │ .ts │ .ts │ .ts │ment.ts │
└─────┬─────┴─────┬────┴─────┬────┴───┬────┴─────┬────┴───┬────┴───┬────┘
│ │ │ │ │ │ │
▼ ▼ ▼ ▼ ▼ ▼ ▼
┌─────────────────────────────────────────────────────────────────────────┐
│ src/stores/ (7개 Zustand 스토어 + settingsStore) │
├───────────┬──────────┬──────────┬────────┬───────────┬────────┬────────┤
│ vessel │ patrol │ event │ kpi │ transfer │ gear │enforce-│
│ Store │ Store │ Store │ Store │ Store │ Store │mentStr │
└─────┬─────┴─────┬────┴─────┬────┴───┬────┴─────┬────┴───┬────┴───┬────┘
│ │ │ │ │ │ │
▼ ▼ ▼ ▼ ▼ ▼ ▼
┌─────────────────────────────────────────────────────────────────────────┐
│ src/features/*/ (페이지 컴포넌트) │
│ store.load() 호출 -> store에서 데이터 구독 -> 뷰 변환은 페이지 책임 │
└─────────────────────────────────────────────────────────────────────────┘
```
### 7.2 스토어별 소비 현황 (16개 페이지가 스토어 사용)
| 스토어 | 소비 페이지 |
|---|---|
| `useVesselStore` | Dashboard, LiveMapView, DarkVesselDetection, VesselDetail |
| `usePatrolStore` | Dashboard, PatrolRoute, FleetOptimization |
| `useEventStore` | Dashboard, MonitoringDashboard, LiveMapView, EventList, MobileService, AIAlert |
| `useKpiStore` | Dashboard, MonitoringDashboard, Statistics |
| `useTransferStore` | TransferDetection, ChinaFishing |
| `useGearStore` | GearDetection |
| `useEnforcementStore` | EnforcementPlan, EnforcementHistory |
### 7.3 페이지 전용 인라인 데이터 (미통합)
아래 페이지들은 도메인 특성상 공유 mock에 포함하지 않고 페이지 전용 인라인 데이터를 유지한다.
| 페이지 | 인라인 데이터 | 사유 |
|---|---|---|
| ChinaFishing | `COUNTERS_ROW1/2`, `VESSEL_LIST`, `MONTHLY_DATA`, `VTS_ITEMS` | 중국어선 전용 센서 카운터/통계 (다른 페이지에서 미사용) |
| VesselDetail | `VESSELS: VesselTrack[]` | 항적 데이터 구조가 `VesselData`와 다름 (주석으로 명시) |
| MLOpsPage | 실험/배포 데이터 | MLOps 전용 도메인 데이터 |
| MapControl | 훈련구역 데이터 | 해상사격 훈련구역 전용 |
| DataHub | 수신현황 데이터 | 데이터 허브 전용 모니터링 |
| AIModelManagement | 모델/규칙 데이터 | AI 모델 관리 전용 |
| AIAssistant | `SAMPLE_CONVERSATIONS` | 챗봇 샘플 대화 |
| LoginPage | `DEMO_ACCOUNTS` | 데모 인증 정보 |
| 기타 (AdminPanel, SystemConfig 등) | 각 페이지 전용 설정/관리 데이터 | 관리 도메인 특화 |
### 7.4 설계 원칙 (구현 완료)
1. **위험도 0~100 통일**: 모든 선박의 위험도를 0~100 정수로 통일. 레벨 문자열은 유틸 함수로 변환.
2. **단일 원천(Single Source of Truth)**: 각 데이터는 하나의 mock 모듈에서만 정의하고, 스토어를 통해 접근.
3. **Lazy Loading**: 스토어의 `load()` 메서드가 최초 호출 시 `import()`로 mock 데이터를 동적 로딩 (loaded 플래그로 중복 방지).
4. **뷰 변환은 페이지 책임**: mock 모듈/스토어는 원본 데이터만 제공하고, 화면별 가공(필터, 정렬, 포맷)은 각 페이지에서 수행.
### 7.5 Mock 모듈 상세 (참고용)
참고: 초기 분석에서 계획했던 `areas.ts`는 최종 구현 시 `enforcement.ts`(단속 이력 데이터)로 대체되었다.
해역/구역 데이터는 RiskMap, MapControl 등 각 페이지에서 전용 데이터로 관리한다.
| # | 모듈 파일 | 스토어 | 내용 |
|---|---|---|---|
| 1 | `data/mock/vessels.ts` | `vesselStore` | 중국어선 + 한국어선 + 미상선박 마스터 (`MOCK_VESSELS`, `MOCK_SUSPECTS`) |
| 2 | `data/mock/patrols.ts` | `patrolStore` | 경비함정 마스터 + 경로/시나리오/커버리지 |
| 3 | `data/mock/events.ts` | `eventStore` | 이벤트 타임라인 + 알림 데이터 |
| 4 | `data/mock/kpi.ts` | `kpiStore` | KPI 수치 + 월별 추이 |
| 5 | `data/mock/transfers.ts` | `transferStore` | 환적 데이터 (TR-001~003) |
| 6 | `data/mock/gear.ts` | `gearStore` | 어구 데이터 (불법어구 목록) |
| 7 | `data/mock/enforcement.ts` | `enforcementStore` | 단속 이력 + 단속 계획 데이터 |
---
## 8. 작업 완료 요약
| 모듈 | 상태 | 스토어 소비 페이지 수 |
|---|---|---|
| `vessels.ts` | **완료** | 4개 (useVesselStore) |
| `events.ts` | **완료** | 6개 (useEventStore) |
| `patrols.ts` | **완료** | 3개 (usePatrolStore) |
| `kpi.ts` | **완료** | 3개 (useKpiStore) |
| `transfers.ts` | **완료** | 2개 (useTransferStore) |
| `gear.ts` | **완료** | 1개 (useGearStore) |
| `enforcement.ts` | **완료** | 2개 (useEnforcementStore) |
### 실제 작업 결과
- Mock 모듈 생성: 7개 파일 (`src/data/mock/`)
- Zustand 스토어 생성: 7개 + 1개 설정용 (`src/stores/`)
- 기존 페이지 리팩토링: 16개 페이지에서 스토어 소비로 전환
- 나머지 15개 페이지: 도메인 특화 인라인 데이터 유지 (공유 필요성 없음)
---
## 9. 결론
위 1~6절에서 분석한 6개의 심각한 중복/불일치 문제(위험도 스케일, 함정 상태 모순, KPI 중복, 이벤트 불일치, 환적 100% 중복, 선박 교차참조)는 **7개 공유 mock 모듈 + 7개 Zustand 스토어** 도입으로 모두 해소되었다.
달성한 효과:
- **데이터 일관성**: Single Source of Truth로 불일치 원천 차단
- **유지보수성**: 데이터 변경 시 mock 모듈 1곳만 수정
- **확장성**: 신규 페이지 추가 시 기존 store import로 즉시 사용
- **코드 품질**: 중복 인라인 데이터 제거, 16개 페이지가 스토어 기반으로 전환
- **성능**: Zustand lazy loading으로 최초 접근 시에만 mock 데이터 로딩
1~6절의 분석 내용은 통합 전 문제 식별 기록으로 보존한다.

194
docs/next-refactoring.md Normal file
파일 보기

@ -0,0 +1,194 @@
# KCG AI Monitoring - 다음 단계 리팩토링 TODO
> 프론트엔드 UI 스캐폴딩 + 기반 인프라(상태관리, 지도 GPU, mock 데이터, CVA) 완료 상태. 백엔드 연동 및 운영 품질 확보를 위해 남은 항목을 순차적으로 진행한다.
---
## 1. ✅ 상태관리 도입 (Zustand 5.0) — COMPLETED
`zustand` 5.0.12 설치, `src/stores/`에 8개 독립 스토어 구현 완료.
- `vesselStore` — 선박 목록, 선택, 필터
- `patrolStore` — 순찰 경로/함정
- `eventStore` — 탐지/경보 이벤트
- `kpiStore` — KPI 메트릭, 추세
- `transferStore` — 전재(환적)
- `gearStore` — 어구 탐지
- `enforcementStore` — 단속 이력
- `settingsStore` — theme/language + localStorage 동기화, 지도 타일 자동 전환
> `AuthContext`는 유지 (인증은 Context API가 적합, 마이그레이션 불필요로 결정)
---
## 2. API 서비스 계층 (Axios 1.14) — 구조 완성, 실제 연동 대기
### 현재 상태
- `src/services/`에 7개 서비스 모듈 구현 (api, vessel, event, patrol, kpi, ws, index)
- `api.ts`: fetch 래퍼 (`apiGet`, `apiPost`) — 향후 Axios 교체 예정
- 각 서비스가 `data/mock/` 모듈에서 mock 데이터 반환 (실제 HTTP 호출 0건)
- `ws.ts`: STOMP WebSocket 스텁 존재, 미구현
### 남은 작업
- [ ] `axios` 1.14 설치 → `api.ts`의 fetch 래퍼를 Axios 인스턴스로 교체
- [ ] Axios 인터셉터:
- Request: Authorization 헤더 자동 주입
- Response: 401 → 로그인 리다이렉트, 500 → 에러 토스트
- [ ] `@tanstack/react-query` 5.x 설치 → TanStack Query Provider 추가
- [ ] 각 서비스의 mock 반환을 실제 API 호출로 교체
- [ ] 로딩 스켈레톤, 에러 바운더리 공통 컴포넌트
---
## 3. 실시간 인프라 (STOMP.js + SockJS) — 스텁 구조만 존재
### 현재 상태
- `services/ws.ts``connectWs` 스텁 함수 존재 (인터페이스 정의 완료)
- STOMP.js, SockJS 미설치 — 실제 WebSocket 연결 없음
- `useStoreLayerSync` hook으로 store→지도 실시간 파이프라인 준비 완료
### 남은 작업
- [ ] `@stomp/stompjs` + `sockjs-client` 설치
- [ ] `ws.ts` 스텁을 실제 STOMP 클라이언트로 구현
- [ ] 구독 채널 설계:
- `/topic/ais-positions` — 실시간 AIS 위치
- `/topic/alerts` — 경보/이벤트
- `/topic/detections` — 탐지 결과
- `/user/queue/notifications` — 개인 알림
- [ ] 재연결 로직 (지수 백오프)
- [ ] store → `useStoreLayerSync` → 지도 마커 실시간 업데이트 연결
- [ ] `eventStore`와 연동하여 알림 배너/뱃지 카운트 업데이트
---
## 4. ✅ 고급 지도 레이어 (deck.gl 9.2) — COMPLETED
`deck.gl` 9.2.11 + `@deck.gl/mapbox` 설치, MapLibre + deck.gl 인터리브 아키텍처 구현 완료.
- **BaseMap**: `forwardRef` + `memo`, `MapboxOverlay``useImperativeHandle`로 외부 노출
- **useMapLayers**: RAF 배치 레이어 업데이트, React 리렌더 0회
- **useStoreLayerSync**: Zustand store.subscribe → RAF → overlay.setProps (React 우회)
- **STATIC_LAYERS**: EEZ + NLL PathLayer 싱글턴 (GPU 1회 업로드)
- **createMarkerLayer**: ScatterplotLayer + transitions 보간 + DataFilterExtension
- **createRadiusLayer**: 반경 원 표시용 ScatterplotLayer
- 레거시 GeoJSON 레이어(`boundaries.ts`)는 하위 호환으로 유지
> 성능 목표 40만척+ GPU 렌더링 달성. TripsLayer/HexagonLayer/IconLayer는 실데이터 확보 후 추가 예정.
---
## 5. ✅ 더미 데이터 통합 — COMPLETED
`src/data/mock/`에 7개 공유 mock 모듈 구현 완료. TypeScript 인터페이스 정의 포함.
```
data/mock/
├── vessels.ts # VesselData — 선박 목록 (한국, 중국, 경비함)
├── events.ts # EventRecord, AlertRecord — 탐지/단속 이벤트
├── transfers.ts # 전재(환적) 데이터
├── patrols.ts # PatrolShip — 순찰 경로/함정
├── gear.ts # 어구 탐지 데이터
├── kpi.ts # KpiMetric, MonthlyTrend, ViolationType
└── enforcement.ts # 단속 이력 데이터
```
- `services/` 계층이 mock 모듈을 import하여 반환 → 향후 API 교체 시 서비스만 수정
- 인터페이스가 API 응답 타입 계약 역할 수행
---
## 6. i18n 실적용 — 구조 완성, 내부 텍스트 미적용
### 현재 상태
- 10 네임스페이스 리소스 완비: common, dashboard, detection, patrol, enforcement, statistics, ai, fieldOps, admin, auth
- ko/en 각 10파일 (총 20 JSON)
- `settingsStore.toggleLanguage()` + `localStorage` 동기화 구현 완료
- **적용 완료**: MainLayout 사이드바 메뉴명, 24개 페이지 제목, LoginPage
- **미적용**: 각 페이지 내부 텍스트 (카드 레이블, 테이블 헤더, 상태 텍스트 등) — 대부분 한국어 하드코딩 잔존
### 남은 작업
- [ ] 각 feature 페이지 내부 텍스트를 `useTranslation('namespace')` + `t()` 로 교체
- [ ] 날짜/숫자 포맷 로컬라이즈 (`Intl.DateTimeFormat`, `Intl.NumberFormat`)
- [ ] 누락 키 감지 자동화 (i18next missing key handler 또는 lint 규칙)
---
## 7. ✅ Tailwind 공통 스타일 모듈화 (CVA) — COMPLETED
`class-variance-authority` 0.7.1 설치, `src/lib/theme/variants.ts`에 3개 CVA 변형 구현 완료.
- **cardVariants**: default / elevated / inner / transparent — CSS 변수 기반 테마 반응
- **badgeVariants**: 8 intent (critical~cyan) x 4 size (xs~lg) — 150회+ 반복 패턴 통합
- **statusDotVariants**: 4 status (online/warning/danger/offline) x 3 size (sm/md/lg)
- `shared/components/ui/card.tsx`, `badge.tsx`에 CVA 적용 완료
- CSS 변수(`surface-raised`, `surface-overlay`, `border`) 참조로 Dark/Light 자동 반응
---
## 8. 코드 스플리팅 — 미착수
### 현재 상태
- **단일 번들 ~3.2MB** (모든 feature + deck.gl + MapLibre + ECharts 포함)
- `React.lazy` 미적용, 모든 31개 페이지가 동기 import
- 초기 로딩 시 사용하지 않는 페이지 코드까지 전부 다운로드
### 필요한 이유
- 초기 로딩 성능 개선 (FCP, LCP)
- 현장 모바일 환경 (LTE/3G)에서의 사용성 확보
- 번들 캐싱 효율 향상 (변경된 chunk만 재다운로드)
### 구현 계획
- [ ] `React.lazy` + `Suspense`로 feature 단위 동적 임포트:
```typescript
const Dashboard = lazy(() => import('@features/dashboard/Dashboard'));
const RiskMap = lazy(() => import('@features/risk-assessment/RiskMap'));
```
- [ ] `App.tsx` 라우트 전체를 lazy 컴포넌트로 교체
- [ ] 로딩 폴백 컴포넌트 (스켈레톤 또는 스피너) 공통화
- [ ] Vite `build.rollupOptions.output.manualChunks` 설정:
```typescript
manualChunks: {
'vendor-react': ['react', 'react-dom', 'react-router-dom'],
'vendor-map': ['maplibre-gl', 'deck.gl', '@deck.gl/mapbox'],
'vendor-chart': ['echarts'],
}
```
- [ ] 목표: 초기 번들 < 300KB (gzip), feature chunk < 100KB
- [ ] `vite-plugin-compression`으로 gzip/brotli 사전 압축 검토
---
## 9. Light 테마 하드코딩 정리
### 현재 상태
- Dark/Light 테마 전환 구조 완성 (CSS 변수 + `.light` 클래스 + settingsStore)
- 시맨틱 변수(`surface-raised`, `text-heading` 등) + CVA 변형은 정상 작동
- **문제**: 일부 alert/status 색상이 Tailwind 하드코딩 (`bg-red-500/20`, `text-red-400`, `border-red-500/30` 등)
- Dark에서는 자연스러우나, Light 전환 시 대비/가독성 부족
### 구현 계획
- [ ] 하드코딩 alert 색상을 CSS 변수 또는 CVA intent로 교체
- [ ] `badgeVariants`의 intent 색상도 CSS 변수 기반으로 전환 검토
- [ ] Light 모드 전용 대비 테스트 (WCAG AA 기준)
---
## 우선순위 및 의존관계
```
✅ 완료 ─────────────────────────────────────
[1. Zustand] [4. deck.gl] [5. mock 데이터] [7. CVA]
진행 중 / 남은 작업 ──────────────────────────
[6. i18n 내부 텍스트] ──┐
├──▶ [2. API 실제 연동] ──▶ [3. 실시간 STOMP]
[9. Light 테마 정리] ───┘
[8. 코드 스플리팅] ← 독립 작업, 언제든 착수 가능 (~3.2MB → 목표 <300KB)
```
### 권장 진행 순서
1. **Phase A (품질)**: i18n 내부 텍스트 적용 (6) + Light 테마 하드코딩 정리 (9) + 코드 스플리팅 (8)
2. **Phase B (연동)**: Axios 설치 + API 실제 연동 (2)
3. **Phase C (실시간)**: STOMP.js + SockJS 실시간 인프라 (3)

436
docs/page-workflow.md Normal file
파일 보기

@ -0,0 +1,436 @@
# 페이지 역할표 및 업무 파이프라인
> 최초 작성일: 2026-04-06
> 마지막 업데이트: 2026-04-06
> 대상: `kcg-ai-monitoring` 프론트엔드 31개 페이지
---
## 0. 공통 아키텍처
### 디렉토리 구조
모든 페이지는 `src/features/` 아래 도메인별 디렉토리에 배치되어 있다.
```
src/features/
admin/ AccessControl, AdminPanel, DataHub, NoticeManagement, SystemConfig
ai-operations/ AIAssistant, AIModelManagement, MLOpsPage
auth/ LoginPage
dashboard/ Dashboard
detection/ ChinaFishing, DarkVesselDetection, GearDetection, GearIdentification
enforcement/ EnforcementHistory, EventList
field-ops/ AIAlert, MobileService, ShipAgent
monitoring/ MonitoringDashboard
patrol/ FleetOptimization, PatrolRoute
risk-assessment/ EnforcementPlan, RiskMap
statistics/ ExternalService, ReportManagement, Statistics
surveillance/ LiveMapView, MapControl
vessel/ TransferDetection, VesselDetail
```
### 데이터 흐름
모든 공유 데이터는 **mock -> store -> page** 패턴으로 흐른다.
```
src/data/mock/*.ts --> src/stores/*Store.ts --> src/features/*/*.tsx
(7개 공유 모듈) (7개 Zustand 스토어) (16개 페이지가 스토어 소비)
```
- 스토어는 `load()` 호출 시 `import()`로 mock 데이터를 lazy loading
- 도메인 특화 데이터는 페이지 내 인라인으로 유지 (MLOps, MapControl, DataHub 등)
- 상세 매핑은 `docs/data-sharing-analysis.md` 참조
### 지도 렌더링
지도가 필요한 11개 페이지는 공통 `src/lib/map/` 인프라를 사용한다.
- **deck.gl** 기반 렌더링 (`BaseMap.tsx`)
- **`useMapLayers`** 훅: 페이지별 동적 레이어 구성
- **`STATIC_LAYERS`**: EEZ/KDLZ 등 정적 레이어를 상수로 분리하여 zero rerender 보장
- 사용 페이지: Dashboard, LiveMapView, MapControl, EnforcementPlan, PatrolRoute, FleetOptimization, GearDetection, DarkVesselDetection, RiskMap, VesselDetail, MobileService
### 다국어 (i18n)
- `react-i18next` 기반, 24개 페이지 + MainLayout + LoginPage에 i18n 적용
- 지원 언어: 한국어 (ko), 영어 (en)
- 페이지 타이틀, 주요 UI 라벨이 번역 키로 관리됨
### 테마
- `settingsStore`에서 dark/light 테마 전환 지원
- 기본값: dark (해양 감시 시스템 특성상)
- `localStorage`에 선택 유지, CSS 클래스 토글 방식
---
## 1. 31개 페이지 역할표
### 1.1 인증/관리 (4개)
| SFR | 화면명 | 경로 | 사용자 | 핵심 기능 | 입력 | 출력/액션 | 업스트림 | 다운스트림 |
|---|---|---|---|---|---|---|---|---|
| SFR-01 | LoginPage | `/login` | 전체 | SSO/GPKI/비밀번호 인증, 5회 실패 잠금 | ID/PW, 인증 방식 선택 | 세션 발급, 역할 부여 | - | 모든 페이지 (인증 게이트) |
| SFR-01 | AccessControl | `/access-control` | 관리자 | RBAC 권한 관리, 감사 로그 | 역할/사용자/권한 설정 | 권한 변경, 감사 기록 | LoginPage | 전체 시스템 접근 제어 |
| SFR-02 | SystemConfig | `/system-config` | 관리자 | 공통코드 기준정보 관리 (해역52/어종578/어업59/선박186) | 코드 검색/필터 | 코드 조회, 설정 변경 | AccessControl | 탐지/분석 엔진 기준데이터 |
| SFR-02 | NoticeManagement | `/notices` | 관리자 | 시스템 공지(배너/팝업/토스트), 역할별 대상 설정 | 공지 작성, 기간/대상 설정 | 배너/팝업 노출 | AccessControl | 모든 페이지 (NotificationBanner) |
### 1.2 데이터 수집/연계 (1개)
| SFR | 화면명 | 경로 | 사용자 | 핵심 기능 | 입력 | 출력/액션 | 업스트림 | 다운스트림 |
|---|---|---|---|---|---|---|---|---|
| SFR-03 | DataHub | `/data-hub` | 관리자 | 통합데이터 허브 — 선박신호 수신 현황 히트맵, 연계 채널 모니터링 | 수신 소스 선택 | 수신률 조회, 연계 상태 확인 | 외부 센서 (VTS, AIS, V-PASS 등) | 탐지 파이프라인 전체 |
### 1.3 AI 모델/운영 (3개)
| SFR | 화면명 | 경로 | 사용자 | 핵심 기능 | 입력 | 출력/액션 | 업스트림 | 다운스트림 |
|---|---|---|---|---|---|---|---|---|
| SFR-04 | AIModelManagement | `/ai-model` | 분석관 | 모델 레지스트리, 탐지 규칙, 피처 엔지니어링, 학습 파이프라인, 7대 탐지엔진 | 모델 버전/규칙/피처 설정 | 모델 배포, 성능 리포트 | DataHub (학습 데이터) | DarkVessel, GearDetection, TransferDetection 등 탐지 엔진 |
| SFR-18/19 | MLOpsPage | `/mlops` | 분석관/관리자 | MLOps/LLMOps 운영 대시보드 (실험, 배포, API Playground, LLM 테스트) | 실험 템플릿, HPS 설정 | 실험 결과, 모델 배포 | AIModelManagement | AIAssistant, 탐지 엔진 |
| SFR-20 | AIAssistant | `/ai-assistant` | 상황실/분석관 | 자연어 Q&A 의사결정 지원 (법령 조회, 대응 절차 안내) | 자연어 질의 | 답변 + 법령 참조 | MLOpsPage (LLM 모델) | 작전 의사결정 |
### 1.4 탐지 (4개)
| SFR | 화면명 | 경로 | 사용자 | 핵심 기능 | 입력 | 출력/액션 | 업스트림 | 다운스트림 |
|---|---|---|---|---|---|---|---|---|
| SFR-09 | DarkVesselDetection | `/dark-vessel` | 분석관 | AIS 조작/위장/Dark Vessel 패턴 탐지 (6가지 패턴), 지도+테이블 | AIS 데이터 스트림 | 의심 선박 목록, 위험도, 라벨 분류 | DataHub (AIS/레이더) | RiskMap, LiveMapView, EventList |
| SFR-10 | GearDetection | `/gear-detection` | 분석관 | 불법 어망/어구 탐지 및 관리, 허가 상태 판정 | 어구 센서/영상 | 어구 목록, 불법 판정 결과 | DataHub (센서) | RiskMap, EnforcementPlan |
| - | GearIdentification | `features/detection/` | 분석관 | 어구 국적 판별 (중국/한국/불확실), GB/T 5147 기준 | 어구 물리적 특성 입력 | 판별 결과 (국적, 신뢰도, 경보등급) | GearDetection | EnforcementHistory |
| - | ChinaFishing | `/china-fishing` | 분석관/상황실 | 중국어선 통합 감시 (센서 카운터, 특이운항, 월별 통계, 환적 탐지, VTS 연계) | 센서 데이터 융합 | 감시 현황, 환적 의심 목록 | DataHub, DarkVessel | RiskMap, EnforcementPlan |
### 1.5 환적 탐지 (1개)
| SFR | 화면명 | 경로 | 사용자 | 핵심 기능 | 입력 | 출력/액션 | 업스트림 | 다운스트림 |
|---|---|---|---|---|---|---|---|---|
| - | TransferDetection | `features/vessel/` | 분석관 | 선박 간 근접 접촉 및 환적 의심 행위 분석 (거리/시간/속도 기준) | AIS 궤적 분석 | 환적 이벤트 목록, 의심도 점수 | DataHub, DarkVessel | EventList, EnforcementPlan |
### 1.6 위험도 평가/계획 (2개)
| SFR | 화면명 | 경로 | 사용자 | 핵심 기능 | 입력 | 출력/액션 | 업스트림 | 다운스트림 |
|---|---|---|---|---|---|---|---|---|
| SFR-05 | RiskMap | `/risk-map` | 분석관/상황실 | 격자 기반 불법조업 위험도 지도 + MTIS 해양사고 통계 연계 | 탐지 결과, 사고 통계 | 히트맵, 해역별 위험도, 사고 통계 차트 | DarkVessel, GearDetection, ChinaFishing | EnforcementPlan, PatrolRoute |
| SFR-06 | EnforcementPlan | `/enforcement-plan` | 상황실 | 단속 계획 수립, 경보 연계, 우선지역 예보 | 위험도 데이터, 가용 함정 | 단속 계획 테이블, 지도 표시 | RiskMap | PatrolRoute, FleetOptimization |
### 1.7 순찰/함대 (2개)
| SFR | 화면명 | 경로 | 사용자 | 핵심 기능 | 입력 | 출력/액션 | 업스트림 | 다운스트림 |
|---|---|---|---|---|---|---|---|---|
| SFR-07 | PatrolRoute | `/patrol-route` | 상황실 | AI 단일 함정 순찰 경로 추천 (웨이포인트, 거리/시간/연료 산출) | 함정 선택, 구역 조건 | 추천 경로, 웨이포인트 목록 | EnforcementPlan, RiskMap | 함정 출동 (ShipAgent) |
| SFR-08 | FleetOptimization | `/fleet-optimization` | 상황실 | 다함정 협력형 경로 최적화 (커버리지 시뮬레이션, 승인 워크플로) | 함대 목록, 구역 조건 | 최적화 결과, 커버리지 비교 | EnforcementPlan, PatrolRoute | 함정 출동 (ShipAgent) |
### 1.8 감시/지도 (2개)
| SFR | 화면명 | 경로 | 사용자 | 핵심 기능 | 입력 | 출력/액션 | 업스트림 | 다운스트림 |
|---|---|---|---|---|---|---|---|---|
| - | LiveMapView | `/events` | 상황실 | 실시간 해역 감시 지도 (AIS 선박 + 이벤트 경보 + 아군 함정) | 실시간 AIS/이벤트 스트림 | 지도 마커, 이벤트 카드, 위험도 바 | 탐지 엔진 전체 | EventList, AIAlert |
| - | MapControl | `/map-control` | 상황실/관리자 | 해역 통제 관리 (해상사격 훈련구역도 No.462, 군/해경 구역) | 구역 데이터 | 훈련구역 지도, 상태 테이블 | 국립해양조사원 데이터 | LiveMapView (레이어) |
### 1.9 대시보드/모니터링 (2개)
| SFR | 화면명 | 경로 | 사용자 | 핵심 기능 | 입력 | 출력/액션 | 업스트림 | 다운스트림 |
|---|---|---|---|---|---|---|---|---|
| - | Dashboard | `/dashboard` | 전체 | 종합 상황판 (KPI, 타임라인, 위험선박 TOP8, 함정 현황, 해역 위험도, 시간대별 탐지 추이) | 전 시스템 데이터 집계 | 한눈에 보는 현황 | 탐지/순찰/이벤트 전체 | 각 상세 페이지로 드릴다운 |
| SFR-12 | MonitoringDashboard | `/monitoring` | 상황실 | 모니터링 및 경보 현황판 (KPI, 24시간 추이, 탐지 유형 분포, 실시간 이벤트) | 경보/탐지 데이터 | 경보 현황 대시보드 | 탐지 엔진, EventList | AIAlert, EnforcementPlan |
### 1.10 이벤트/이력 (2개)
| SFR | 화면명 | 경로 | 사용자 | 핵심 기능 | 입력 | 출력/액션 | 업스트림 | 다운스트림 |
|---|---|---|---|---|---|---|---|---|
| - | EventList | `/event-list` | 상황실/분석관 | 이벤트 전체 목록 (검색/정렬/페이징/엑셀/출력), 15건+ 이벤트 | 필터 조건 | 이벤트 테이블, 엑셀 내보내기 | 탐지 엔진, LiveMapView | EnforcementHistory, ReportManagement |
| SFR-11 | EnforcementHistory | `/enforcement-history` | 분석관 | 단속/탐지 이력 관리 (AI 매칭 검증 포함) | 검색 조건 | 이력 테이블, AI 일치 여부 | EventList, 현장 단속 | ReportManagement, Statistics |
### 1.11 현장 대응 (3개)
| SFR | 화면명 | 경로 | 사용자 | 핵심 기능 | 입력 | 출력/액션 | 업스트림 | 다운스트림 |
|---|---|---|---|---|---|---|---|---|
| SFR-15 | MobileService | `/mobile-service` | 현장 단속요원 | 모바일 앱 프리뷰 (위험도/의심선박/경로추천/경보, 푸시 설정) | 모바일 위치, 푸시 설정 | 경보 수신, 지도 조회 | AIAlert, LiveMapView | 현장 단속 수행 |
| SFR-16 | ShipAgent | `/ship-agent` | 현장 단속요원 | 함정용 Agent 관리 (배포/동기화 상태, 버전 관리) | 함정 Agent 설치 | Agent 상태 조회, 동기화 | PatrolRoute, FleetOptimization | 현장 단속 수행 |
| SFR-17 | AIAlert | `/ai-alert` | 상황실/현장 | AI 탐지 알림 자동 발송 (함정/관제요원 대상, 탐지시각/좌표/유형/신뢰도 포함) | 탐지 이벤트 트리거 | 알림 발송, 수신 확인 | MonitoringDashboard, EventList | MobileService, ShipAgent |
### 1.12 통계/외부연계/보고 (3개)
| SFR | 화면명 | 경로 | 사용자 | 핵심 기능 | 입력 | 출력/액션 | 업스트림 | 다운스트림 |
|---|---|---|---|---|---|---|---|---|
| SFR-13 | Statistics | `/statistics` | 상황실/분석관 | 통계/지표/성과 분석 (월별 추이, 위반유형, KPI 달성률) | 기간/유형 필터 | 차트, KPI 테이블, 보고서 | EnforcementHistory, EventList | 외부 보고, 전략 수립 |
| SFR-14 | ExternalService | `/external-service` | 관리자/외부 | 외부 서비스 제공 (해수부/수협/기상청 API/파일 연계, 비식별/익명화 정책) | 서비스 설정 | API 호출 수, 연계 상태 | Statistics, 탐지 결과 | 외부기관 |
| - | ReportManagement | `/reports` | 분석관/상황실 | 증거 관리 및 보고서 생성 (사건별 자동 패키징) | 사건 선택, 증거 파일 업로드 | 보고서 PDF, 증거 패키지 | EnforcementHistory, EventList | 검찰/외부기관 |
### 1.13 선박 상세 (1개)
| SFR | 화면명 | 경로 | 사용자 | 핵심 기능 | 입력 | 출력/액션 | 업스트림 | 다운스트림 |
|---|---|---|---|---|---|---|---|---|
| - | VesselDetail | `/vessel/:id` | 분석관/상황실 | 선박 상세 정보 (AIS 데이터, 항적, 입항 이력, 선원 정보, 비허가 선박 목록) | 선박 ID/MMSI | 상세 프로필, 지도 항적 | LiveMapView, DarkVessel, EventList | EnforcementPlan, ReportManagement |
### 1.14 시스템 관리 (1개)
| SFR | 화면명 | 경로 | 사용자 | 핵심 기능 | 입력 | 출력/액션 | 업스트림 | 다운스트림 |
|---|---|---|---|---|---|---|---|---|
| - | AdminPanel | `/admin` | 관리자 | 시스템 인프라 관리 (서버 상태, CPU/메모리/디스크 모니터링) | - | 서버 상태 대시보드 | - | 시스템 안정성 보장 |
---
## 2. 업무 파이프라인 (4개)
### 2.1 탐지 파이프라인
불법 조업을 탐지하고 실시간 감시하여 현장 작전까지 연결하는 핵심 파이프라인.
```
AIS/레이더/위성 신호
┌─────────┐
│ DataHub │ ← 통합데이터 허브 (VTS, AIS, V-PASS, E-Nav 수집)
└────┬────┘
┌──────────────────────────────────────────────┐
│ AI 탐지 엔진 (AIModelManagement 관리) │
│ │
│ DarkVesselDetection ─ AIS 조작/위장/소실 │
│ GearDetection ─────── 불법 어구 탐지 │
│ ChinaFishing ──────── 중국어선 통합 감시 │
│ TransferDetection ─── 환적 행위 탐지 │
│ GearIdentification ── 어구 국적 판별 │
└──────────────┬───────────────────────────────┘
┌──────────┐ ┌───────────────────┐
│ RiskMap │─────▶│ LiveMapView │ ← 실시간 지도 감시
└────┬─────┘ │ MonitoringDashboard│ ← 경보 현황판
│ └───────────────────┘
┌──────────────────┐
│ EnforcementPlan │ ← 단속 우선지역 예보
└────────┬─────────┘
┌──────────────┐ ┌───────────────────┐
│ PatrolRoute │─────▶│ FleetOptimization │ ← 다함정 최적화
└──────┬───────┘ └─────────┬─────────┘
│ │
▼ ▼
┌──────────┐
│ AIAlert │ ← 함정/관제 자동 알림 발송
└────┬─────┘
현장 작전 (MobileService, ShipAgent)
```
### 2.2 대응 파이프라인
AI 알림 수신 후 현장 단속, 이력 기록, 보고서 생성까지의 대응 프로세스.
```
┌──────────┐
│ AIAlert │ ← AI 탐지 알림 자동 발송
└────┬─────┘
┌──────────────────────────────────┐
│ 현장 대응 │
│ │
│ MobileService ── 모바일 경보 수신│
│ ShipAgent ────── 함정 Agent 연동 │
└──────────────┬───────────────────┘
현장 단속 수행
(정선/검문/나포/퇴거)
┌──────────────────────┐
│ EnforcementHistory │ ← 단속 이력 등록, AI 매칭 검증
└──────────┬───────────┘
┌──────────────────────┐
│ ReportManagement │ ← 증거 패키징, 보고서 생성
└──────────┬───────────┘
검찰/외부기관 (ExternalService 통해 연계)
```
### 2.3 분석 파이프라인
축적된 데이터를 분석하여 전략적 의사결정을 지원하는 파이프라인.
```
┌─────────────┐
│ Statistics │ ← 월별 추이, 위반유형, KPI 달성률
└──────┬──────┘
┌──────────┐
│ RiskMap │ ← 격자 위험도 + MTIS 해양사고 통계
└────┬─────┘
┌──────────────┐
│ VesselDetail │ ← 개별 선박 심층 분석 (항적, 이력)
└──────┬───────┘
┌──────────────┐
│ AIAssistant │ ← 자연어 Q&A (법령 조회, 대응 절차)
└──────┬───────┘
전략 수립 (순찰 패턴, 탐지 규칙 조정)
```
### 2.4 관리 파이프라인
시스템 접근 제어, 환경 설정, 데이터 관리, 인프라 모니터링 파이프라인.
```
┌────────────────┐
│ AccessControl │ ← RBAC 역할/권한 설정
└───────┬────────┘
┌────────────┐
│ LoginPage │ ← SSO/GPKI/비밀번호 인증
└──────┬─────┘
┌──────────────────────────────────────┐
│ 시스템 설정/관리 │
│ │
│ SystemConfig ──── 공통코드/환경설정 │
│ NoticeManagement ── 공지/배너/팝업 │
│ DataHub ────────── 데이터 수집 관리 │
│ AdminPanel ────── 서버/인프라 모니터 │
└──────────────────────────────────────┘
```
---
## 3. 사용자 역할별 페이지 접근 매트릭스
시스템에 정의된 5개 역할(LoginPage의 `DEMO_ACCOUNTS` 및 AccessControl의 `ROLES` 기반)에 대한 페이지 접근 권한.
### 3.1 역할 정의
| 역할 | 코드 | 설명 | 인원(시뮬) |
|---|---|---|---|
| 시스템 관리자 | `ADMIN` | 전체 시스템 관리 권한 | 3명 |
| 상황실 운영자 | `OPERATOR` | 상황판, 통계, 경보 운영 | 12명 |
| 분석 담당자 | `ANALYST` | AI 모델, 통계, 항적 분석 | 8명 |
| 현장 단속요원 | `FIELD` | 함정 Agent, 모바일 대응 | 45명 |
| 유관기관 열람자 | `VIEWER` | 공유 대시보드 열람 | 6명 |
### 3.2 접근 매트릭스
| 페이지 | ADMIN | OPERATOR | ANALYST | FIELD | VIEWER |
|---|---|---|---|---|---|
| **인증/관리** | | | | | |
| LoginPage | O | O | O | O | O |
| AccessControl | O | - | - | - | - |
| SystemConfig | O | - | - | - | - |
| NoticeManagement | O | - | - | - | - |
| AdminPanel | O | - | - | - | - |
| **데이터/AI** | | | | | |
| DataHub | O | - | - | - | - |
| AIModelManagement | O | - | O | - | - |
| MLOpsPage | O | - | O | - | - |
| AIAssistant | O | O | O | - | - |
| **탐지** | | | | | |
| DarkVesselDetection | O | - | O | - | - |
| GearDetection | O | - | O | - | - |
| ChinaFishing | O | O | O | - | - |
| TransferDetection | O | - | O | - | - |
| **위험도/계획** | | | | | |
| RiskMap | O | O | O | - | - |
| EnforcementPlan | O | O | - | - | - |
| **순찰** | | | | | |
| PatrolRoute | O | O | - | - | - |
| FleetOptimization | O | O | - | - | - |
| **감시/지도** | | | | | |
| LiveMapView | O | O | O | - | - |
| MapControl | O | O | - | - | - |
| **대시보드** | | | | | |
| Dashboard | O | O | O | O | O |
| MonitoringDashboard | O | O | - | - | - |
| **이벤트/이력** | | | | | |
| EventList | O | O | O | O | - |
| EnforcementHistory | O | - | O | - | - |
| **현장 대응** | | | | | |
| MobileService | O | - | - | O | - |
| ShipAgent | O | - | - | O | - |
| AIAlert | O | O | - | O | - |
| **통계/보고** | | | | | |
| Statistics | O | O | O | - | - |
| ExternalService | O | - | - | - | O |
| ReportManagement | O | O | O | - | - |
| **선박 상세** | | | | | |
| VesselDetail | O | O | O | - | - |
### 3.3 역할별 요약
| 역할 | 접근 가능 페이지 | 페이지 수 |
|---|---|---|
| **시스템 관리자** (ADMIN) | 전체 페이지 | 31 |
| **상황실 운영자** (OPERATOR) | Dashboard, MonitoringDashboard, LiveMapView, MapControl, EventList, EnforcementPlan, PatrolRoute, FleetOptimization, ChinaFishing, RiskMap, Statistics, ReportManagement, AIAssistant, AIAlert, VesselDetail | 15 |
| **분석 담당자** (ANALYST) | Dashboard, DarkVesselDetection, GearDetection, ChinaFishing, TransferDetection, RiskMap, LiveMapView, EventList, EnforcementHistory, Statistics, ReportManagement, VesselDetail, AIAssistant, AIModelManagement, MLOpsPage | 15 |
| **현장 단속요원** (FIELD) | Dashboard, MobileService, ShipAgent, AIAlert, EventList | 5 |
| **유관기관 열람자** (VIEWER) | Dashboard, ExternalService | 2 |
---
## 4. 페이지 간 데이터 흐름 요약
```
┌──────────────────┐
│ LoginPage │
│ (인증 게이트) │
└────────┬─────────┘
┌────────────────────┬┴──────────────────┐
▼ ▼ ▼
┌──────────────┐ ┌─────────────────┐ ┌─────────────┐
│ 관리 파이프라인│ │ 탐지 파이프라인 │ │ 현장 대응 │
│ │ │ │ │ │
│ AccessControl│ │ DataHub │ │ MobileSvc │
│ SystemConfig │ │ ↓ │ │ ShipAgent │
│ NoticeManage │ │ AI탐지엔진 │ │ AIAlert │
│ DataHub │ │ (DV/Gear/CN/TR)│ └──────┬──────┘
│ AdminPanel │ │ ↓ │ │
└──────────────┘ │ RiskMap │ │
│ ↓ │ ▼
│ EnforcementPlan │ ┌──────────────┐
│ ↓ │ │ 대응 파이프라인│
│ PatrolRoute │ │ │
│ FleetOptim │ │ Enforcement │
│ ↓ │ │ History │
│ LiveMapView │ │ ReportManage │
│ Monitoring │ │ ExternalSvc │
└────────┬────────┘ └──────────────┘
┌─────────────────┐
│ 분석 파이프라인 │
│ │
│ Statistics │
│ VesselDetail │
│ AIAssistant │
└─────────────────┘
```
---
## 5. 미할당 SFR 참고
현재 라우트에서 확인되는 SFR 번호 기준, 아래 기능은 기존 페이지에 통합되어 있다:
- **Dashboard**: SFR 번호 미부여, 종합 상황판 (기존 유지)
- **LiveMapView**: SFR 번호 미부여, 실시간 감시 지도
- **EventList**: SFR-02 공통 컴포넌트 적용 대상으로 분류
- **MapControl**: SFR 번호 미부여, 해역 통제 관리
- **VesselDetail**: SFR 번호 미부여, 선박 상세
- **ReportManagement**: SFR 번호 미부여, 증거/보고서 관리
- **AdminPanel**: SFR 번호 미부여, 인프라 관리
- **GearIdentification**: ChinaFishing 내 서브 컴포넌트

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다. Load Diff

파일 보기

@ -1,8 +1,7 @@
# SFR 요구사항별 화면 사용 가이드
> **문서 작성일:** 2026-04-06
> **최종 업데이트:** 2026-04-17 (2026-04-17 릴리즈 기준)
> **시스템 버전:** 운영 배포 (rocky-211 + redis-211)
> **시스템 버전:** v0.1.0 (프로토타입)
> **다국어:** 한국어/영어 전환 지원 (헤더 우측 EN/한국어 버튼)
> **테마:** 다크/라이트 전환 지원 (헤더 우측 해/달 아이콘 버튼)
@ -12,12 +11,7 @@
이 문서는 **KCG AI 모니터링 시스템**의 각 SFR(소프트웨어 기능 요구사항)이 화면에서 어떻게 구현되어 있는지를 **비개발자**(일반 사용자, 사업 PM, 산출물 작성자)가 이해할 수 있도록 정리한 가이드입니다.
### 시스템 현황 (2026-04-17 기준)
- **프런트엔드·백엔드·분석엔진(prediction) 운영 배포 완료** — 자체 JWT 인증 + 트리 기반 RBAC + 감사 로그 + 65+ API
- **AI 분석 엔진(prediction)**: 5분 주기로 AIS 원천 데이터(snpdb)를 분석하여 결과를 `kcgaidb` 에 자동 저장 (14 알고리즘 + DAR-03 G-01~G-06)
- **실시간 연동 화면**: Dashboard / MonitoringDashboard / ChinaFishing / DarkVesselDetection / GearDetection / EnforcementHistory / EventList / AIAlert / Statistics / AccessControl / PermissionsPanel / Audit 등 **15+ 화면이 실 API + prediction 결과를 실시간으로 표시**
- **Mock 화면**: DataHub / AIModelManagement / RiskMap / PatrolRoute / FleetOptimization / ExternalService / ShipAgent / MLOpsPage / AIAssistant 는 UI 완성, 백엔드/AI 엔진 연동은 단계적 추가 중
- **자세한 추적 매트릭스**: `docs/sfr-traceability.md` v3.0 참조
현재 시스템은 **프로토타입 단계(v0.1.0)**로, 모든 SFR의 UI가 완성되어 있으나 백엔드 서버 연동은 아직 이루어지지 않았습니다. 화면에 표시되는 데이터는 시연용 샘플 데이터입니다.
---
@ -61,18 +55,17 @@
- 역할별 데모 계정 선택 (ADMIN, OPERATOR, ANALYST, FIELD, VIEWER)
- 로그인 후 역할에 따른 메뉴 접근 제어
**구현 완료 (2026-04-17 기준):**
- ✅ 로그인 화면 UI + 자체 ID/PW 인증 + JWT 쿠키 세션 + 역할별 데모 계정 5종 실 로그인
- ✅ 비밀번호 정책(9자 이상 영문+숫자+특수) + 5회 실패 30분 잠금 + BCrypt 해시
- ✅ 트리 기반 RBAC (47 리소스 노드, Level 0 13개 + Level 1 32개, 5 operation) + Caffeine 10분 TTL
- ✅ 모든 로그인 시도 감사 로그 저장 및 조회 (로그인 이력 화면)
- ✅ 역할 기반 세션 유지 및 메뉴 접근 제어 (사이드바/라우트 가드)
**구현 완료:**
- ✅ 로그인 화면 UI 및 데모 계정 5종 로그인 기능
- ✅ 역할 기반 세션 유지 및 메뉴 접근 제어
**향후 구현 예정 (기업 환경 연동):**
- 🔲 SSO(해양경찰 통합인증) 연동
**향후 구현 예정:**
- 🔲 SSO(Single Sign-On) 연동
- 🔲 GPKI(정부 공인인증서) 인증 연동
- 🔲 공무원증 기반 인증 연동
- 🔲 인사 시스템 연동 역할 자동 부여
- 🔲 실제 사용자 DB 연동 및 비밀번호 암호화
**보완 필요:**
- ⚠️ 현재 데모 계정은 하드코딩되어 있으며, 운영 환경에서는 실제 인증 체계로 대체 필요
---
@ -90,17 +83,16 @@
- 역할별 접근 가능 메뉴 및 기능 권한 설정
- 사용자 목록 조회 및 역할 할당
**구현 완료 (2026-04-17 기준):**
- ✅ 트리 기반 RBAC 실 운영 — 47 리소스 노드 × 5 operation (READ/CREATE/UPDATE/DELETE/EXPORT) × 다중 역할 OR 합집합
- ✅ 역할별 권한 매트릭스 시각화 (셀 클릭 Y → N → 상속 사이클)
- ✅ 부모 READ 거부 시 자식 강제 거부, 상속 표시
- ✅ 역할 CRUD (admin:role-management) + 권한 매트릭스 갱신 (admin:permission-management)
- ✅ 사용자-역할 할당 다이얼로그 (admin:user-management)
- ✅ 모든 권한 변경은 `auth_audit_log` 에 자동 기록 (ROLE_CREATE/UPDATE/DELETE/PERM_UPDATE/USER_ROLE_ASSIGN)
**구현 완료:**
- ✅ RBAC 5역할 체계 UI 및 역할별 권한 매트릭스 표시
- ✅ 권한 설정 화면 레이아웃 및 인터랙션
**향후 구현 예정:**
- 🔲 권한 변경 이력 UI (auth_audit_log 조회는 현재 별도 화면)
- 🔲 역할 템플릿 복제 기능
- 🔲 실제 사용자 DB 연동을 통한 권한 CRUD
- 🔲 감사 로그(권한 변경 이력) 기록
**보완 필요:**
- ⚠️ 현재 화면의 데이터는 샘플이며 실제 저장/반영되지 않음
---
@ -377,18 +369,17 @@ AIS(선박자동식별장치) 신호를 의도적으로 끈 의심 선박(Dark V
- 의심 선박 상세 프로필 및 이동 궤적 조회
- 위험도 등급별 분류 표시
**구현 완료 (2026-04-17 기준):**
- ✅ **AI 분석 엔진(prediction) 5분 주기 실시간 탐지 결과 표시** — snpdb AIS 원천 데이터 기반
- ✅ Dark Vessel 11패턴 기반 0~100점 연속 점수 + 4단계 tier(CRITICAL≥70 / HIGH≥50 / WATCH≥30 / NONE)
- ✅ DarkDetailPanel — 선박 선택 시 ScoreBreakdown으로 P1~P11 각 패턴별 기여도 표시
- ✅ 지도 기반 실시간 위치 + tier별 색상 구분 (라이트/다크 모드 대응)
- ✅ 최근 1시간 / 중국 선박(MMSI 412*) 필터, MMSI/선박명/패턴 검색
- ✅ 특이운항 미니맵 (24h 궤적 + DARK/SPOOFING/TRANSSHIP/GEAR_VIOLATION/HIGH_RISK 구간 병합 하이라이트)
**구현 완료:**
- ✅ 의심 선박 7척 목록/지도 시각화
- ✅ 5가지 행동 패턴 분석 결과 UI
**향후 구현 예정:**
- 🔲 spoofing_score 산출 재설계 (중국 MID 412 선박 전원 0 수렴 이슈, BD-09 필터 + teleport 25kn 임계 재검토)
- 🔲 AI Dark Vessel 탐지 엔진 연동
- 🔲 실시간 AIS 데이터 분석 연동
- 🔲 SAR(위성영상) 기반 탐지 연동
- 🔲 과거 이력 차트 (현재는 최근 24h 중심)
**보완 필요:**
- ⚠️ 현재 탐지 결과는 샘플 데이터이며, AI 탐지 엔진 연동 후 실시간 탐지 결과로 교체 필요
---
@ -407,17 +398,16 @@ AIS(선박자동식별장치) 신호를 의도적으로 끈 의심 선박(Dark V
- 해역별 중국 어선 밀집도 분석
- 시계열 활동 패턴 분석
**구현 완료 (2026-04-17 기준):**
- ✅ **3개 탭(AI 감시 대시보드 / 환적접촉탐지 / 어구·어망 판별) 전부 실데이터 연동**`/api/analysis/*` 경유, MMSI prefix `412` 고정
- ✅ 중국 선박 전체 분석 결과 실시간 그리드 (최근 1h, 위험도순 상위 200건)
- ✅ 특이운항 판별 — riskScore ≥ 40 상위 목록 + 선박 클릭 시 24h 궤적 미니맵 + 판별 구간 패널
- ✅ 해역별 통항량 + 안전도 분석 (종합 위험/안전 지수) + 위험도 도넛
- ✅ 자동탐지 결과(어구 판별 탭) row 클릭 시 상단 입력 폼 자동 프리필
**구현 완료:**
- ✅ 중국 어선 분석 종합 대시보드 UI
- ✅ 지도 기반 활동 현황 시각화
**향후 구현 예정:**
- 🔲 관심영역 / VIIRS 위성영상 / 기상 예보 / VTS연계 (현재 "데모 데이터" 배지)
- 🔲 비허가 / 제재 / 관심 선박 탭 데이터 소스 연동 (현재 "준비중" 배지)
- 🔲 월별 집계 API 연동 (현재 통계 탭 "준비중")
- 🔲 AI 탐지 엔진 연동 (Dark Vessel + 어구 탐지 통합)
- 🔲 실시간 데이터 기반 분석 갱신
**보완 필요:**
- ⚠️ 현재 분석 데이터는 샘플이며, 실제 탐지 엔진 연동 필요
---
@ -436,17 +426,17 @@ AIS(선박자동식별장치) 신호를 의도적으로 끈 의심 선박(Dark V
- 탐지 결과 상세 정보 (위치, 크기, 어구 유형, 위험도)
- 탐지 이미지 확인
**구현 완료 (2026-04-17 기준):**
- ✅ **DAR-03 G-01~G-06 실시간 탐지 결과** — prediction 5분 주기 + 한중어업협정 906척 레지스트리(V029) 매칭 53%+
- ✅ G코드별 탐지: G-01(수역-어구 불일치) / G-02(금어기) / G-03(미등록 어구) / G-04(MMSI cycling) / G-05(고정어구 drift) / G-06(쌍끌이 — STRONG/PROBABLE/SUSPECT tier)
- ✅ 어구 그룹 지도 (ZONE_I~IV 폴리곤 + GeoJsonLayer + IconLayer) + 세부 필터 패널(해역/판정/위험도/모선 상태/허가/멤버 수) + localStorage 영속화
- ✅ GearDetailPanel — 후보 클릭 → 점수 근거(관측 7종 + 보정 3종) + 모선 확정/제외 버튼
- ✅ 24h 궤적 리플레이 (GearReplayController + TripsLayer, SPEED_FACTOR=2880, 24h→30s)
- ✅ 어구/어망 판별 화면 — 허가코드/어구물리특성/발견위치 입력 → 국적 판별(한/중/미확인) + 판별 근거·경고·AI 탐지 Rule·교차 검증 파이프라인
**구현 완료:**
- ✅ 어구 6건 탐지 결과 목록/지도 UI
- ✅ 어구 식별 결정트리 시각화
**향후 구현 예정:**
- 🔲 영상(CCTV/SAR) 기반 어구 자동 분류
- 🔲 한·중 어구 5종 구조 비교 이미지 라이브러리 확장
- 🔲 AI 어구 탐지 모델 연동 (영상 분석 기반)
- 🔲 실시간 CCTV/SAR 영상 분석 연동
- 🔲 탐지 결과 자동 분류 및 알림
**보완 필요:**
- ⚠️ 현재 탐지 결과는 샘플 데이터이며, AI 탐지 모델 연동 후 실제 탐지 결과로 교체 필요
---
@ -465,17 +455,17 @@ AIS(선박자동식별장치) 신호를 의도적으로 끈 의심 선박(Dark V
- 이력 상세 정보 조회 및 검색/필터
- 이력 데이터 엑셀 내보내기
**구현 완료 (2026-04-17 기준):**
- ✅ **실시간 이벤트 조회**`/api/events` 페이징/필터/확인(ACK)/상태 변경
- ✅ **단속 이력 CRUD**`/api/enforcement/records` (GET/POST/PATCH) + ENF-yyyyMMdd-NNNN UID 자동 발급
- ✅ 이벤트 발생 → 확인 → 단속 등록 → 오탐 처리 워크플로우 (액션 버튼 4종)
- ✅ 모든 쓰기 액션 `auth_audit_log` 자동 기록 (ENFORCEMENT_CREATE / ENFORCEMENT_UPDATE / ACK_EVENT / UPDATE_EVENT_STATUS)
- ✅ KPI 카운트 (CRITICAL/HIGH/MEDIUM/LOW) + 엑셀 내보내기 + 출력
- ✅ 단속 완료 시 prediction_events.status 자동 RESOLVED 갱신
**구현 완료:**
- ✅ 단속 이력 6건 목록/상세 UI
- ✅ AI 매칭 검증 결과 표시
**향후 구현 예정:**
- 🔲 증거 파일(사진/영상) 업로드 서버 연동
- 🔲 AI 매칭 검증 정량 지표 (탐지↔단속 confusion matrix)
- 🔲 단속 이력 DB 연동 (조회/등록/수정)
- 🔲 AI 매칭 검증 엔진 연동
- 🔲 탐지-단속 연계 자동 분석
**보완 필요:**
- ⚠️ 현재 이력 데이터는 샘플이며, DB 연동 후 실제 단속 데이터로 교체 필요
---
@ -497,15 +487,17 @@ AIS(선박자동식별장치) 신호를 의도적으로 끈 의심 선박(Dark V
- 함정 배치 현황 요약
- 실시간 경보 알림 표시
**구현 완료 (2026-04-17 기준):**
- ✅ **실시간 KPI 카드**`/api/stats/kpi` 연동, prediction 5분 주기 결과 기반
- ✅ 실시간 상황 타임라인 — 최근 `prediction_events` 스트림 (긴급/경고 카운트 실시간)
- ✅ 함정 배치 현황 + 경보 알림 + 순찰 현황 통합
- ✅ 라이트/다크 모드 반응형 (2026-04-17 PR #C 하드코딩 색상 제거)
**구현 완료:**
- ✅ KPI 카드 + 히트맵 + 타임라인 + 함정 현황 통합 대시보드 UI
- ✅ 반응형 레이아웃 (화면 크기에 따른 자동 배치)
**향후 구현 예정:**
- 🔲 WebSocket 기반 실시간 push (현재는 주기 polling)
- 🔲 맞춤형 대시보드 레이아웃 (드래그/리사이즈)
- 🔲 실시간 데이터 연동 (WebSocket 등)
- 🔲 KPI 수치 실시간 갱신
- 🔲 히트맵/타임라인 실시간 업데이트
**보완 필요:**
- ⚠️ 현재 모든 수치는 샘플 데이터이며, 실시간 연동 후 정확한 운영 데이터로 교체 필요
---
@ -524,15 +516,17 @@ AIS(선박자동식별장치) 신호를 의도적으로 끈 의심 선박(Dark V
- 경보 처리(확인/대응/종결) 워크플로우
- 경보 발생 이력 조회
**구현 완료 (2026-04-17 기준):**
- ✅ **실시간 경보 수신**`/api/events` + `/api/alerts` 실 API 연동, prediction event_generator 4룰 기반
- ✅ 경보 등급별(CRITICAL/HIGH/MEDIUM/LOW) 현황 + KPI 카운트
- ✅ 경보 처리 워크플로우 — 확인(ACK) → 단속 등록 → 오탐 처리 (DB 저장 + `auth_audit_log` 기록)
- ✅ 시스템 상태 패널 (백엔드/AI 분석 엔진/DB 상태 실시간 표시, 30초 자동 갱신)
**구현 완료:**
- ✅ 경보 등급별 현황판 UI
- ✅ 경보 목록/상세 조회 화면
**향후 구현 예정:**
- 🔲 경보 자동 에스컬레이션 정책
- 🔲 경보 룰 커스터마이즈 UI
- 🔲 실시간 경보 수신 연동
- 🔲 경보 처리 워크플로우 DB 연동
- 🔲 경보 자동 에스컬레이션
**보완 필요:**
- ⚠️ 현재 경보 데이터는 샘플이며, 실시간 연동 후 실제 경보 데이터로 교체 필요
---
@ -551,15 +545,17 @@ AIS(선박자동식별장치) 신호를 의도적으로 끈 의심 선박(Dark V
- 선박/이벤트 클릭 시 상세 정보 팝업
- 지도 확대/축소 및 해역 필터링
**구현 완료 (2026-04-17 기준):**
- ✅ **실시간 선박 위치 + 이벤트 마커** — prediction 5분 주기 분석 결과(`vessel_analysis_results.lat/lon`) + `prediction_events` 기반
- ✅ MapLibre GL 5 + deck.gl 9 GPU 렌더링 (40만척+ 지원)
- ✅ 위험도별 마커 opacity/radius 차등 (2026-04-17 `ALERT_LEVEL_MARKER_OPACITY/RADIUS` 헬퍼 적용)
- ✅ 이벤트 상세 패널 + 고위험 사건 실시간 알림 (LIVE 표시)
**구현 완료:**
- ✅ LiveMap 기반 실시간 감시 지도 UI
- ✅ 선박/이벤트 마커 및 팝업 인터랙션
**향후 구현 예정:**
- 🔲 WebSocket 기반 실시간 push (현재는 주기 갱신)
- 🔲 SAR 위성영상 오버레이
- 🔲 실시간 AIS/VMS 데이터 연동
- 🔲 WebSocket 기반 실시간 위치 업데이트
- 🔲 이벤트 발생 시 자동 지도 포커스 이동
**보완 필요:**
- ⚠️ 현재 선박 위치는 샘플 데이터이며, 실시간 데이터 연동 필요
---
@ -605,15 +601,17 @@ AIS(선박자동식별장치) 신호를 의도적으로 끈 의심 선박(Dark V
- 기간별/해역별/유형별 필터링
- 통계 데이터 엑셀 내보내기 및 인쇄
**구현 완료 (2026-04-17 기준):**
- ✅ **실시간 통계 데이터**`/api/stats/monthly|daily|hourly` 연동, prediction `stats_aggregator` 집계 결과 기반
- ✅ 월별/일별/시간별 추이 그래프 (ECharts, KST 기준)
- ✅ 해역별/유형별 필터링 + 엑셀 내보내기/인쇄
- ✅ 감사·보안 통계 — `/api/admin/stats/audit|access|login` (2026-04-17 AdminStatsService 계층 분리)
**구현 완료:**
- ✅ 월별 추이 차트 및 KPI 5개 대시보드 UI
- ✅ 필터링 및 엑셀 내보내기/인쇄 기능
**향후 구현 예정:**
- 🔲 보고서 자동 생성 (PDF/HWP) — 현재는 UI만
- 🔲 맞춤형 지표 대시보드 설정
- 🔲 통계 데이터 DB 연동
- 🔲 실제 운영 데이터 기반 KPI 자동 산출
- 🔲 맞춤형 보고서 생성 기능
**보완 필요:**
- ⚠️ 현재 KPI 수치(정확도 93.2%, 오탐율 7.8% 등)는 샘플 데이터이며, 실제 운영 데이터 기반으로 교체 필요
---
@ -745,15 +743,17 @@ AI가 분석한 결과를 기반으로 관련 담당자에게 알림을 발송
- 알림 수신자 설정 및 발송
- 알림 전송 결과(성공/실패) 확인
**구현 완료 (2026-04-17 기준):**
- ✅ **AI 알림 이력 실 API 조회**`/api/alerts` 연동 (2026-04-17 AlertService 계층 분리)
- ✅ prediction `alert_dispatcher` 모듈이 event_generator 결과 기반으로 `prediction_alerts` 테이블에 자동 기록
- ✅ 알림 유형별 분류 + DataTable 검색/정렬/페이징/엑셀 내보내기
**구현 완료:**
- ✅ 알림 5건 전송 현황 UI
- ✅ 알림 유형별 분류 및 상세 조회
**향후 구현 예정:**
- 🔲 실제 SMS/푸시 발송 게이트웨이 연동 (현재는 DB 기록만)
- 🔲 알림 템플릿 엔진
- 🔲 수신자 그룹 관리
- 🔲 실제 알림 발송 기능 구현 (SMS, 이메일, Push 등)
- 🔲 AI 분석 결과 기반 자동 알림 트리거
- 🔲 알림 발송 이력 DB 연동
**보완 필요:**
- ⚠️ 현재 알림은 실제 발송되지 않으며, 발송 채널(SMS/이메일/Push) 연동 필요
---
@ -857,27 +857,15 @@ AI에게 질문하고 답변을 받을 수 있는 대화형(채팅) 인터페이
---
## 부록: 현재 시스템 상태 요약 (2026-04-17 기준)
## 부록: 현재 시스템 상태 요약
| 항목 | 상태 |
|------|------|
| UI 구현 | 모든 SFR 완료 |
| **백엔드 연동** | **15+ 화면 실 API 연동 완료** (Auth/RBAC/Audit/Events/Alerts/Enforcement/Stats/Analysis/Master 등 65+ API) |
| **AI 분석 엔진 (prediction)** | **운영 중** — 5분 주기로 snpdb 분석 → kcgaidb 저장, 14 알고리즘 + DAR-03 G-01~G-06 |
| **데이터** | 실 AIS 원천(snpdb) + prediction 분석 결과 + 자체 DB 저장 데이터 (일부 화면은 여전히 Mock) |
| **인증 체계** | 자체 ID/PW + JWT + 트리 기반 RBAC + 5회 실패 잠금 (SSO/GPKI 미연동) |
| **실시간 기능** | prediction 5분 주기 + 프론트 30초 폴링 (WebSocket push 미도입) |
| **AI 모델** | Dark Vessel 11패턴 / DAR-03 G-01~G-06 / 환적 5단계 / 경량 risk 등 14종 운영 중 (일부 모델은 Mock 계획 단계) |
| **외부 시스템** | snpdb / gc-signal-batch 연동 완료. 유관기관 OpenAPI(GICOMS/MTIS 등)는 미연동 |
| **디자인 시스템** | `design-system.html` 쇼케이스 SSOT 전영역 준수, 라이트/다크 모드 완전 대응 |
| **다국어** | 한/영 alert/confirm/aria-label 전수 치환 완료 (JSX placeholder 35건은 후속 과제) |
| **모바일 앱** | 웹 시뮬레이션만 제공 (PWA/네이티브 앱 미개발) |
---
## 변경 이력
| 일자 | 내용 |
|------|------|
| 2026-04-06 | 초기 작성 (프론트엔드 프로토타입 v0.1.0 기준) |
| 2026-04-17 | 헤더 + SFR-01/02/09/10/11/12/13/17 주요 섹션 업데이트. 실 API 연동 / prediction 운영 상태 / 2026-04-17 PR #A/#B/#C 반영 |
| 백엔드 연동 | 미구현 (전체) |
| 데이터 | 시연용 샘플 데이터 |
| 인증 체계 | 데모 계정 5종 (SSO/GPKI 미연동) |
| 실시간 기능 | 미구현 (WebSocket 등 미연동) |
| AI 모델 | 미연동 (탐지/예측/최적화 등) |
| 외부 시스템 | 미연동 (GICOMS, MTIS 등) |
| 모바일 앱 | 웹 시뮬레이션만 제공 (네이티브 앱 미개발) |

파일 보기

@ -1,12 +0,0 @@
<!doctype html>
<html lang="ko" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>KCG AI Monitoring — Design System</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/designSystemMain.tsx"></script>
</body>
</html>

파일 보기

@ -11,7 +11,6 @@
"@deck.gl/mapbox": "^9.2.11",
"@xyflow/react": "^12.10.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"deck.gl": "^9.2.11",
"echarts": "^6.0.0",
"i18next": "^26.0.3",
@ -21,7 +20,6 @@
"react-dom": "^19.2.4",
"react-i18next": "^17.0.2",
"react-router-dom": "^7.12.0",
"tailwind-merge": "^3.5.0",
"zustand": "^5.0.12"
},
"devDependencies": {
@ -2826,7 +2824,7 @@
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://nexus.gc-si.dev/repository/npm-public/clsx/-/clsx-2.1.1.tgz",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"license": "MIT",
"engines": {
@ -4909,16 +4907,6 @@
"kdbush": "^4.0.2"
}
},
"node_modules/tailwind-merge": {
"version": "3.5.0",
"resolved": "https://nexus.gc-si.dev/repository/npm-public/tailwind-merge/-/tailwind-merge-3.5.0.tgz",
"integrity": "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/dcastil"
}
},
"node_modules/tailwindcss": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz",

파일 보기

@ -15,7 +15,6 @@
"@deck.gl/mapbox": "^9.2.11",
"@xyflow/react": "^12.10.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"deck.gl": "^9.2.11",
"echarts": "^6.0.0",
"i18next": "^26.0.3",
@ -25,7 +24,6 @@
"react-dom": "^19.2.4",
"react-i18next": "^17.0.2",
"react-router-dom": "^7.12.0",
"tailwind-merge": "^3.5.0",
"zustand": "^5.0.12"
},
"devDependencies": {

Binary file not shown.

Before

Width:  |  Height:  |  크기: 72 KiB

Binary file not shown.

Before

Width:  |  Height:  |  크기: 51 KiB

Binary file not shown.

Before

Width:  |  Height:  |  크기: 69 KiB

Binary file not shown.

Before

Width:  |  Height:  |  크기: 67 KiB

Binary file not shown.

Before

Width:  |  Height:  |  크기: 54 KiB

파일 보기

@ -1,13 +1,41 @@
import { Suspense, useMemo, lazy } from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { AuthProvider, useAuth } from '@/app/auth/AuthContext';
import { MainLayout } from '@/app/layout/MainLayout';
import { LoginPage } from '@features/auth';
import { useMenuStore } from '@stores/menuStore';
import { COMPONENT_REGISTRY } from '@/app/componentRegistry';
// 권한 노드 없는 드릴다운 라우트 (인증만 체크)
const VesselDetail = lazy(() => import('@features/vessel').then((m) => ({ default: m.VesselDetail })));
/* SFR-01 */ import { AccessControl } from '@features/admin';
/* SFR-02 */ import { SystemConfig, NoticeManagement } from '@features/admin';
/* SFR-03 */ import { DataHub } from '@features/admin';
/* SFR-04 */ import { AIModelManagement } from '@features/ai-operations';
/* SFR-05 */ import { RiskMap } from '@features/risk-assessment';
/* SFR-06 */ import { EnforcementPlan } from '@features/risk-assessment';
/* SFR-07 */ import { PatrolRoute } from '@features/patrol';
/* SFR-08 */ import { FleetOptimization } from '@features/patrol';
/* SFR-09 */ import { DarkVesselDetection } from '@features/detection';
/* SFR-10 */ import { GearDetection } from '@features/detection';
/* SFR-11 */ import { EnforcementHistory, CaseList, MatchDashboard, MatchVerify, LabelList, HistorySearch, VesselHistory, TypeStats, ReportGen, ChangeHistory } from '@features/enforcement';
/* SFR-12 */ import { MonitoringDashboard } from '@features/monitoring';
/* SFR-13 */ import { Statistics } from '@features/statistics';
/* SFR-14 */ import { ExternalService } from '@features/statistics';
/* SFR-15 */ import { MobileService } from '@features/field-ops';
/* SFR-16 */ import { ShipAgent } from '@features/field-ops';
/* SFR-17 */ import { AIAlert } from '@features/field-ops';
/* SFR-18+19 */ import { MLOpsPage } from '@features/ai-operations';
/* SFR-20 */ import { AIAssistant } from '@features/ai-operations';
/* 기존 */ import { Dashboard } from '@features/dashboard';
import { LiveMapView, MapControl } from '@features/surveillance';
import { EventList } from '@features/enforcement';
import { VesselDetail } from '@features/vessel';
import { ChinaFishing } from '@features/detection';
import { ReportManagement } from '@features/statistics';
import { AdminPanel } from '@features/admin';
// Phase 4: 모선 워크플로우
import { ParentReview } from '@features/parent-inference/ParentReview';
import { ParentExclusion } from '@features/parent-inference/ParentExclusion';
import { LabelSession } from '@features/parent-inference/LabelSession';
// Phase 4: 관리자 로그
import { AuditLogs } from '@features/admin/AuditLogs';
import { AccessLogs } from '@features/admin/AccessLogs';
import { LoginHistoryView } from '@features/admin/LoginHistoryView';
/**
* .
@ -40,66 +68,75 @@ function ProtectedRoute({
return <>{children}</>;
}
function LoadingFallback() {
return (
<div className="flex items-center justify-center min-h-[40vh]">
<div className="text-sm text-hint"> ...</div>
</div>
);
}
/**
* DB menu_config Route .
* React Router v6는 <Routes> <Route> .
*/
function useDynamicRoutes() {
const items = useMenuStore((s) => s.items);
const routableItems = useMemo(
() => items.filter((i) => i.menuType === 'ITEM' && i.urlPath),
[items],
);
return routableItems.map((item) => {
const Comp = item.componentKey ? COMPONENT_REGISTRY[item.componentKey] : null;
if (!Comp || !item.urlPath) return null;
const path = item.urlPath.replace(/^\//, '');
return (
<Route
key={item.menuCd}
path={path}
element={
<ProtectedRoute resource={item.rsrcCd ?? undefined}>
<Suspense fallback={<LoadingFallback />}>
<Comp />
</Suspense>
</ProtectedRoute>
}
/>
);
});
}
function AppRoutes() {
const dynamicRoutes = useDynamicRoutes();
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/" element={<ProtectedRoute><MainLayout /></ProtectedRoute>}>
<Route index element={<Navigate to="/dashboard" replace />} />
{dynamicRoutes}
{/* 드릴다운 전용 라우트 — 메뉴/권한 노드 없음, 인증만 체크 */}
<Route path="vessel/:id" element={<Suspense fallback={<LoadingFallback />}><VesselDetail /></Suspense>} />
</Route>
</Routes>
);
}
export default function App() {
return (
<BrowserRouter>
<AuthProvider>
<AppRoutes />
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/" element={<ProtectedRoute><MainLayout /></ProtectedRoute>}>
<Route index element={<Navigate to="/dashboard" replace />} />
{/* SFR-12 대시보드 */}
<Route path="dashboard" element={<ProtectedRoute resource="dashboard"><Dashboard /></ProtectedRoute>} />
<Route path="monitoring" element={<ProtectedRoute resource="monitoring"><MonitoringDashboard /></ProtectedRoute>} />
{/* SFR-05~06 위험도·단속계획 */}
<Route path="risk-map" element={<ProtectedRoute resource="risk-assessment:risk-map"><RiskMap /></ProtectedRoute>} />
<Route path="enforcement-plan" element={<ProtectedRoute resource="risk-assessment:enforcement-plan"><EnforcementPlan /></ProtectedRoute>} />
{/* SFR-11 단속 사건 관리 (리스트·등록·상세·수정 통합) */}
<Route path="enforcement-plan/cases" element={<ProtectedRoute resource="enforcement:enforcement-history"><CaseList /></ProtectedRoute>} />
<Route path="enforcement-plan/match-dashboard" element={<ProtectedRoute resource="enforcement:enforcement-history"><MatchDashboard /></ProtectedRoute>} />
<Route path="enforcement-plan/match-verify" element={<ProtectedRoute resource="enforcement:enforcement-history" operation="UPDATE"><MatchVerify /></ProtectedRoute>} />
<Route path="enforcement-plan/label-list" element={<ProtectedRoute resource="enforcement:enforcement-history"><LabelList /></ProtectedRoute>} />
<Route path="enforcement-plan/history-search" element={<ProtectedRoute resource="enforcement:enforcement-history"><HistorySearch /></ProtectedRoute>} />
<Route path="enforcement-plan/vessel-history" element={<ProtectedRoute resource="enforcement:enforcement-history"><VesselHistory /></ProtectedRoute>} />
<Route path="enforcement-plan/type-stats" element={<ProtectedRoute resource="enforcement:enforcement-history"><TypeStats /></ProtectedRoute>} />
<Route path="enforcement-plan/report-gen" element={<ProtectedRoute resource="enforcement:enforcement-history"><ReportGen /></ProtectedRoute>} />
<Route path="enforcement-plan/change-history" element={<ProtectedRoute resource="enforcement:enforcement-history"><ChangeHistory /></ProtectedRoute>} />
{/* SFR-09~10 탐지 */}
<Route path="dark-vessel" element={<ProtectedRoute resource="detection:dark-vessel"><DarkVesselDetection /></ProtectedRoute>} />
<Route path="gear-detection" element={<ProtectedRoute resource="detection:gear-detection"><GearDetection /></ProtectedRoute>} />
<Route path="china-fishing" element={<ProtectedRoute resource="detection:china-fishing"><ChinaFishing /></ProtectedRoute>} />
{/* SFR-07~08 순찰경로 */}
<Route path="patrol-route" element={<ProtectedRoute resource="patrol:patrol-route"><PatrolRoute /></ProtectedRoute>} />
<Route path="fleet-optimization" element={<ProtectedRoute resource="patrol:fleet-optimization"><FleetOptimization /></ProtectedRoute>} />
{/* SFR-11 이력 */}
<Route path="enforcement-history" element={<ProtectedRoute resource="enforcement:enforcement-history"><EnforcementHistory /></ProtectedRoute>} />
<Route path="event-list" element={<ProtectedRoute resource="enforcement:event-list"><EventList /></ProtectedRoute>} />
{/* SFR-15~17 현장 대응 */}
<Route path="mobile-service" element={<ProtectedRoute resource="field-ops:mobile-service"><MobileService /></ProtectedRoute>} />
<Route path="ship-agent" element={<ProtectedRoute resource="field-ops:ship-agent"><ShipAgent /></ProtectedRoute>} />
<Route path="ai-alert" element={<ProtectedRoute resource="field-ops:ai-alert"><AIAlert /></ProtectedRoute>} />
{/* SFR-13~14 통계·외부연계 */}
<Route path="statistics" element={<ProtectedRoute resource="statistics:statistics"><Statistics /></ProtectedRoute>} />
<Route path="external-service" element={<ProtectedRoute resource="statistics:external-service"><ExternalService /></ProtectedRoute>} />
<Route path="reports" element={<ProtectedRoute resource="statistics:statistics"><ReportManagement /></ProtectedRoute>} />
{/* SFR-04 AI 모델 */}
<Route path="ai-model" element={<ProtectedRoute resource="ai-operations:ai-model"><AIModelManagement /></ProtectedRoute>} />
{/* SFR-18~20 AI 운영 */}
<Route path="mlops" element={<ProtectedRoute resource="ai-operations:mlops"><MLOpsPage /></ProtectedRoute>} />
<Route path="ai-assistant" element={<ProtectedRoute resource="ai-operations:ai-assistant"><AIAssistant /></ProtectedRoute>} />
{/* SFR-03 데이터허브 */}
<Route path="data-hub" element={<ProtectedRoute resource="admin:system-config"><DataHub /></ProtectedRoute>} />
{/* SFR-02 환경설정 */}
<Route path="system-config" element={<ProtectedRoute resource="admin:system-config"><SystemConfig /></ProtectedRoute>} />
<Route path="notices" element={<ProtectedRoute resource="admin"><NoticeManagement /></ProtectedRoute>} />
{/* SFR-01 권한·시스템 */}
<Route path="access-control" element={<ProtectedRoute resource="admin:permission-management"><AccessControl /></ProtectedRoute>} />
<Route path="admin" element={<ProtectedRoute resource="admin"><AdminPanel /></ProtectedRoute>} />
{/* Phase 4: 관리자 로그 */}
<Route path="admin/audit-logs" element={<ProtectedRoute resource="admin:audit-logs"><AuditLogs /></ProtectedRoute>} />
<Route path="admin/access-logs" element={<ProtectedRoute resource="admin:access-logs"><AccessLogs /></ProtectedRoute>} />
<Route path="admin/login-history" element={<ProtectedRoute resource="admin:login-history"><LoginHistoryView /></ProtectedRoute>} />
{/* Phase 4: 모선 워크플로우 */}
<Route path="parent-inference/review" element={<ProtectedRoute resource="parent-inference-workflow:parent-review"><ParentReview /></ProtectedRoute>} />
<Route path="parent-inference/exclusion" element={<ProtectedRoute resource="parent-inference-workflow:parent-exclusion"><ParentExclusion /></ProtectedRoute>} />
<Route path="parent-inference/label-session" element={<ProtectedRoute resource="parent-inference-workflow:label-session"><LabelSession /></ProtectedRoute>} />
{/* 기존 유지 */}
<Route path="events" element={<ProtectedRoute resource="surveillance:live-map"><LiveMapView /></ProtectedRoute>} />
<Route path="map-control" element={<ProtectedRoute resource="surveillance:map-control"><MapControl /></ProtectedRoute>} />
<Route path="vessel/:id" element={<ProtectedRoute resource="vessel:vessel-detail"><VesselDetail /></ProtectedRoute>} />
</Route>
</Routes>
</AuthProvider>
</BrowserRouter>
);

파일 보기

@ -1,6 +1,5 @@
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react';
import { fetchMe, loginApi, logoutApi, LoginError, type BackendUser, type MenuConfigItem } from '@/services/authApi';
import { useMenuStore } from '@stores/menuStore';
import { fetchMe, loginApi, logoutApi, LoginError, type BackendUser } from '@/services/authApi';
/*
* SFR-01: 시스템
@ -34,6 +33,55 @@ export interface AuthUser {
// ─── 세션 타임아웃 (30분) ──────────────────
const SESSION_TIMEOUT = 30 * 60 * 1000;
// 경로 → 권한 리소스 매핑 (ProtectedRoute용)
const PATH_TO_RESOURCE: Record<string, string> = {
'/dashboard': 'dashboard',
'/monitoring': 'monitoring',
'/events': 'surveillance:live-map',
'/map-control': 'surveillance:map-control',
'/dark-vessel': 'detection:dark-vessel',
'/gear-detection': 'detection:gear-detection',
'/china-fishing': 'detection:china-fishing',
'/vessel': 'vessel',
'/risk-map': 'risk-assessment:risk-map',
'/enforcement-plan/cases': 'enforcement:enforcement-history',
'/enforcement-plan/match-dashboard': 'enforcement:enforcement-history',
'/enforcement-plan/match-verify': 'enforcement:enforcement-history',
'/enforcement-plan/label-list': 'enforcement:enforcement-history',
'/enforcement-plan/history-search': 'enforcement:enforcement-history',
'/enforcement-plan/vessel-history': 'enforcement:enforcement-history',
'/enforcement-plan/type-stats': 'enforcement:enforcement-history',
'/enforcement-plan/report-gen': 'enforcement:enforcement-history',
'/enforcement-plan/change-history': 'enforcement:enforcement-history',
'/enforcement-plan': 'risk-assessment:enforcement-plan',
'/patrol-route': 'patrol:patrol-route',
'/fleet-optimization': 'patrol:fleet-optimization',
'/enforcement-history': 'enforcement:enforcement-history',
'/event-list': 'enforcement:event-list',
'/mobile-service': 'field-ops:mobile-service',
'/ship-agent': 'field-ops:ship-agent',
'/ai-alert': 'field-ops:ai-alert',
'/ai-assistant': 'ai-operations:ai-assistant',
'/ai-model': 'ai-operations:ai-model',
'/mlops': 'ai-operations:mlops',
'/statistics': 'statistics:statistics',
'/external-service': 'statistics:external-service',
'/admin/audit-logs': 'admin:audit-logs',
'/admin/access-logs': 'admin:access-logs',
'/admin/login-history': 'admin:login-history',
'/admin': 'admin',
'/access-control': 'admin:permission-management',
'/system-config': 'admin:system-config',
'/notices': 'admin',
'/reports': 'statistics:statistics',
'/data-hub': 'admin:system-config',
// 모선 워크플로우
'/parent-inference/review': 'parent-inference-workflow:parent-review',
'/parent-inference/exclusion': 'parent-inference-workflow:parent-exclusion',
'/parent-inference/label-session': 'parent-inference-workflow:label-session',
'/parent-inference': 'parent-inference-workflow',
};
interface AuthContextType {
user: AuthUser | null;
loading: boolean;
@ -93,10 +141,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
let alive = true;
fetchMe()
.then((b) => {
if (alive && b) {
setUser(backendToAuthUser(b));
if (b.menuConfig) useMenuStore.getState().setMenuConfig(b.menuConfig);
}
if (alive && b) setUser(backendToAuthUser(b));
})
.finally(() => {
if (alive) setLoading(false);
@ -138,7 +183,6 @@ export function AuthProvider({ children }: { children: ReactNode }) {
try {
const b = await loginApi(account, password);
setUser(backendToAuthUser(b));
if (b.menuConfig) useMenuStore.getState().setMenuConfig(b.menuConfig);
setLastActivity(Date.now());
} catch (e) {
if (e instanceof LoginError) throw e;
@ -151,7 +195,6 @@ export function AuthProvider({ children }: { children: ReactNode }) {
await logoutApi();
} finally {
setUser(null);
useMenuStore.getState().clear();
}
}, []);
@ -166,9 +209,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const hasAccess = useCallback(
(path: string) => {
if (!user) return false;
// DB menu_config 기반 longest-match (PATH_TO_RESOURCE 대체)
const resource = useMenuStore.getState().getResourceForPath(path);
if (!resource) return true;
// 경로의 첫 세그먼트로 매핑
const matched = Object.keys(PATH_TO_RESOURCE).find((p) => path.startsWith(p));
if (!matched) return true; // 매핑 없는 경로는 허용 (안전한 기본값으로 변경 가능)
const resource = PATH_TO_RESOURCE[matched];
return hasPermission(resource, 'READ');
},
[user, hasPermission],

파일 보기

@ -1,157 +0,0 @@
import { lazy, type ComponentType } from 'react';
type LazyComponent = React.LazyExoticComponent<ComponentType<unknown>>;
/**
* DB menu_config.component_key lazy-loaded React .
* 1 .
*/
export const COMPONENT_REGISTRY: Record<string, LazyComponent> = {
// ── 상황판·감시 ──
'features/dashboard/Dashboard': lazy(() =>
import('@features/dashboard/Dashboard').then((m) => ({ default: m.Dashboard })),
),
'features/monitoring/MonitoringDashboard': lazy(() =>
import('@features/monitoring/MonitoringDashboard').then((m) => ({
default: m.MonitoringDashboard,
})),
),
'features/surveillance/LiveMapView': lazy(() =>
import('@features/surveillance').then((m) => ({ default: m.LiveMapView })),
),
'features/surveillance/MapControl': lazy(() =>
import('@features/surveillance').then((m) => ({ default: m.MapControl })),
),
// ── 위험도·단속 ──
'features/risk-assessment/RiskMap': lazy(() =>
import('@features/risk-assessment').then((m) => ({ default: m.RiskMap })),
),
'features/risk-assessment/EnforcementPlan': lazy(() =>
import('@features/risk-assessment').then((m) => ({ default: m.EnforcementPlan })),
),
// ── 탐지 ──
'features/detection/DarkVesselDetection': lazy(() =>
import('@features/detection').then((m) => ({ default: m.DarkVesselDetection })),
),
'features/detection/GearDetection': lazy(() =>
import('@features/detection').then((m) => ({ default: m.GearDetection })),
),
'features/detection/ChinaFishing': lazy(() =>
import('@features/detection').then((m) => ({ default: m.ChinaFishing })),
),
'features/detection/GearCollisionDetection': lazy(() =>
import('@features/detection').then((m) => ({ default: m.GearCollisionDetection })),
),
// ── 단속·이벤트 ──
'features/enforcement/EnforcementHistory': lazy(() =>
import('@features/enforcement').then((m) => ({ default: m.EnforcementHistory })),
),
'features/enforcement/EventList': lazy(() =>
import('@features/enforcement').then((m) => ({ default: m.EventList })),
),
// ── 통계 ──
'features/statistics/Statistics': lazy(() =>
import('@features/statistics').then((m) => ({ default: m.Statistics })),
),
'features/statistics/ReportManagement': lazy(() =>
import('@features/statistics').then((m) => ({ default: m.ReportManagement })),
),
'features/statistics/ExternalService': lazy(() =>
import('@features/statistics').then((m) => ({ default: m.ExternalService })),
),
// ── 순찰 ──
'features/patrol/PatrolRoute': lazy(() =>
import('@features/patrol').then((m) => ({ default: m.PatrolRoute })),
),
'features/patrol/FleetOptimization': lazy(() =>
import('@features/patrol').then((m) => ({ default: m.FleetOptimization })),
),
// ── 현장작전 ──
'features/field-ops/AIAlert': lazy(() =>
import('@features/field-ops').then((m) => ({ default: m.AIAlert })),
),
'features/field-ops/MobileService': lazy(() =>
import('@features/field-ops').then((m) => ({ default: m.MobileService })),
),
'features/field-ops/ShipAgent': lazy(() =>
import('@features/field-ops').then((m) => ({ default: m.ShipAgent })),
),
// ── AI 운영 ──
'features/ai-operations/AIModelManagement': lazy(() =>
import('@features/ai-operations').then((m) => ({ default: m.AIModelManagement })),
),
'features/ai-operations/MLOpsPage': lazy(() =>
import('@features/ai-operations').then((m) => ({ default: m.MLOpsPage })),
),
'features/ai-operations/LGCNSMLOpsPage': lazy(() =>
import('@features/ai-operations').then((m) => ({ default: m.LGCNSMLOpsPage })),
),
'features/ai-operations/LLMOpsPage': lazy(() =>
import('@features/ai-operations').then((m) => ({ default: m.LLMOpsPage })),
),
'features/ai-operations/AIAssistant': lazy(() =>
import('@features/ai-operations').then((m) => ({ default: m.AIAssistant })),
),
// ── 관리 ──
'features/admin/AdminPanel': lazy(() =>
import('@features/admin').then((m) => ({ default: m.AdminPanel })),
),
'features/admin/SystemConfig': lazy(() =>
import('@features/admin').then((m) => ({ default: m.SystemConfig })),
),
'features/admin/DataHub': lazy(() =>
import('@features/admin').then((m) => ({ default: m.DataHub })),
),
'features/admin/AccessControl': lazy(() =>
import('@features/admin').then((m) => ({ default: m.AccessControl })),
),
'features/admin/NoticeManagement': lazy(() =>
import('@features/admin').then((m) => ({ default: m.NoticeManagement })),
),
'features/admin/AuditLogs': lazy(() =>
import('@features/admin/AuditLogs').then((m) => ({ default: m.AuditLogs })),
),
'features/admin/AccessLogs': lazy(() =>
import('@features/admin/AccessLogs').then((m) => ({ default: m.AccessLogs })),
),
'features/admin/LoginHistoryView': lazy(() =>
import('@features/admin/LoginHistoryView').then((m) => ({
default: m.LoginHistoryView,
})),
),
'features/admin/AISecurityPage': lazy(() =>
import('@features/admin').then((m) => ({ default: m.AISecurityPage })),
),
'features/admin/AIAgentSecurityPage': lazy(() =>
import('@features/admin').then((m) => ({ default: m.AIAgentSecurityPage })),
),
'features/admin/DataRetentionPolicy': lazy(() =>
import('@features/admin').then((m) => ({ default: m.DataRetentionPolicy })),
),
'features/admin/DataModelVerification': lazy(() =>
import('@features/admin').then((m) => ({ default: m.DataModelVerification })),
),
'features/admin/PerformanceMonitoring': lazy(() =>
import('@features/admin').then((m) => ({ default: m.PerformanceMonitoring })),
),
// ── 모선 워크플로우 ──
'features/parent-inference/ParentReview': lazy(() =>
import('@features/parent-inference/ParentReview').then((m) => ({
default: m.ParentReview,
})),
),
'features/parent-inference/ParentExclusion': lazy(() =>
import('@features/parent-inference/ParentExclusion').then((m) => ({
default: m.ParentExclusion,
})),
),
'features/parent-inference/LabelSession': lazy(() =>
import('@features/parent-inference/LabelSession').then((m) => ({
default: m.LabelSession,
})),
),
// ── 선박 (숨김 라우트) ──
'features/vessel/VesselDetail': lazy(() =>
import('@features/vessel').then((m) => ({ default: m.VesselDetail })),
),
};

파일 보기

@ -1,54 +0,0 @@
import {
LayoutDashboard, Activity, Radar, Map, Layers, Shield,
EyeOff, Anchor, Ship, FileText, List, BarChart3,
Navigation, Users, Send, Smartphone, Monitor,
GitBranch, CheckSquare, Ban, Tag, Settings,
Brain, Cpu, MessageSquare, Database, Wifi, Globe,
Fingerprint, Megaphone, ScrollText, History, KeyRound,
type LucideIcon,
} from 'lucide-react';
/**
* DB icon Lucide React .
* .
*/
const ICON_MAP: Record<string, LucideIcon> = {
LayoutDashboard,
Activity,
Radar,
Map,
Layers,
Shield,
EyeOff,
Anchor,
Ship,
FileText,
List,
BarChart3,
Navigation,
Users,
Send,
Smartphone,
Monitor,
GitBranch,
CheckSquare,
Ban,
Tag,
Settings,
Brain,
Cpu,
MessageSquare,
Database,
Wifi,
Globe,
Fingerprint,
Megaphone,
ScrollText,
History,
KeyRound,
};
export function resolveIcon(name: string | null): LucideIcon | null {
if (!name) return null;
return ICON_MAP[name] ?? null;
}

파일 보기

@ -2,16 +2,19 @@ import { useState, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { Outlet, NavLink, useNavigate, useLocation } from 'react-router-dom';
import {
LogOut, ChevronLeft, ChevronRight,
Shield, Bell, Search, Clock, Lock,
Download, FileSpreadsheet, Printer,
LayoutDashboard, Map, List, Ship, Anchor, Radar,
FileText, Settings, LogOut, ChevronLeft, ChevronRight,
Shield, Bell, Search, Fingerprint, Clock, Lock, Database, Megaphone, Layers,
Download, FileSpreadsheet, Printer, Wifi, Brain, Activity,
ChevronsLeft, ChevronsRight,
Navigation, Users, EyeOff, BarChart3, Globe,
Smartphone, Monitor, Send, Cpu, MessageSquare,
GitBranch, CheckSquare, Ban, Tag, ScrollText, History, KeyRound,
Plus, Edit3,
} from 'lucide-react';
import { useAuth } from '@/app/auth/AuthContext';
import { getRoleColorHex } from '@shared/constants/userRoles';
import { useAuth, type UserRole } from '@/app/auth/AuthContext';
import { NotificationBanner, NotificationPopup, type SystemNotice } from '@shared/components/common/NotificationBanner';
import { useSettingsStore } from '@stores/settingsStore';
import { useMenuStore, getMenuLabel, type MenuConfigItem } from '@stores/menuStore';
import { resolveIcon } from '@/app/iconRegistry';
/*
* SFR-01 :
@ -25,6 +28,13 @@ import { resolveIcon } from '@/app/iconRegistry';
* - 하단: 페이지네이션
*/
const ROLE_COLORS: Record<UserRole, string> = {
ADMIN: 'text-red-400',
OPERATOR: 'text-blue-400',
ANALYST: 'text-purple-400',
FIELD: 'text-green-400',
VIEWER: 'text-yellow-400',
};
const AUTH_METHOD_LABELS: Record<string, string> = {
password: 'ID/PW',
@ -32,12 +42,155 @@ const AUTH_METHOD_LABELS: Record<string, string> = {
sso: 'SSO',
};
interface NavItem { to: string; icon: React.ElementType; labelKey: string; }
interface NavSubGroup { subGroupKey: string; icon: React.ElementType; items: NavItem[]; }
interface NavGroup { groupKey: string; icon: React.ElementType; items: (NavItem | NavSubGroup)[]; }
type NavEntry = NavItem | NavGroup;
const isGroup = (entry: NavEntry): entry is NavGroup => 'groupKey' in entry;
const isSubGroup = (entry: NavItem | NavSubGroup): entry is NavSubGroup => 'subGroupKey' in entry;
/** 서브그룹 포함 그룹에서 모든 NavItem을 플랫하게 추출 */
function flatGroupItems(items: (NavItem | NavSubGroup)[]): NavItem[] {
return items.flatMap(item => isSubGroup(item) ? item.items : [item]);
}
const NAV_ENTRIES: NavEntry[] = [
// ── 상황판·감시 ──
{ to: '/dashboard', icon: LayoutDashboard, labelKey: 'nav.dashboard' },
{ to: '/monitoring', icon: Activity, labelKey: 'nav.monitoring' },
{ to: '/events', icon: Radar, labelKey: 'nav.eventList' },
{ to: '/map-control', icon: Map, labelKey: 'nav.riskMap' },
// ── 위험도·단속 ──
{ to: '/risk-map', icon: Layers, labelKey: 'nav.riskMap' },
// ── SFR-11 단속계획 (3단 메뉴) ──
{
groupKey: 'group.enforcementPlan', icon: Shield,
items: [
{ to: '/enforcement-plan', icon: Shield, labelKey: 'nav.enforcementPlan' },
{
subGroupKey: 'subGroup.caseManagement', icon: FileText,
items: [
{ to: '/enforcement-plan/cases', icon: List, labelKey: 'nav.caseManagement' },
],
},
{
subGroupKey: 'subGroup.aiMatch', icon: Activity,
items: [
{ to: '/enforcement-plan/match-dashboard', icon: Activity, labelKey: 'nav.matchDashboard' },
{ to: '/enforcement-plan/match-verify', icon: CheckSquare, labelKey: 'nav.matchVerify' },
{ to: '/enforcement-plan/label-list', icon: Tag, labelKey: 'nav.labelList' },
],
},
{
subGroupKey: 'subGroup.historyAnalysis', icon: Search,
items: [
{ to: '/enforcement-plan/history-search', icon: Search, labelKey: 'nav.historySearch' },
{ to: '/enforcement-plan/vessel-history', icon: Ship, labelKey: 'nav.vesselHistory' },
{ to: '/enforcement-plan/type-stats', icon: BarChart3, labelKey: 'nav.typeStats' },
{ to: '/enforcement-plan/report-gen', icon: FileSpreadsheet, labelKey: 'nav.reportGen' },
],
},
{
subGroupKey: 'subGroup.auditTrail', icon: History,
items: [
{ to: '/enforcement-plan/change-history', icon: History, labelKey: 'nav.changeHistory' },
],
},
],
},
// ── 탐지 ──
{ to: '/dark-vessel', icon: EyeOff, labelKey: 'nav.darkVessel' },
{ to: '/gear-detection', icon: Anchor, labelKey: 'nav.gearDetection' },
{ to: '/china-fishing', icon: Ship, labelKey: 'nav.chinaFishing' },
// ── 이력·통계 ──
{ to: '/enforcement-history', icon: FileText, labelKey: 'nav.enforcementHistory' },
{ to: '/event-list', icon: List, labelKey: 'nav.eventList' },
{ to: '/statistics', icon: BarChart3, labelKey: 'nav.statistics' },
{ to: '/reports', icon: FileText, labelKey: 'nav.reports' },
// ── 함정용 (그룹) ──
{
groupKey: 'group.fieldOps', icon: Ship,
items: [
{ to: '/patrol-route', icon: Navigation, labelKey: 'nav.patrolRoute' },
{ to: '/fleet-optimization', icon: Users, labelKey: 'nav.fleetOptimization' },
{ to: '/ai-alert', icon: Send, labelKey: 'nav.aiAlert' },
{ to: '/mobile-service', icon: Smartphone, labelKey: 'nav.mobileService' },
{ to: '/ship-agent', icon: Monitor, labelKey: 'nav.shipAgent' },
],
},
// ── 모선 워크플로우 (운영자 의사결정, 그룹) ──
{
groupKey: 'group.parentInference', icon: GitBranch,
items: [
{ to: '/parent-inference/review', icon: CheckSquare, labelKey: 'nav.parentReview' },
{ to: '/parent-inference/exclusion', icon: Ban, labelKey: 'nav.parentExclusion' },
{ to: '/parent-inference/label-session', icon: Tag, labelKey: 'nav.labelSession' },
],
},
// ── 관리자 (그룹) ──
{
groupKey: 'group.admin', icon: Settings,
items: [
{ to: '/ai-model', icon: Brain, labelKey: 'nav.aiModel' },
{ to: '/mlops', icon: Cpu, labelKey: 'nav.mlops' },
{ to: '/ai-assistant', icon: MessageSquare, labelKey: 'nav.aiAssistant' },
{ to: '/external-service', icon: Globe, labelKey: 'nav.externalService' },
{ to: '/data-hub', icon: Wifi, labelKey: 'nav.dataHub' },
{ to: '/system-config', icon: Database, labelKey: 'nav.systemConfig' },
{ to: '/notices', icon: Megaphone, labelKey: 'nav.notices' },
{ to: '/admin', icon: Settings, labelKey: 'nav.admin' },
{ to: '/access-control', icon: Fingerprint, labelKey: 'nav.accessControl' },
{ to: '/admin/audit-logs', icon: ScrollText, labelKey: 'nav.auditLogs' },
{ to: '/admin/access-logs', icon: History, labelKey: 'nav.accessLogs' },
{ to: '/admin/login-history', icon: KeyRound, labelKey: 'nav.loginHistory' },
],
},
];
// getPageLabel용 flat 목록
const NAV_ITEMS = NAV_ENTRIES.flatMap(e => isGroup(e) ? flatGroupItems(e.items) : [e]);
function formatRemaining(seconds: number) {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m}:${String(s).padStart(2, '0')}`;
}
// ─── 공통 페이지네이션 (간소형) ─────────────
function PagePagination({ page, totalPages, onPageChange }: {
page: number; totalPages: number; onPageChange: (p: number) => void;
}) {
if (totalPages <= 1) return null;
const range: number[] = [];
const maxVis = 5;
let s = Math.max(0, page - Math.floor(maxVis / 2));
const e = Math.min(totalPages - 1, s + maxVis - 1);
if (e - s < maxVis - 1) s = Math.max(0, e - maxVis + 1);
for (let i = s; i <= e; i++) range.push(i);
const btnCls = "p-1 rounded text-hint hover:text-heading hover:bg-surface-overlay disabled:opacity-30 disabled:cursor-not-allowed transition-colors";
return (
<div className="flex items-center justify-center gap-1">
<button onClick={() => onPageChange(0)} disabled={page === 0} className={btnCls}><ChevronsLeft className="w-3.5 h-3.5" /></button>
<button onClick={() => onPageChange(page - 1)} disabled={page === 0} className={btnCls}><ChevronLeft className="w-3.5 h-3.5" /></button>
{range.map((p) => (
<button
key={p}
onClick={() => onPageChange(p)}
className={`min-w-[22px] h-5 px-1 rounded text-[10px] font-medium transition-colors ${
p === page ? 'bg-blue-600 text-heading' : 'text-muted-foreground hover:text-heading hover:bg-surface-overlay'
}`}
>{p + 1}</button>
))}
<button onClick={() => onPageChange(page + 1)} disabled={page >= totalPages - 1} className={btnCls}><ChevronRight className="w-3.5 h-3.5" /></button>
<button onClick={() => onPageChange(totalPages - 1)} disabled={page >= totalPages - 1} className={btnCls}><ChevronsRight className="w-3.5 h-3.5" /></button>
<span className="text-[9px] text-hint ml-2">{page + 1} / {totalPages}</span>
</div>
);
}
export function MainLayout() {
const { t } = useTranslation('common');
const { theme, toggleTheme, language, toggleLanguage } = useSettingsStore();
@ -46,18 +199,43 @@ export function MainLayout() {
const location = useLocation();
const { user, logout, hasAccess, sessionRemaining } = useAuth();
const contentRef = useRef<HTMLDivElement>(null);
const { getTopLevelEntries, getChildren } = useMenuStore();
// getPageLabel: DB 메뉴에서 현재 라우트 페이지명 (DB labels 기반)
// getPageLabel: 현재 라우트에서 페이지명 가져오기 (i18n)
const getPageLabel = (pathname: string): string => {
const allItems = useMenuStore.getState().items.filter((i) => i.menuType === 'ITEM' && i.urlPath);
const item = allItems.find((n) => pathname.startsWith(n.urlPath!));
return item ? getMenuLabel(item, language) : '';
const item = NAV_ITEMS.find((n) => pathname.startsWith(n.to));
return item ? t(item.labelKey) : '';
};
// 공통 검색
const [pageSearch, setPageSearch] = useState('');
// 공통 스크롤 페이징 (페이지 단위 스크롤)
const [scrollPage, setScrollPage] = useState(0);
const scrollPageSize = 800; // px per page
const handleScrollPageChange = (p: number) => {
setScrollPage(p);
if (contentRef.current) {
contentRef.current.scrollTo({ top: p * scrollPageSize, behavior: 'smooth' });
}
};
// 스크롤 이벤트로 현재 페이지 추적
const handleScroll = () => {
if (contentRef.current) {
const { scrollTop, scrollHeight, clientHeight } = contentRef.current;
const totalScrollPages = Math.max(1, Math.ceil((scrollHeight - clientHeight) / scrollPageSize) + 1);
const currentPage = Math.min(Math.floor(scrollTop / scrollPageSize), totalScrollPages - 1);
setScrollPage(currentPage);
}
};
const getTotalScrollPages = () => {
if (!contentRef.current) return 1;
const { scrollHeight, clientHeight } = contentRef.current;
return Math.max(1, Math.ceil((scrollHeight - clientHeight) / scrollPageSize) + 1);
};
// 인쇄
const handlePrint = () => {
const el = contentRef.current;
@ -122,7 +300,7 @@ export function MainLayout() {
});
// RBAC
const roleColor = user ? getRoleColorHex(user.role) : null;
const roleColor = user ? ROLE_COLORS[user.role] : null;
const isSessionWarning = sessionRemaining <= 5 * 60;
// SFR-02: 공통알림 데이터
@ -175,7 +353,7 @@ export function MainLayout() {
<div className="mx-2 mt-2 px-3 py-2 rounded-lg bg-surface-overlay border border-border">
<div className="flex items-center gap-1.5">
<Lock className="w-3 h-3 text-hint" />
<span className="text-[9px] font-bold whitespace-nowrap overflow-hidden text-ellipsis" style={{ color: roleColor ?? undefined }}>{t(`role.${user.role}`)}</span>
<span className={`text-[9px] font-bold whitespace-nowrap overflow-hidden text-ellipsis ${roleColor}`}>{t(`role.${user.role}`)}</span>
</div>
<div className="text-[8px] text-hint mt-0.5">
{t('layout.auth')} {AUTH_METHOD_LABELS[user.authMethod] || user.authMethod}
@ -183,31 +361,120 @@ export function MainLayout() {
</div>
)}
{/* 네비게이션 — DB menu_config 기반 동적 렌더링 + RBAC 필터 */}
{/* 네비게이션 — RBAC 기반 필터링 + 그룹 메뉴 */}
<nav className="flex-1 overflow-y-auto py-2 px-2 space-y-0.5">
{getTopLevelEntries().map((entry) => {
if (entry.menuType === 'GROUP') {
{NAV_ENTRIES.map((entry) => {
if (isGroup(entry)) {
// 그룹 내 모든 NavItem 플랫 추출 (RBAC 필터링용)
const allItems = flatGroupItems(entry.items);
const accessiblePaths = new Set(allItems.filter(item => hasAccess(item.to)).map(item => item.to));
if (accessiblePaths.size === 0) return null;
const GroupIcon = entry.icon;
const isAnyActive = allItems.some(item => location.pathname.startsWith(item.to));
return (
<GroupMenu
key={entry.menuCd}
group={entry}
children={getChildren(entry.menuCd)}
collapsed={collapsed}
hasAccess={hasAccess}
openGroups={openGroups}
toggleGroup={toggleGroup}
location={location}
language={language}
/>
<div key={entry.groupKey}>
{/* 1단: 그룹 헤더 */}
<button
type="button"
onClick={() => toggleGroup(entry.groupKey)}
className={`flex items-center gap-2.5 px-3 py-2 rounded-lg text-[12px] font-medium w-full transition-colors ${
isAnyActive || openGroups.has(entry.groupKey)
? 'text-foreground bg-surface-overlay'
: 'text-hint hover:bg-surface-overlay hover:text-label'
}`}
>
<GroupIcon className="w-4 h-4 shrink-0" />
{!collapsed && (
<>
<span className="flex-1 text-left whitespace-nowrap overflow-hidden text-ellipsis">{t(entry.groupKey)}</span>
<ChevronRight className={`w-3 h-3 shrink-0 transition-transform ${openGroups.has(entry.groupKey) || isAnyActive ? 'rotate-90' : ''}`} />
</>
)}
</button>
{/* 2단: 그룹 하위 메뉴 (NavItem 또는 NavSubGroup) */}
{(openGroups.has(entry.groupKey) || isAnyActive) && (
<div className={`mt-0.5 space-y-0.5 ${collapsed ? '' : 'ml-3 pl-2 border-l border-border'}`}>
{entry.items.map((child) => {
if (isSubGroup(child)) {
// 서브그룹 내 RBAC 필터링
const subItems = child.items.filter(si => accessiblePaths.has(si.to));
if (subItems.length === 0) return null;
const SubIcon = child.icon;
const isSubActive = subItems.some(si => location.pathname.startsWith(si.to));
return (
<div key={child.subGroupKey}>
{/* 2단: 서브그룹 헤더 */}
<button
type="button"
onClick={() => toggleGroup(child.subGroupKey)}
className={`flex items-center gap-2 px-2.5 py-1.5 rounded-lg text-[10px] font-semibold w-full transition-colors ${
isSubActive || openGroups.has(child.subGroupKey)
? 'text-label bg-surface-overlay/60'
: 'text-hint hover:bg-surface-overlay hover:text-label'
}`}
>
<SubIcon className="w-3.5 h-3.5 shrink-0" />
{!collapsed && (
<>
<span className="flex-1 text-left whitespace-nowrap overflow-hidden text-ellipsis">{t(child.subGroupKey)}</span>
<ChevronRight className={`w-2.5 h-2.5 shrink-0 transition-transform ${openGroups.has(child.subGroupKey) || isSubActive ? 'rotate-90' : ''}`} />
</>
)}
</button>
{/* 3단: 서브그룹 아이템 */}
{(openGroups.has(child.subGroupKey) || isSubActive) && (
<div className={`mt-0.5 space-y-0.5 ${collapsed ? '' : 'ml-3 pl-2 border-l border-border/50'}`}>
{subItems.map(si => (
<NavLink
key={si.to}
to={si.to}
className={({ isActive }) =>
`flex items-center gap-1.5 px-2 py-1 rounded-md text-[10px] font-medium transition-colors ${
isActive
? 'bg-blue-600/15 text-blue-400 border border-blue-500/20'
: 'text-muted-foreground hover:bg-surface-overlay hover:text-foreground border border-transparent'
}`
}
>
<si.icon className="w-3 h-3 shrink-0" />
{!collapsed && <span className="whitespace-nowrap overflow-hidden text-ellipsis">{t(si.labelKey)}</span>}
</NavLink>
))}
</div>
)}
</div>
);
}
// 일반 ITEM
if (!entry.urlPath || !hasAccess(entry.urlPath)) return null;
const Icon = resolveIcon(entry.icon);
// 일반 NavItem (2단 직속)
if (!accessiblePaths.has(child.to)) return null;
return (
<NavLink
key={entry.menuCd}
to={entry.urlPath}
key={child.to}
to={child.to}
className={({ isActive }) =>
`flex items-center gap-2 px-2.5 py-1.5 rounded-lg text-[11px] font-medium transition-colors ${
isActive
? 'bg-blue-600/15 text-blue-400 border border-blue-500/20'
: 'text-muted-foreground hover:bg-surface-overlay hover:text-foreground border border-transparent'
}`
}
>
<child.icon className="w-3.5 h-3.5 shrink-0" />
{!collapsed && <span className="whitespace-nowrap overflow-hidden text-ellipsis">{t(child.labelKey)}</span>}
</NavLink>
);
})}
</div>
)}
</div>
);
}
// 일반 메뉴 아이템
if (!hasAccess(entry.to)) return null;
return (
<NavLink
key={entry.to}
to={entry.to}
className={({ isActive }) =>
`flex items-center gap-2.5 px-3 py-2 rounded-lg text-[12px] font-medium transition-colors ${
isActive
@ -216,8 +483,8 @@ export function MainLayout() {
}`
}
>
{Icon && <Icon className="w-4 h-4 shrink-0" />}
{!collapsed && <span className="whitespace-nowrap overflow-hidden text-ellipsis">{getMenuLabel(entry, language)}</span>}
<entry.icon className="w-4 h-4 shrink-0" />
{!collapsed && <span className="whitespace-nowrap overflow-hidden text-ellipsis">{t(entry.labelKey)}</span>}
</NavLink>
);
})}
@ -261,7 +528,7 @@ export function MainLayout() {
<div className="flex items-center gap-3">
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-hint" />
<input aria-label={t('layout.searchPlaceholder')}
<input
type="text"
placeholder={t('layout.searchPlaceholder')}
className="w-56 bg-surface-overlay border border-border rounded-lg pl-8 pr-3 py-1.5 text-[11px] text-heading placeholder:text-hint focus:outline-none focus:border-blue-500/40"
@ -274,7 +541,7 @@ export function MainLayout() {
<div className="w-1.5 h-1.5 rounded-full bg-red-500 animate-pulse" />
<span className="text-[10px] text-red-400 font-bold whitespace-nowrap">{t('layout.alertCount', { count: 3 })}</span>
</div>
<button type="button" aria-label={t('layout.notifications', { defaultValue: '알림' })} className="relative p-1.5 rounded-lg hover:bg-surface-overlay text-muted-foreground hover:text-heading transition-colors">
<button className="relative p-1.5 rounded-lg hover:bg-surface-overlay text-muted-foreground hover:text-heading transition-colors">
<Bell className="w-4 h-4" />
<span className="absolute top-0.5 right-0.5 w-2 h-2 bg-red-500 rounded-full" />
</button>
@ -282,9 +549,8 @@ export function MainLayout() {
{/* 언어 토글 */}
<button
onClick={toggleLanguage}
aria-label={t('aria.languageToggle')}
className="px-2 py-1 rounded-lg text-[10px] font-bold bg-surface-overlay border border-border text-label hover:text-heading transition-colors whitespace-nowrap"
title={language === 'ko' ? t('message.switchToEnglish') : t('message.switchToKorean')}
title={language === 'ko' ? 'Switch to English' : '한국어로 전환'}
>
{language === 'ko' ? 'EN' : '한국어'}
</button>
@ -318,7 +584,7 @@ export function MainLayout() {
<div className="text-[8px] text-hint">{user.org}</div>
</div>
{roleColor && (
<span className="text-[8px] font-bold px-1.5 py-0.5 rounded whitespace-nowrap bg-white/[0.04]" style={{ color: roleColor }}>
<span className={`text-[8px] font-bold px-1.5 py-0.5 rounded whitespace-nowrap ${roleColor} bg-white/[0.04]`}>
{user.role}
</span>
)}
@ -339,7 +605,6 @@ export function MainLayout() {
<div className="relative flex items-center">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3 h-3 text-hint pointer-events-none" />
<input
aria-label={t('aria.searchInPage')}
value={pageSearch}
onChange={(e) => setPageSearch(e.target.value)}
onKeyDown={(e) => {
@ -356,7 +621,7 @@ export function MainLayout() {
(window as unknown as { find: (s: string) => boolean }).find?.(pageSearch);
}
}}
className="flex items-center gap-1 px-2.5 py-1 rounded-r-md text-[9px] bg-blue-400 hover:bg-blue-300 text-on-bright font-medium border border-blue-600 transition-colors"
className="flex items-center gap-1 px-2.5 py-1 rounded-r-md text-[9px] bg-blue-600 hover:bg-blue-500 text-heading font-medium border border-blue-600 transition-colors"
>
<Search className="w-3 h-3" />
{t('action.search')}
@ -393,9 +658,19 @@ export function MainLayout() {
<main
ref={contentRef}
className="flex-1 overflow-auto"
onScroll={handleScroll}
>
<Outlet />
</main>
{/* SFR-02: 공통 페이지네이션 (하단) */}
<div className="shrink-0 border-t border-border bg-background/60 px-4 py-1">
<PagePagination
page={scrollPage}
totalPages={getTotalScrollPages()}
onPageChange={handleScrollPageChange}
/>
</div>
</div>
{/* SFR-02: 공통알림 팝업 */}
@ -403,88 +678,3 @@ export function MainLayout() {
</div>
);
}
/* ─── 그룹 메뉴 서브 컴포넌트 (DB 기반) ─── */
function GroupMenu({
group,
children,
collapsed,
hasAccess,
openGroups,
toggleGroup,
location,
language,
}: {
group: MenuConfigItem;
children: MenuConfigItem[];
collapsed: boolean;
hasAccess: (path: string) => boolean;
openGroups: Set<string>;
toggleGroup: (name: string) => void;
location: { pathname: string };
language: string;
}) {
const navItems = children.filter((c) => c.menuType === 'ITEM' && c.urlPath);
const accessibleItems = navItems.filter((c) => hasAccess(c.urlPath!));
if (accessibleItems.length === 0) return null;
const GroupIcon = resolveIcon(group.icon);
const isAnyActive = accessibleItems.some((c) => location.pathname.startsWith(c.urlPath!));
const isOpen = openGroups.has(group.menuCd) || isAnyActive;
return (
<div>
<button
type="button"
onClick={() => toggleGroup(group.menuCd)}
className={`flex items-center gap-2.5 px-3 py-2 rounded-lg text-[12px] font-medium w-full transition-colors ${
isOpen ? 'text-foreground bg-surface-overlay' : 'text-hint hover:bg-surface-overlay hover:text-label'
}`}
>
{GroupIcon && <GroupIcon className="w-4 h-4 shrink-0" />}
{!collapsed && (
<>
<span className="flex-1 text-left whitespace-nowrap overflow-hidden text-ellipsis">
{getMenuLabel(group, language)}
</span>
<ChevronRight className={`w-3 h-3 shrink-0 transition-transform ${isOpen ? 'rotate-90' : ''}`} />
</>
)}
</button>
{isOpen && (
<div className={`mt-0.5 space-y-0.5 ${collapsed ? '' : 'ml-3 pl-2 border-l border-border'}`}>
{children.map((child) => {
if (child.menuType === 'DIVIDER') {
if (collapsed) return null;
return (
<div key={child.menuCd} className="pt-2 pb-0.5 px-2.5">
<span className="text-[8px] font-bold text-hint uppercase tracking-wider">{child.dividerLabel}</span>
</div>
);
}
if (!child.urlPath || !hasAccess(child.urlPath)) return null;
const ChildIcon = resolveIcon(child.icon);
return (
<NavLink
key={child.menuCd}
to={child.urlPath}
className={({ isActive }) =>
`flex items-center gap-2 px-2.5 py-1.5 rounded-lg text-[11px] font-medium transition-colors ${
isActive
? 'bg-blue-600/15 text-blue-400 border border-blue-500/20'
: 'text-muted-foreground hover:bg-surface-overlay hover:text-foreground border border-transparent'
}`
}
>
{ChildIcon && <ChildIcon className="w-3.5 h-3.5 shrink-0" />}
{!collapsed && (
<span className="whitespace-nowrap overflow-hidden text-ellipsis">{getMenuLabel(child, language)}</span>
)}
</NavLink>
);
})}
</div>
)}
</div>
);
}

파일 보기

@ -1,140 +0,0 @@
/* 디자인 쇼케이스 전용 스타일 */
.ds-shell {
display: flex;
flex-direction: column;
height: 100vh;
background: var(--background, #0b1220);
color: var(--foreground, #e2e8f0);
}
.ds-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1.5rem;
border-bottom: 1px solid rgb(51 65 85 / 0.5);
flex-shrink: 0;
background: var(--surface-overlay, rgb(15 23 42 / 0.6));
-webkit-backdrop-filter: blur(8px);
backdrop-filter: blur(8px);
}
.ds-body {
display: flex;
flex: 1;
min-height: 0;
}
.ds-nav {
width: 220px;
flex-shrink: 0;
padding: 1rem 0.75rem;
border-right: 1px solid rgb(51 65 85 / 0.5);
overflow-y: auto;
position: sticky;
top: 0;
}
.ds-main {
flex: 1;
overflow-y: auto;
padding: 2rem 2.5rem 6rem;
scroll-behavior: smooth;
}
.ds-main section {
margin-bottom: 4rem;
}
/* 추적 ID 시스템 */
.trk-item {
position: relative;
transition: outline-color 0.2s;
}
.trk-copyable {
cursor: pointer;
}
.trk-copyable:hover {
outline: 1px dashed rgb(59 130 246 / 0.5);
outline-offset: 4px;
}
.trk-active {
outline: 2px solid rgb(59 130 246);
outline-offset: 4px;
animation: trk-pulse 1.2s ease-out;
will-change: opacity;
}
.trk-item[data-copied='true'] {
outline: 2px solid rgb(34 197 94) !important;
outline-offset: 4px;
}
.trk-item[data-copied='true']::after {
content: '복사됨 ✓';
position: absolute;
top: -1.5rem;
left: 0;
font-size: 0.625rem;
color: rgb(34 197 94);
background: rgb(34 197 94 / 0.15);
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
pointer-events: none;
z-index: 10;
}
@keyframes trk-pulse {
0% {
opacity: 1;
}
50% {
opacity: 0.35;
}
100% {
opacity: 1;
}
}
/* 쇼케이스 그리드 */
.ds-grid {
display: grid;
gap: 0.75rem;
}
.ds-grid-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.ds-grid-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.ds-grid-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.ds-grid-6 { grid-template-columns: repeat(6, minmax(0, 1fr)); }
.ds-sample {
padding: 1rem;
background: var(--surface-raised, rgb(30 41 59 / 0.4));
border: 1px solid rgb(51 65 85 / 0.4);
border-radius: 0.5rem;
}
.ds-sample-label {
font-size: 0.625rem;
color: var(--text-hint, rgb(148 163 184));
font-family: ui-monospace, monospace;
margin-top: 0.5rem;
word-break: break-all;
}
.ds-code {
display: block;
padding: 0.75rem 1rem;
background: rgb(15 23 42 / 0.7);
border: 1px solid rgb(51 65 85 / 0.4);
border-radius: 0.375rem;
font-family: ui-monospace, monospace;
font-size: 0.75rem;
color: rgb(203 213 225);
white-space: pre;
overflow-x: auto;
}

파일 보기

@ -1,192 +0,0 @@
import { useEffect, useState } from 'react';
import { TrkProvider, useTrk } from './lib/TrkContext';
import { IntroSection } from './sections/IntroSection';
import { TokenSection } from './sections/TokenSection';
import { TypographySection } from './sections/TypographySection';
import { BadgeSection } from './sections/BadgeSection';
import { ButtonSection } from './sections/ButtonSection';
import { FormSection } from './sections/FormSection';
import { CardSectionShowcase } from './sections/CardSection';
import { LayoutSection } from './sections/LayoutSection';
import { CatalogSection } from './sections/CatalogSection';
import { GuideSection } from './sections/GuideSection';
import './DesignSystemApp.css';
interface NavItem {
id: string;
label: string;
anchor: string;
}
const NAV_ITEMS: NavItem[] = [
{ id: 'intro', label: '1. 소개', anchor: 'TRK-SEC-intro' },
{ id: 'token', label: '2. 테마 · 토큰', anchor: 'TRK-SEC-token' },
{ id: 'typography', label: '3. 타이포그래피', anchor: 'TRK-SEC-typography' },
{ id: 'badge', label: '4. Badge', anchor: 'TRK-SEC-badge' },
{ id: 'button', label: '5. Button', anchor: 'TRK-SEC-button' },
{ id: 'form', label: '6. Form', anchor: 'TRK-SEC-form' },
{ id: 'card', label: '7. Card / Section', anchor: 'TRK-SEC-card' },
{ id: 'layout', label: '8. Layout', anchor: 'TRK-SEC-layout' },
{ id: 'catalog', label: '9. 분류 카탈로그', anchor: 'TRK-SEC-catalog' },
{ id: 'guide', label: '10. 예외 / 가이드', anchor: 'TRK-SEC-guide' },
];
function DesignSystemShell() {
const { copyMode, setCopyMode } = useTrk();
const [theme, setTheme] = useState<'dark' | 'light'>('dark');
const [activeNav, setActiveNav] = useState<string>('intro');
// 테마 토글
useEffect(() => {
const root = document.documentElement;
root.classList.remove('dark', 'light');
root.classList.add(theme);
}, [theme]);
// 단축키: 'a' 입력 시 테마 전환 (input/textarea 등 편집 중에는 무시)
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key !== 'a' && e.key !== 'A') return;
if (e.ctrlKey || e.metaKey || e.altKey) return;
const target = e.target as HTMLElement | null;
const tag = target?.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || target?.isContentEditable) {
return;
}
setTheme((prev) => (prev === 'dark' ? 'light' : 'dark'));
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, []);
// 스크롤 감지로 현재 네비 하이라이트
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
const id = entry.target.getAttribute('data-section-id');
if (id) setActiveNav(id);
}
}
},
{ rootMargin: '-40% 0px -55% 0px', threshold: 0 },
);
const sections = document.querySelectorAll('[data-section-id]');
sections.forEach((s) => observer.observe(s));
return () => observer.disconnect();
}, []);
const scrollTo = (anchor: string) => {
const el = document.querySelector(`[data-trk="${anchor}"]`);
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
};
return (
<div className="ds-shell">
{/* 고정 헤더 */}
<header className="ds-header">
<div className="flex items-center gap-3">
<h1 className="text-base font-bold text-heading">KCG Design System</h1>
<code className="text-[10px] text-hint font-mono">v0.1.0 · </code>
</div>
<div className="flex items-center gap-3">
<label className="flex items-center gap-1.5 cursor-pointer text-xs text-label">
<input
type="checkbox"
checked={copyMode}
onChange={(e) => setCopyMode(e.target.checked)}
className="accent-blue-500"
/>
ID
</label>
<button
type="button"
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
title="단축키: A"
className="px-3 py-1 rounded-md border border-slate-600/40 text-xs text-label hover:bg-slate-700/30 flex items-center gap-1.5"
>
<span>{theme === 'dark' ? '☾ Dark' : '☀ Light'}</span>
<kbd className="text-[9px] font-mono text-hint border border-slate-600/40 rounded px-1 py-0">A</kbd>
</button>
</div>
</header>
<div className="ds-body">
{/* 좌측 네비 */}
<nav className="ds-nav">
<div className="text-[10px] text-hint uppercase mb-2 tracking-wider">Sections</div>
<ul className="space-y-0.5">
{NAV_ITEMS.map((item) => (
<li key={item.id}>
<button
type="button"
onClick={() => scrollTo(item.anchor)}
className={`w-full text-left px-2 py-1.5 rounded text-xs transition-colors ${
activeNav === item.id
? 'bg-blue-500/15 text-blue-400 border-l-2 border-blue-500'
: 'text-label hover:bg-slate-700/20'
}`}
>
{item.label}
</button>
</li>
))}
</ul>
<div className="mt-6 pt-4 border-t border-slate-700/40 text-[10px] text-hint space-y-1">
<div>
<strong className="text-label"> ID </strong>
</div>
<code className="block font-mono">TRK-&lt;&gt;-&lt;&gt;</code>
<div className="mt-2">
: <code className="font-mono">#trk=ID</code>
</div>
</div>
</nav>
{/* 우측 컨텐츠 */}
<main className="ds-main">
<section data-section-id="intro">
<IntroSection />
</section>
<section data-section-id="token">
<TokenSection />
</section>
<section data-section-id="typography">
<TypographySection />
</section>
<section data-section-id="badge">
<BadgeSection />
</section>
<section data-section-id="button">
<ButtonSection />
</section>
<section data-section-id="form">
<FormSection />
</section>
<section data-section-id="card">
<CardSectionShowcase />
</section>
<section data-section-id="layout">
<LayoutSection />
</section>
<section data-section-id="catalog">
<CatalogSection />
</section>
<section data-section-id="guide">
<GuideSection />
</section>
</main>
</div>
</div>
);
}
export function DesignSystemApp() {
return (
<TrkProvider>
<DesignSystemShell />
</TrkProvider>
);
}

파일 보기

@ -1,71 +0,0 @@
import { type ReactNode, type CSSProperties, type MouseEvent } from 'react';
import { useTrk } from './TrkContext';
/**
* ID
* ID를 :
* 1. ID
* 2. "ID 복사 모드"
* 3. URL hash (#trk=TRK-BADGE-critical-sm)
*/
interface TrkProps {
id: string;
children: ReactNode;
className?: string;
style?: CSSProperties;
/** 인라인 요소로 렌더할지 여부 (기본: block) */
inline?: boolean;
}
export function Trk({ id, children, className = '', style, inline = false }: TrkProps) {
const { copyMode, activeId } = useTrk();
const isActive = activeId === id;
const handleClick = async (e: MouseEvent) => {
if (!copyMode) return;
e.preventDefault();
e.stopPropagation();
try {
await navigator.clipboard.writeText(id);
const el = e.currentTarget as HTMLElement;
el.dataset.copied = 'true';
setTimeout(() => delete el.dataset.copied, 800);
} catch {
// clipboard API 미지원 시 무시
}
};
const Wrapper = inline ? 'span' : 'div';
return (
<Wrapper
data-trk={id}
className={`trk-item ${isActive ? 'trk-active' : ''} ${copyMode ? 'trk-copyable' : ''} ${className}`}
style={style}
title={id}
onClick={handleClick}
>
{children}
</Wrapper>
);
}
export function TrkSectionHeader({
id,
title,
description,
}: {
id: string;
title: string;
description?: string;
}) {
return (
<div data-trk={id} className="mb-4">
<div className="flex items-baseline gap-2">
<h2 className="text-xl font-bold text-heading">{title}</h2>
<code className="text-[10px] text-hint font-mono">{id}</code>
</div>
{description && <p className="text-xs text-hint mt-1">{description}</p>}
</div>
);
}

파일 보기

@ -1,50 +0,0 @@
import { useContext, createContext, useState, useEffect, type ReactNode } from 'react';
interface TrkContextValue {
copyMode: boolean;
setCopyMode: (v: boolean) => void;
activeId: string | null;
setActiveId: (id: string | null) => void;
}
// eslint-disable-next-line react-refresh/only-export-components
export const TrkContext = createContext<TrkContextValue | null>(null);
export function TrkProvider({ children }: { children: ReactNode }) {
const [copyMode, setCopyMode] = useState(false);
const [activeId, setActiveId] = useState<string | null>(null);
// 딥링크 처리: #trk=TRK-BADGE-critical-sm
useEffect(() => {
const applyHash = () => {
const hash = window.location.hash;
const match = hash.match(/#trk=([\w-]+)/);
if (match) {
const id = match[1];
setActiveId(id);
setTimeout(() => {
const el = document.querySelector(`[data-trk="${id}"]`);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}, 50);
}
};
applyHash();
window.addEventListener('hashchange', applyHash);
return () => window.removeEventListener('hashchange', applyHash);
}, []);
return (
<TrkContext.Provider value={{ copyMode, setCopyMode, activeId, setActiveId }}>
{children}
</TrkContext.Provider>
);
}
// eslint-disable-next-line react-refresh/only-export-components
export function useTrk() {
const ctx = useContext(TrkContext);
if (!ctx) throw new Error('useTrk must be used within TrkProvider');
return ctx;
}

파일 보기

@ -1,109 +0,0 @@
import { TrkSectionHeader, Trk } from '../lib/Trk';
import { Badge } from '@shared/components/ui/badge';
import {
BADGE_INTENT_META,
BADGE_INTENT_ORDER,
BADGE_SIZE_ORDER,
} from '@lib/theme/variantMeta';
export function BadgeSection() {
return (
<>
<TrkSectionHeader
id="TRK-SEC-badge"
title="Badge"
description={`${BADGE_INTENT_ORDER.length} intent × ${BADGE_SIZE_ORDER.length} size = ${BADGE_INTENT_ORDER.length * BADGE_SIZE_ORDER.length} 변형. CVA + cn()로 className override 허용, !important 없음.`}
/>
{/* 변형 그리드 */}
<h3 className="text-sm font-semibold text-heading mb-2 mt-2"> </h3>
<Trk id="TRK-BADGE-matrix" className="ds-sample">
<div className="overflow-x-auto">
<table className="w-full border-collapse">
<thead>
<tr>
<th className="text-left text-[10px] text-hint font-mono pb-2 pr-3">
intent / size
</th>
{BADGE_SIZE_ORDER.map((size) => (
<th key={size} className="text-left text-[10px] text-hint font-mono pb-2 px-2">
{size}
</th>
))}
</tr>
</thead>
<tbody>
{BADGE_INTENT_ORDER.map((intent) => (
<tr key={intent}>
<td className="text-[11px] text-label font-mono pr-3 py-1.5">{intent}</td>
{BADGE_SIZE_ORDER.map((size) => (
<td key={size} className="px-2 py-1.5">
<Trk id={`TRK-BADGE-${intent}-${size}`} inline>
<Badge intent={intent} size={size}>
{intent.toUpperCase()}
</Badge>
</Trk>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</Trk>
{/* intent 의미 가이드 (variantMeta에서 자동 열거) */}
<h3 className="text-sm font-semibold text-heading mb-2 mt-6">Intent </h3>
<div className="ds-grid ds-grid-2">
{BADGE_INTENT_ORDER.map((intent) => {
const meta = BADGE_INTENT_META[intent];
return (
<Trk key={intent} id={`TRK-BADGE-usage-${intent}`} className="ds-sample">
<div className="flex items-center gap-3">
<Badge intent={intent} size="md">
{meta.titleKo}
</Badge>
<span className="text-xs text-label flex-1">{meta.description}</span>
</div>
</Trk>
);
})}
</div>
{/* 사용 예시 코드 */}
<h3 className="text-sm font-semibold text-heading mb-2 mt-6"> </h3>
<Trk id="TRK-BADGE-usage-code" className="ds-sample">
<code className="ds-code">
{`import { Badge } from '@shared/components/ui/badge';
import { getAlertLevelIntent, getAlertLevelLabel } from '@shared/constants/alertLevels';
// 카탈로그 API와 결합 — 라벨/색상 변경은 카탈로그 파일에서만
<Badge intent={getAlertLevelIntent('CRITICAL')} size="sm">
{getAlertLevelLabel('CRITICAL', t, lang)}
</Badge>
// className override (tailwind-merge가 같은 그룹 충돌 감지)
<Badge intent="info" size="md" className="rounded-full px-3">
</Badge>`}
</code>
</Trk>
{/* 금지 패턴 */}
<h3 className="text-sm font-semibold text-heading mb-2 mt-6"> </h3>
<Trk id="TRK-BADGE-antipattern" className="ds-sample border-red-500/30">
<div className="text-xs text-red-400 mb-2"> </div>
<code className="ds-code">
{`// ❌ className 직접 작성 (intent prop 무시)
<Badge className="bg-red-400 text-white text-[11px]">...</Badge>
// ❌ !important 사용
<Badge className="!bg-red-400 !text-slate-900">...</Badge>
// ❌ <div className="bg-red-400 ..."> → Badge 컴포넌트 사용 필수
<div className="inline-flex bg-red-400 text-white rounded px-2"></div>`}
</code>
</Trk>
</>
);
}

파일 보기

@ -1,129 +0,0 @@
import { TrkSectionHeader, Trk } from '../lib/Trk';
import { Button } from '@shared/components/ui/button';
import {
BUTTON_VARIANT_META,
BUTTON_VARIANT_ORDER,
BUTTON_SIZE_ORDER,
} from '@lib/theme/variantMeta';
import { Plus, Download, Trash2, Search, Save } from 'lucide-react';
export function ButtonSection() {
return (
<>
<TrkSectionHeader
id="TRK-SEC-button"
title="Button"
description={`${BUTTON_VARIANT_ORDER.length} variant × ${BUTTON_SIZE_ORDER.length} size = ${BUTTON_VARIANT_ORDER.length * BUTTON_SIZE_ORDER.length} 변형. CVA 기반, 직접 className 작성 금지.`}
/>
{/* 매트릭스 */}
<h3 className="text-sm font-semibold text-heading mb-2 mt-2"> </h3>
<Trk id="TRK-BUTTON-matrix" className="ds-sample">
<div className="overflow-x-auto">
<table className="w-full border-collapse">
<thead>
<tr>
<th className="text-left text-[10px] text-hint font-mono pb-2 pr-3">
variant / size
</th>
{BUTTON_SIZE_ORDER.map((size) => (
<th key={size} className="text-left text-[10px] text-hint font-mono pb-2 px-3">
{size}
</th>
))}
</tr>
</thead>
<tbody>
{BUTTON_VARIANT_ORDER.map((variant) => (
<tr key={variant}>
<td className="text-[11px] text-label font-mono pr-3 py-2">{variant}</td>
{BUTTON_SIZE_ORDER.map((size) => (
<td key={size} className="px-3 py-2">
<Trk id={`TRK-BUTTON-${variant}-${size}`} inline>
<Button variant={variant} size={size}>
{variant}
</Button>
</Trk>
</td>
))}
</tr>
))}
</tbody>
</table>
</div>
</Trk>
{/* 아이콘 버튼 */}
<h3 className="text-sm font-semibold text-heading mb-2 mt-6"> </h3>
<Trk id="TRK-BUTTON-with-icon" className="ds-sample">
<div className="flex flex-wrap items-center gap-3">
<Button variant="primary" icon={<Plus className="w-4 h-4" />}>
</Button>
<Button variant="secondary" icon={<Download className="w-4 h-4" />}>
</Button>
<Button variant="outline" icon={<Search className="w-4 h-4" />}>
</Button>
<Button variant="ghost" icon={<Save className="w-4 h-4" />}>
</Button>
<Button variant="destructive" icon={<Trash2 className="w-4 h-4" />}>
</Button>
</div>
</Trk>
{/* 상태 */}
<h3 className="text-sm font-semibold text-heading mb-2 mt-6"></h3>
<Trk id="TRK-BUTTON-states" className="ds-sample">
<div className="flex flex-wrap items-center gap-3">
<Button variant="primary">Normal</Button>
<Button variant="primary" disabled>
Disabled
</Button>
</div>
</Trk>
{/* variant 의미 가이드 (variantMeta에서 자동 열거) */}
<h3 className="text-sm font-semibold text-heading mb-2 mt-6">Variant </h3>
<div className="ds-grid ds-grid-2">
{BUTTON_VARIANT_ORDER.map((variant) => {
const meta = BUTTON_VARIANT_META[variant];
return (
<Trk key={variant} id={`TRK-BUTTON-usage-${variant}`} className="ds-sample">
<div className="flex items-center gap-3">
<Button variant={variant} size="sm">
{meta.titleKo}
</Button>
<span className="text-xs text-label flex-1">{meta.description}</span>
</div>
</Trk>
);
})}
</div>
{/* 사용 예시 */}
<h3 className="text-sm font-semibold text-heading mb-2 mt-6"> </h3>
<Trk id="TRK-BUTTON-usage-code" className="ds-sample">
<code className="ds-code">
{`import { Button } from '@shared/components/ui/button';
import { Plus } from 'lucide-react';
<Button variant="primary" size="md" icon={<Plus className="w-4 h-4" />}>
</Button>
<Button variant="destructive" onClick={handleDelete} disabled={loading}>
</Button>
// 금지
// ❌ <button className="bg-blue-600 text-white px-4 py-2 rounded-lg">
// ❌ <Button className="bg-red-500"> → variant="destructive" 사용`}
</code>
</Trk>
</>
);
}

파일 보기

@ -1,111 +0,0 @@
import { TrkSectionHeader, Trk } from '../lib/Trk';
import { Card, CardHeader, CardTitle, CardContent } from '@shared/components/ui/card';
import { Database, Activity, Bell } from 'lucide-react';
export function CardSectionShowcase() {
return (
<>
<TrkSectionHeader
id="TRK-SEC-card"
title="Card · Section"
description="4 variant (default/elevated/inner/transparent). CardHeader+CardTitle+CardContent 조합."
/>
{/* 4 variant */}
<h3 className="text-sm font-semibold text-heading mb-2 mt-2">Variant</h3>
<div className="ds-grid ds-grid-2">
<Trk id="TRK-CARD-default" className="ds-sample">
<label className="text-[10px] text-hint font-mono mb-2 block">variant=default</label>
<Card variant="default">
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-1.5">
<Database className="w-3.5 h-3.5 text-blue-400" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-1.5">
<div className="flex justify-between text-[11px]">
<span className="text-hint">PostgreSQL</span>
<span className="text-label">v15.4 </span>
</div>
<div className="flex justify-between text-[11px]">
<span className="text-hint"></span>
<span className="text-label">8 / 20</span>
</div>
</CardContent>
</Card>
</Trk>
<Trk id="TRK-CARD-elevated" className="ds-sample">
<label className="text-[10px] text-hint font-mono mb-2 block">variant=elevated ()</label>
<Card variant="elevated">
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-1.5">
<Activity className="w-3.5 h-3.5 text-green-400" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-1.5">
<div className="flex justify-between text-[11px]">
<span className="text-hint">API</span>
<span className="text-green-400"></span>
</div>
<div className="flex justify-between text-[11px]">
<span className="text-hint">Prediction</span>
<span className="text-green-400">5 </span>
</div>
</CardContent>
</Card>
</Trk>
<Trk id="TRK-CARD-inner" className="ds-sample">
<label className="text-[10px] text-hint font-mono mb-2 block">variant=inner</label>
<Card variant="inner">
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-1.5">
<Bell className="w-3.5 h-3.5 text-yellow-400" />
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-[11px] text-label"> . depth.</p>
</CardContent>
</Card>
</Trk>
<Trk id="TRK-CARD-transparent" className="ds-sample">
<label className="text-[10px] text-hint font-mono mb-2 block">variant=transparent</label>
<Card variant="transparent">
<CardHeader className="pb-2">
<CardTitle> </CardTitle>
</CardHeader>
<CardContent>
<p className="text-[11px] text-label">/ ( ).</p>
</CardContent>
</Card>
</Trk>
</div>
{/* 사용 예시 */}
<h3 className="text-sm font-semibold text-heading mb-2 mt-6"> </h3>
<Trk id="TRK-CARD-usage-code" className="ds-sample">
<code className="ds-code">
{`import { Card, CardHeader, CardTitle, CardContent } from '@shared/components/ui/card';
import { Database } from 'lucide-react';
<Card variant="elevated">
<CardHeader className="pb-2">
<CardTitle className="flex items-center gap-1.5">
<Database className="w-3.5 h-3.5 text-blue-400" />
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{/* 콘텐츠 */}
</CardContent>
</Card>`}
</code>
</Trk>
</>
);
}

파일 보기

@ -1,130 +0,0 @@
import { type ReactNode } from 'react';
import { TrkSectionHeader, Trk } from '../lib/Trk';
import { Badge } from '@shared/components/ui/badge';
import type { BadgeIntent } from '@lib/theme/variants';
import { CATALOG_REGISTRY, type CatalogEntry } from '@shared/constants/catalogRegistry';
/**
* `CATALOG_REGISTRY` .
* `catalogRegistry.ts` .
* .
*/
interface AnyMeta {
code: string;
intent?: BadgeIntent;
fallback?: { ko: string; en: string };
classes?: string | { bg?: string; text?: string; border?: string };
label?: string;
}
function getKoLabel(meta: AnyMeta): string {
return meta.fallback?.ko ?? meta.label ?? meta.code;
}
function getEnLabel(meta: AnyMeta): string | undefined {
return meta.fallback?.en;
}
function getFallbackClasses(meta: AnyMeta): string | undefined {
if (typeof meta.classes === 'string') return meta.classes;
if (typeof meta.classes === 'object' && meta.classes) {
return [meta.classes.bg, meta.classes.text, meta.classes.border].filter(Boolean).join(' ');
}
return undefined;
}
function renderBadge(meta: AnyMeta, label: string): ReactNode {
if (meta.intent) {
return (
<Badge intent={meta.intent} size="sm">
{label}
</Badge>
);
}
const classes =
getFallbackClasses(meta) ??
'bg-slate-100 text-slate-700 border-slate-300 dark:bg-slate-500/20 dark:text-slate-300 dark:border-slate-500/30';
return (
<span
className={`inline-flex items-center px-2 py-0.5 rounded-md border text-[12px] font-semibold ${classes}`}
>
{label}
</span>
);
}
function CatalogBadges({ entry }: { entry: CatalogEntry }) {
const items = Object.values(entry.items) as AnyMeta[];
return (
<div className="space-y-1.5">
{items.map((meta) => {
const koLabel = getKoLabel(meta);
const enLabel = getEnLabel(meta);
const trkId = `${entry.showcaseId}-${meta.code}`;
return (
<Trk key={meta.code} id={trkId} className="flex items-center gap-3 rounded-sm">
<code className="text-[10px] text-hint font-mono whitespace-nowrap w-32 shrink-0 truncate">
{meta.code}
</code>
<div className="flex-1">{renderBadge(meta, koLabel)}</div>
<div className="flex-1">
{enLabel ? (
renderBadge(meta, enLabel)
) : (
<span className="text-[10px] text-hint italic">(no en)</span>
)}
</div>
</Trk>
);
})}
</div>
);
}
export function CatalogSection() {
return (
<>
<TrkSectionHeader
id="TRK-SEC-catalog"
title={`분류 카탈로그 (${CATALOG_REGISTRY.length}+)`}
description="백엔드 enum/code_master 기반 SSOT. 쇼케이스와 실 페이지가 동일 레지스트리 참조."
/>
<Trk id="TRK-CAT-intro" className="ds-sample mb-4">
<p className="text-xs text-label leading-relaxed">
API를 (/intent/ ):
</p>
<code className="ds-code mt-2">
{`import { getAlertLevelIntent, getAlertLevelLabel } from '@shared/constants/alertLevels';
<Badge intent={getAlertLevelIntent(event.level)} size="sm">
{getAlertLevelLabel(event.level, t, lang)}
</Badge>`}
</code>
<p className="text-[11px] text-hint mt-2">
<strong> </strong>: <code className="font-mono">shared/constants/catalogRegistry.ts</code>
.
</p>
</Trk>
{CATALOG_REGISTRY.map((entry) => (
<Trk key={entry.id} id={entry.showcaseId} className="ds-sample mb-3">
<div className="mb-2">
<div className="flex items-baseline gap-2 flex-wrap">
<h3 className="text-sm font-semibold text-heading">
{entry.titleKo} · {entry.titleEn}
</h3>
<code className="text-[10px] text-hint font-mono">{entry.showcaseId}</code>
</div>
<p className="text-[11px] text-hint">{entry.description}</p>
{entry.source && (
<p className="text-[10px] text-hint italic mt-0.5">: {entry.source}</p>
)}
</div>
<CatalogBadges entry={entry} />
</Trk>
))}
</>
);
}

파일 보기

@ -1,124 +0,0 @@
import { useState } from 'react';
import { TrkSectionHeader, Trk } from '../lib/Trk';
import { Input } from '@shared/components/ui/input';
import { Select } from '@shared/components/ui/select';
import { Textarea } from '@shared/components/ui/textarea';
import { Checkbox } from '@shared/components/ui/checkbox';
import { Radio } from '@shared/components/ui/radio';
export function FormSection() {
const [radio, setRadio] = useState('a');
return (
<>
<TrkSectionHeader
id="TRK-SEC-form"
title="Form 요소"
description="Input · Select · Textarea · Checkbox · Radio. 공통 스타일 토큰 공유."
/>
{/* Input 사이즈 */}
<h3 className="text-sm font-semibold text-heading mb-2 mt-2">Input · </h3>
<div className="ds-grid ds-grid-3">
{(['sm', 'md', 'lg'] as const).map((size) => (
<Trk key={size} id={`TRK-FORM-input-${size}`} className="ds-sample">
<label className="text-[10px] text-hint font-mono mb-1 block">size={size}</label>
<Input size={size} placeholder={`Input size=${size}`} />
</Trk>
))}
</div>
{/* Input 상태 */}
<h3 className="text-sm font-semibold text-heading mb-2 mt-6">Input · </h3>
<div className="ds-grid ds-grid-3">
<Trk id="TRK-FORM-input-default" className="ds-sample">
<label className="text-[10px] text-hint font-mono mb-1 block">state=default</label>
<Input placeholder="기본 상태" />
</Trk>
<Trk id="TRK-FORM-input-error" className="ds-sample">
<label className="text-[10px] text-hint font-mono mb-1 block">state=error</label>
<Input state="error" defaultValue="잘못된 값" />
</Trk>
<Trk id="TRK-FORM-input-success" className="ds-sample">
<label className="text-[10px] text-hint font-mono mb-1 block">state=success</label>
<Input state="success" defaultValue="유효한 값" />
</Trk>
<Trk id="TRK-FORM-input-disabled" className="ds-sample">
<label className="text-[10px] text-hint font-mono mb-1 block">disabled</label>
<Input disabled defaultValue="비활성" />
</Trk>
<Trk id="TRK-FORM-input-readonly" className="ds-sample">
<label className="text-[10px] text-hint font-mono mb-1 block">readOnly</label>
<Input readOnly defaultValue="읽기 전용" />
</Trk>
<Trk id="TRK-FORM-input-type-number" className="ds-sample">
<label className="text-[10px] text-hint font-mono mb-1 block">type=number</label>
<Input type="number" defaultValue={42} />
</Trk>
</div>
{/* Select */}
<h3 className="text-sm font-semibold text-heading mb-2 mt-6">Select</h3>
<div className="ds-grid ds-grid-3">
{(['sm', 'md', 'lg'] as const).map((size) => (
<Trk key={size} id={`TRK-FORM-select-${size}`} className="ds-sample">
<label className="text-[10px] text-hint font-mono mb-1 block">size={size}</label>
<Select aria-label={`쇼케이스 샘플 Select ${size}`} size={size} defaultValue="">
<option value=""> </option>
<option value="CRITICAL">CRITICAL</option>
<option value="HIGH">HIGH</option>
<option value="MEDIUM">MEDIUM</option>
</Select>
</Trk>
))}
</div>
{/* Textarea */}
<h3 className="text-sm font-semibold text-heading mb-2 mt-6">Textarea</h3>
<Trk id="TRK-FORM-textarea" className="ds-sample">
<Textarea placeholder="여러 줄 입력..." rows={3} />
</Trk>
{/* Checkbox */}
<h3 className="text-sm font-semibold text-heading mb-2 mt-6">Checkbox</h3>
<Trk id="TRK-FORM-checkbox" className="ds-sample">
<div className="flex flex-wrap gap-4">
<Checkbox label="옵션 1" defaultChecked />
<Checkbox label="옵션 2" />
<Checkbox label="비활성" disabled />
<Checkbox label="체크됨 비활성" disabled defaultChecked />
</div>
</Trk>
{/* Radio */}
<h3 className="text-sm font-semibold text-heading mb-2 mt-6">Radio</h3>
<Trk id="TRK-FORM-radio" className="ds-sample">
<div className="flex flex-wrap gap-4">
<Radio name="grp" label="선택 A" checked={radio === 'a'} onChange={() => setRadio('a')} />
<Radio name="grp" label="선택 B" checked={radio === 'b'} onChange={() => setRadio('b')} />
<Radio name="grp" label="선택 C" checked={radio === 'c'} onChange={() => setRadio('c')} />
<Radio name="grp" label="비활성" disabled />
</div>
</Trk>
{/* 사용 예시 */}
<h3 className="text-sm font-semibold text-heading mb-2 mt-6"> </h3>
<Trk id="TRK-FORM-usage-code" className="ds-sample">
<code className="ds-code">
{`import { Input } from '@shared/components/ui/input';
import { Select } from '@shared/components/ui/select';
import { Checkbox } from '@shared/components/ui/checkbox';
<Input size="md" placeholder="검색어 입력" value={q} onChange={(e) => setQ(e.target.value)} />
<Select size="sm" value={level} onChange={(e) => setLevel(e.target.value)}>
<option value=""> </option>
<option value="CRITICAL">CRITICAL</option>
</Select>
<Checkbox label="활성화" checked={active} onChange={(e) => setActive(e.target.checked)} />`}
</code>
</Trk>
</>
);
}

파일 보기

@ -1,130 +0,0 @@
import { TrkSectionHeader, Trk } from '../lib/Trk';
export function GuideSection() {
return (
<>
<TrkSectionHeader
id="TRK-SEC-guide"
title="예외 처리 · 가이드"
description="공통 패턴에서 벗어날 때의 명시적 규칙"
/>
<Trk id="TRK-GUIDE-fullbleed" className="ds-sample">
<h3 className="text-sm font-semibold text-heading mb-2"> fullBleed를 ?</h3>
<ul className="text-xs text-label space-y-1.5 list-disc list-inside leading-relaxed">
<li>
<strong className="text-heading"> </strong> (LiveMapView,
VesselDetail)
</li>
<li>
<strong className="text-heading">3 </strong> + +
padding이
</li>
<li>
<strong className="text-heading"> 100% </strong>
</li>
</ul>
<p className="text-xs text-hint mt-2">
: <code className="font-mono">&lt;PageContainer fullBleed&gt;</code> .{' '}
<code className="font-mono">-m-4</code>, <code className="font-mono">-mx-4</code> negative margin .
</p>
</Trk>
<Trk id="TRK-GUIDE-classname-override" className="ds-sample mt-3">
<h3 className="text-sm font-semibold text-heading mb-2"> className override를 ?</h3>
<ul className="text-xs text-label space-y-1.5 list-disc list-inside leading-relaxed">
<li>
<strong className="text-heading"> </strong> width/margin (
<code className="font-mono">w-48</code>, <code className="font-mono">flex-1</code> )
</li>
<li>
<strong className="text-heading"> </strong> sm/md/lg
</li>
</ul>
<p className="text-xs text-label mt-2">
<strong className="text-green-400">:</strong>{' '}
<code className="font-mono">
&lt;Badge intent="info" className="w-full justify-center"&gt;
</code>
</p>
<p className="text-xs text-label">
<strong className="text-red-400">:</strong>{' '}
<code className="font-mono">&lt;Badge className="bg-red-500 text-white"&gt;</code>{' '}
<span className="text-hint">intent prop을 </span>
</p>
</Trk>
<Trk id="TRK-GUIDE-dynamic-color" className="ds-sample mt-3">
<h3 className="text-sm font-semibold text-heading mb-2"> hex </h3>
<ul className="text-xs text-label space-y-1.5 list-disc list-inside leading-relaxed">
<li>
DB에서 (: Role.colorHex) <code className="font-mono">style={`{{ background: role.colorHex }}`}</code>{' '}
</li>
<li>
<code className="font-mono">getAlertLevelHex(level)</code> API에서 hex
</li>
<li>
deck.gl RGB , hex
</li>
</ul>
</Trk>
<Trk id="TRK-GUIDE-anti-patterns" className="ds-sample mt-3 border border-red-500/30">
<h3 className="text-sm font-semibold text-red-400 mb-2"> </h3>
<ul className="text-xs text-label space-y-1.5 leading-relaxed">
<li> <code className="font-mono">!important</code> prefix (<code className="font-mono">!bg-red-500</code>)</li>
<li> <code className="font-mono">className="bg-X text-Y"</code> Badge </li>
<li> <code className="font-mono">&lt;button className="bg-blue-600 ..."&gt;</code> Button </li>
<li> <code className="font-mono">&lt;input className="bg-surface ..."&gt;</code> Input </li>
<li> <code className="font-mono">p-4 space-y-5</code> padding PageContainer size </li>
<li> <code className="font-mono">-m-4</code>, <code className="font-mono">-mx-4</code> negative margin fullBleed </li>
<li> <code className="font-mono">const STATUS_COLORS = {`{...}`}</code> shared/constants </li>
<li> <code className="font-mono">date.toLocaleString('ko-KR', ...)</code> <code className="font-mono">formatDateTime</code> </li>
</ul>
</Trk>
<Trk id="TRK-GUIDE-new-page" className="ds-sample mt-3">
<h3 className="text-sm font-semibold text-heading mb-2"> 릿</h3>
<code className="ds-code">
{`import { PageContainer, PageHeader, Section } from '@shared/components/layout';
import { Button } from '@shared/components/ui/button';
import { Input } from '@shared/components/ui/input';
import { Badge } from '@shared/components/ui/badge';
import { getAlertLevelIntent, getAlertLevelLabel } from '@shared/constants/alertLevels';
import { formatDateTime } from '@shared/utils/dateFormat';
import { Shield, Plus } from 'lucide-react';
import { useTranslation } from 'react-i18next';
export function MyNewPage() {
const { t, i18n } = useTranslation('common');
const lang = i18n.language as 'ko' | 'en';
return (
<PageContainer>
<PageHeader
icon={Shield}
iconColor="text-blue-400"
title="새 페이지"
description="페이지 설명"
actions={
<Button variant="primary" icon={<Plus className="w-4 h-4" />}>
</Button>
}
/>
<Section title="데이터 목록">
<Badge intent={getAlertLevelIntent('HIGH')} size="sm">
{getAlertLevelLabel('HIGH', t, lang)}
</Badge>
<span className="text-xs text-hint">{formatDateTime(row.createdAt)}</span>
</Section>
</PageContainer>
);
}`}
</code>
</Trk>
</>
);
}

파일 보기

@ -1,68 +0,0 @@
import { TrkSectionHeader, Trk } from '../lib/Trk';
export function IntroSection() {
return (
<>
<TrkSectionHeader
id="TRK-SEC-intro"
title="KCG Design System"
description="kcg-ai-monitoring 프론트엔드의 시각 언어 · 컴포넌트 · 레이아웃 규칙 단일 참조 페이지"
/>
<Trk id="TRK-INTRO-overview" className="ds-sample">
<h3 className="text-sm font-semibold text-heading mb-2"> </h3>
<ul className="text-xs text-label space-y-1.5 list-disc list-inside leading-relaxed">
<li>
<strong className="text-heading"> </strong>
. `className="bg-red-600"` .
</li>
<li>
<strong className="text-heading"> </strong> · ·
.
</li>
<li>
<strong className="text-heading">ID </strong> <code>TRK-*</code> ID가 ,
· .
</li>
</ul>
</Trk>
<Trk id="TRK-INTRO-howto" className="ds-sample mt-3">
<h3 className="text-sm font-semibold text-heading mb-2"> </h3>
<ol className="text-xs text-label space-y-1.5 list-decimal list-inside leading-relaxed">
<li>
<strong className="text-heading">"ID 복사 모드"</strong> ID가
.
</li>
<li>
URL <code className="font-mono text-blue-400">#trk=TRK-BADGE-critical-sm</code>
+ .
</li>
<li>
<strong className="text-heading">Dark / Light</strong> .
</li>
<li>
. <code>TRK-SEC-*</code> ID가 .
</li>
</ol>
</Trk>
<Trk id="TRK-INTRO-naming" className="ds-sample mt-3">
<h3 className="text-sm font-semibold text-heading mb-2"> ID </h3>
<code className="ds-code">
{`TRK-<카테고리>-<슬러그>[-<변형>]
:
TRK-TOKEN-text-heading --text-heading
TRK-BADGE-critical-sm Badge intent=critical size=sm
TRK-BUTTON-primary-md Button variant=primary size=md
TRK-LAYOUT-page-container PageContainer
TRK-CAT-alert-level-HIGH alertLevels HIGH
TRK-SEC-badge Badge
카테고리: SEC, INTRO, TOKEN, TYPO, BADGE, BUTTON, FORM, CARD, LAYOUT, CAT, GUIDE`}
</code>
</Trk>
</>
);
}

파일 보기

@ -1,177 +0,0 @@
import { TrkSectionHeader, Trk } from '../lib/Trk';
import { PageContainer, PageHeader, Section } from '@shared/components/layout';
import { Button } from '@shared/components/ui/button';
import { Input } from '@shared/components/ui/input';
import { Shield, BarChart3, Plus, Search } from 'lucide-react';
export function LayoutSection() {
return (
<>
<TrkSectionHeader
id="TRK-SEC-layout"
title="Layout 컴포넌트"
description="PageContainer · PageHeader · Section. 40+ 페이지 레이아웃 표준."
/>
{/* PageContainer */}
<h3 className="text-sm font-semibold text-heading mb-2 mt-2">PageContainer</h3>
<div className="ds-grid ds-grid-3">
<Trk id="TRK-LAYOUT-page-container-sm" className="ds-sample">
<label className="text-[10px] text-hint font-mono mb-1 block">size=sm (p-4 space-y-3)</label>
<div className="border border-dashed border-slate-600/40 rounded">
<PageContainer size="sm">
<div className="h-4 bg-blue-500/30 rounded" />
<div className="h-4 bg-blue-500/30 rounded" />
</PageContainer>
</div>
</Trk>
<Trk id="TRK-LAYOUT-page-container-md" className="ds-sample">
<label className="text-[10px] text-hint font-mono mb-1 block">size=md · (p-5 space-y-4)</label>
<div className="border border-dashed border-slate-600/40 rounded">
<PageContainer size="md">
<div className="h-4 bg-blue-500/30 rounded" />
<div className="h-4 bg-blue-500/30 rounded" />
</PageContainer>
</div>
</Trk>
<Trk id="TRK-LAYOUT-page-container-lg" className="ds-sample">
<label className="text-[10px] text-hint font-mono mb-1 block">size=lg (p-6 space-y-4)</label>
<div className="border border-dashed border-slate-600/40 rounded">
<PageContainer size="lg">
<div className="h-4 bg-blue-500/30 rounded" />
<div className="h-4 bg-blue-500/30 rounded" />
</PageContainer>
</div>
</Trk>
</div>
<Trk id="TRK-LAYOUT-page-container-fullbleed" className="ds-sample mt-3">
<label className="text-[10px] text-hint font-mono mb-1 block">
fullBleed / (padding 0, space-y 0)
</label>
<div className="border border-dashed border-slate-600/40 rounded h-16 relative">
<PageContainer fullBleed className="h-full">
<div className="h-full bg-blue-500/30 rounded flex items-center justify-center text-xs text-heading">
fullBleed: 가장자리까지 (LiveMapView / VesselDetail )
</div>
</PageContainer>
</div>
</Trk>
{/* PageHeader */}
<h3 className="text-sm font-semibold text-heading mb-2 mt-6">PageHeader</h3>
<div className="space-y-3">
<Trk id="TRK-LAYOUT-page-header-simple" className="ds-sample">
<label className="text-[10px] text-hint font-mono mb-2 block"> (title + description)</label>
<PageHeader title="대시보드" description="실시간 종합 상황판" />
</Trk>
<Trk id="TRK-LAYOUT-page-header-icon" className="ds-sample">
<label className="text-[10px] text-hint font-mono mb-2 block"> </label>
<PageHeader
icon={Shield}
iconColor="text-blue-400"
title="권한 관리"
description="사용자/역할/권한 설정"
/>
</Trk>
<Trk id="TRK-LAYOUT-page-header-demo" className="ds-sample">
<label className="text-[10px] text-hint font-mono mb-2 block"> </label>
<PageHeader
icon={BarChart3}
iconColor="text-purple-400"
title="AI 모델 관리"
description="ML 모델 배포 상태"
demo
/>
</Trk>
<Trk id="TRK-LAYOUT-page-header-actions" className="ds-sample">
<label className="text-[10px] text-hint font-mono mb-2 block"> </label>
<PageHeader
title="보고서 관리"
description="단속 증거 보고서 목록"
actions={
<>
<Input size="sm" placeholder="검색..." className="w-40" />
<Button variant="secondary" size="sm" icon={<Search className="w-3.5 h-3.5" />}>
</Button>
<Button variant="primary" size="sm" icon={<Plus className="w-3.5 h-3.5" />}>
</Button>
</>
}
/>
</Trk>
</div>
{/* Section */}
<h3 className="text-sm font-semibold text-heading mb-2 mt-6">Section (Card )</h3>
<div className="ds-grid ds-grid-2">
<Trk id="TRK-LAYOUT-section-basic" className="ds-sample">
<label className="text-[10px] text-hint font-mono mb-2 block"> Section</label>
<Section title="해역별 위험도" icon={BarChart3} iconColor="text-orange-400">
<div className="space-y-1.5">
<div className="flex justify-between text-[11px]">
<span className="text-hint"></span>
<span className="text-label">23</span>
</div>
<div className="flex justify-between text-[11px]">
<span className="text-hint"></span>
<span className="text-label">12</span>
</div>
</div>
</Section>
</Trk>
<Trk id="TRK-LAYOUT-section-actions" className="ds-sample">
<label className="text-[10px] text-hint font-mono mb-2 block"> </label>
<Section
title="최근 이벤트"
icon={BarChart3}
actions={
<Button variant="ghost" size="sm">
</Button>
}
>
<div className="text-[11px] text-hint"> 3</div>
</Section>
</Trk>
</div>
{/* 전체 조합 예시 */}
<h3 className="text-sm font-semibold text-heading mb-2 mt-6"> </h3>
<Trk id="TRK-LAYOUT-full-example" className="ds-sample">
<code className="ds-code">
{`import { PageContainer, PageHeader, Section } from '@shared/components/layout';
import { Button } from '@shared/components/ui/button';
import { Shield, Plus } from 'lucide-react';
export function AccessControlPage() {
return (
<PageContainer>
<PageHeader
icon={Shield}
iconColor="text-blue-400"
title="권한 관리"
description="사용자별 역할 및 권한 매핑"
actions={
<Button variant="primary" icon={<Plus className="w-4 h-4" />}>
</Button>
}
/>
<Section title="사용자 목록">
<DataTable ... />
</Section>
</PageContainer>
);
}`}
</code>
</Trk>
</>
);
}

Some files were not shown because too many files have changed in this diff Show More