Compare commits
1 커밋
main
...
feature/sf
| 작성자 | SHA1 | 날짜 | |
|---|---|---|---|
|
|
353c960c3f |
2
.gitignore
vendored
2
.gitignore
vendored
@ -5,7 +5,6 @@ backend/target/
|
|||||||
backend/build/
|
backend/build/
|
||||||
|
|
||||||
# === Python (prediction) ===
|
# === Python (prediction) ===
|
||||||
.venv/
|
|
||||||
prediction/.venv/
|
prediction/.venv/
|
||||||
prediction/__pycache__/
|
prediction/__pycache__/
|
||||||
prediction/**/__pycache__/
|
prediction/**/__pycache__/
|
||||||
@ -55,7 +54,6 @@ frontend/.vite/
|
|||||||
|
|
||||||
# === 대용량/참고 문서 ===
|
# === 대용량/참고 문서 ===
|
||||||
*.hwpx
|
*.hwpx
|
||||||
*.docx
|
|
||||||
|
|
||||||
# === Claude Code ===
|
# === Claude Code ===
|
||||||
!.claude/
|
!.claude/
|
||||||
|
|||||||
173
CLAUDE.md
173
CLAUDE.md
@ -2,45 +2,6 @@
|
|||||||
|
|
||||||
해양경찰청 AI 기반 불법어선 탐지 및 단속 지원 플랫폼
|
해양경찰청 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)
|
├── frontend/ # React 19 + TypeScript + Vite (UI)
|
||||||
├── backend/ # Spring Boot 3.x + Java 21 (인증/권한/감사 + 분석 API)
|
├── backend/ # Spring Boot 3.x + Java 21 (인증/권한/감사 + 분석 API)
|
||||||
├── prediction/ # Python 3.9 + FastAPI (AIS 분석 엔진, 5분 주기)
|
├── prediction/ # Python 3.9 + FastAPI (AIS 분석 엔진, 5분 주기)
|
||||||
├── database/ # PostgreSQL 마이그레이션 (Flyway V001~V016, 48 테이블)
|
├── database/ # PostgreSQL 마이그레이션 (Flyway V001~V013)
|
||||||
│ └── migration/
|
│ └── migration/
|
||||||
├── deploy/ # 배포 가이드 + 서버 설정 문서
|
├── deploy/ # 배포 가이드 + 서버 설정 문서
|
||||||
├── docs/ # 프로젝트 문서 (SFR, 아키텍처)
|
├── docs/ # 프로젝트 문서 (SFR, 아키텍처)
|
||||||
@ -63,14 +24,14 @@ kcg-ai-monitoring/
|
|||||||
```
|
```
|
||||||
[Frontend Vite :5173] ──→ [Backend Spring :8080] ──→ [PostgreSQL kcgaidb]
|
[Frontend Vite :5173] ──→ [Backend Spring :8080] ──→ [PostgreSQL kcgaidb]
|
||||||
↑ write
|
↑ write
|
||||||
[Prediction FastAPI :18092] ─────┘ (5분 주기 분석 결과 저장)
|
[Prediction FastAPI :8001] ──────┘ (5분 주기 분석 결과 저장)
|
||||||
↑ read
|
↑ read ↑ read
|
||||||
[SNPDB PostgreSQL] (AIS 원본)
|
[SNPDB PostgreSQL] (AIS 원본) [Iran Backend] (레거시 프록시, 선택)
|
||||||
```
|
```
|
||||||
|
|
||||||
- **자체 백엔드**: 인증/권한/감사로그/관리자 + 운영자 의사결정 (확정/제외/학습) + prediction 분석 결과 조회 API (`/api/analysis/*`)
|
- **자체 백엔드**: 인증/권한/감사로그/관리자 + 운영자 의사결정 (확정/제외/학습)
|
||||||
- **Prediction**: AIS → 분석 결과를 kcgaidb 에 직접 write (백엔드 호출 없음)
|
- **iran 백엔드 프록시**: 분석 결과 read-only 참조 (vessel_analysis, group_polygons, correlations)
|
||||||
- **DB 공유 아키텍처**: 백엔드와 prediction 은 HTTP 호출 없이 kcgaidb 를 통해서만 연동
|
- **신규 DB (kcgaidb)**: 자체 생산 데이터만 저장, prediction 분석 테이블은 미복사
|
||||||
|
|
||||||
## 명령어
|
## 명령어
|
||||||
|
|
||||||
@ -140,126 +101,6 @@ make format # 프론트 prettier
|
|||||||
- 커밋: Conventional Commits (한국어), `.githooks/commit-msg` 검증
|
- 커밋: Conventional Commits (한국어), `.githooks/commit-msg` 검증
|
||||||
- pre-commit: `frontend/` 디렉토리 기준 TypeScript + ESLint 검증
|
- 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 뷰어 (개발 단계용)
|
## System Flow 뷰어 (개발 단계용)
|
||||||
|
|
||||||
- **URL**: https://kcg-ai-monitoring.gc-si.dev/system-flow.html (메인 SPA와 별개)
|
- **URL**: https://kcg-ai-monitoring.gc-si.dev/system-flow.html (메인 SPA와 별개)
|
||||||
|
|||||||
@ -12,7 +12,7 @@ Phase 2에서 초기화 예정.
|
|||||||
## 책임
|
## 책임
|
||||||
- 자체 인증/권한/감사로그
|
- 자체 인증/권한/감사로그
|
||||||
- 운영자 의사결정 (모선 확정/제외/학습)
|
- 운영자 의사결정 (모선 확정/제외/학습)
|
||||||
- prediction 분석 결과 조회 API (`/api/analysis/*`)
|
- iran 백엔드 분석 데이터 프록시
|
||||||
- 관리자 화면 API
|
- 관리자 화면 API
|
||||||
|
|
||||||
상세 설계: `.claude/plans/vast-tinkering-knuth.md`
|
상세 설계: `.claude/plans/vast-tinkering-knuth.md`
|
||||||
|
|||||||
@ -142,7 +142,6 @@
|
|||||||
<goal>compile</goal>
|
<goal>compile</goal>
|
||||||
</goals>
|
</goals>
|
||||||
<configuration>
|
<configuration>
|
||||||
<parameters>true</parameters>
|
|
||||||
<annotationProcessorPaths>
|
<annotationProcessorPaths>
|
||||||
<path>
|
<path>
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
@ -162,7 +161,6 @@
|
|||||||
<goal>testCompile</goal>
|
<goal>testCompile</goal>
|
||||||
</goals>
|
</goals>
|
||||||
<configuration>
|
<configuration>
|
||||||
<parameters>true</parameters>
|
|
||||||
<annotationProcessorPaths>
|
<annotationProcessorPaths>
|
||||||
<path>
|
<path>
|
||||||
<groupId>org.projectlombok</groupId>
|
<groupId>org.projectlombok</groupId>
|
||||||
|
|||||||
@ -1,11 +1,17 @@
|
|||||||
package gc.mda.kcg.admin;
|
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 gc.mda.kcg.permission.annotation.RequirePermission;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
import java.util.LinkedHashMap;
|
||||||
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -22,23 +28,127 @@ import java.util.Map;
|
|||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class AdminStatsController {
|
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")
|
@GetMapping("/audit")
|
||||||
@RequirePermission(resource = "admin:audit-logs", operation = "READ")
|
@RequirePermission(resource = "admin:audit-logs", operation = "READ")
|
||||||
public Map<String, Object> auditStats() {
|
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")
|
@GetMapping("/access")
|
||||||
@RequirePermission(resource = "admin:access-logs", operation = "READ")
|
@RequirePermission(resource = "admin:access-logs", operation = "READ")
|
||||||
public Map<String, Object> accessStats() {
|
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")
|
@GetMapping("/login")
|
||||||
@RequirePermission(resource = "admin:login-history", operation = "READ")
|
@RequirePermission(resource = "admin:login-history", operation = "READ")
|
||||||
public Map<String, Object> loginStats() {
|
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.dto.UserInfoResponse;
|
||||||
import gc.mda.kcg.auth.provider.AuthProvider;
|
import gc.mda.kcg.auth.provider.AuthProvider;
|
||||||
import gc.mda.kcg.config.AppProperties;
|
import gc.mda.kcg.config.AppProperties;
|
||||||
import gc.mda.kcg.menu.MenuConfigService;
|
|
||||||
import jakarta.servlet.http.Cookie;
|
import jakarta.servlet.http.Cookie;
|
||||||
import jakarta.servlet.http.HttpServletRequest;
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
import jakarta.servlet.http.HttpServletResponse;
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
@ -26,7 +25,6 @@ public class AuthController {
|
|||||||
private final AuthService authService;
|
private final AuthService authService;
|
||||||
private final JwtService jwtService;
|
private final JwtService jwtService;
|
||||||
private final AppProperties appProperties;
|
private final AppProperties appProperties;
|
||||||
private final MenuConfigService menuConfigService;
|
|
||||||
|
|
||||||
@PostMapping("/login")
|
@PostMapping("/login")
|
||||||
public ResponseEntity<?> login(@RequestBody LoginRequest req,
|
public ResponseEntity<?> login(@RequestBody LoginRequest req,
|
||||||
@ -97,8 +95,7 @@ public class AuthController {
|
|||||||
u.getUserSttsCd(),
|
u.getUserSttsCd(),
|
||||||
u.getAuthProvider(),
|
u.getAuthProvider(),
|
||||||
info.roles(),
|
info.roles(),
|
||||||
info.permissions(),
|
info.permissions()
|
||||||
menuConfigService.getActiveMenuConfig()
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,7 +1,5 @@
|
|||||||
package gc.mda.kcg.auth.dto;
|
package gc.mda.kcg.auth.dto;
|
||||||
|
|
||||||
import gc.mda.kcg.menu.MenuConfigDto;
|
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
@ -14,6 +12,5 @@ public record UserInfoResponse(
|
|||||||
String status,
|
String status,
|
||||||
String authProvider,
|
String authProvider,
|
||||||
List<String> roles,
|
List<String> roles,
|
||||||
Map<String, List<String>> permissions,
|
Map<String, List<String>> permissions
|
||||||
List<MenuConfigDto> menuConfig
|
|
||||||
) {}
|
) {}
|
||||||
|
|||||||
@ -12,7 +12,7 @@ import org.springframework.context.annotation.Configuration;
|
|||||||
public class AppProperties {
|
public class AppProperties {
|
||||||
|
|
||||||
private Prediction prediction = new Prediction();
|
private Prediction prediction = new Prediction();
|
||||||
private SignalBatch signalBatch = new SignalBatch();
|
private IranBackend iranBackend = new IranBackend();
|
||||||
private Cors cors = new Cors();
|
private Cors cors = new Cors();
|
||||||
private Jwt jwt = new Jwt();
|
private Jwt jwt = new Jwt();
|
||||||
|
|
||||||
@ -22,7 +22,7 @@ public class AppProperties {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Getter @Setter
|
@Getter @Setter
|
||||||
public static class SignalBatch {
|
public static class IranBackend {
|
||||||
private String baseUrl;
|
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 gc.mda.kcg.permission.annotation.RequirePermission;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
import org.springframework.beans.factory.annotation.Qualifier;
|
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.client.RestClient;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
import org.springframework.web.client.RestClientException;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Prediction FastAPI 서비스 프록시.
|
* Prediction (Python FastAPI) 서비스 프록시.
|
||||||
* 기본 baseUrl: app.prediction.base-url (개발 http://localhost:8001, 운영 http://192.168.1.19:18092)
|
* 현재는 stub - Phase 5에서 실 연결.
|
||||||
*
|
|
||||||
* 엔드포인트:
|
|
||||||
* 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=
|
|
||||||
*/
|
*/
|
||||||
@Slf4j
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/prediction")
|
@RequestMapping("/api/prediction")
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class PredictionProxyController {
|
public class PredictionProxyController {
|
||||||
|
|
||||||
@Qualifier("predictionRestClient")
|
private final IranBackendClient iranClient;
|
||||||
private final RestClient predictionClient;
|
|
||||||
|
|
||||||
@GetMapping("/health")
|
@GetMapping("/health")
|
||||||
public ResponseEntity<?> health() {
|
public ResponseEntity<?> health() {
|
||||||
return proxyGet("/health", Map.of(
|
Map<String, Object> data = iranClient.getJson("/api/prediction/health");
|
||||||
"status", "DISCONNECTED",
|
if (data == null) {
|
||||||
"message", "Prediction 서비스 미연결"
|
return ResponseEntity.ok(Map.of(
|
||||||
));
|
"status", "DISCONNECTED",
|
||||||
|
"message", "Prediction 서비스 미연결 (Phase 5에서 연결 예정)"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
return ResponseEntity.ok(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/status")
|
@GetMapping("/status")
|
||||||
@RequirePermission(resource = "monitoring", operation = "READ")
|
@RequirePermission(resource = "monitoring", operation = "READ")
|
||||||
public ResponseEntity<?> status() {
|
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")
|
@PostMapping("/trigger")
|
||||||
@RequirePermission(resource = "ai-operations:mlops", operation = "UPDATE")
|
@RequirePermission(resource = "ai-operations:mlops", operation = "UPDATE")
|
||||||
public ResponseEntity<?> trigger() {
|
public ResponseEntity<?> trigger() {
|
||||||
return proxyPost("/api/v1/analysis/trigger", null,
|
return ResponseEntity.ok(Map.of("ok", false, "message", "Prediction 서비스 미연결"));
|
||||||
Map.of("ok", false, "message", "Prediction 서비스 미연결"));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* AI 채팅 프록시 (POST) — Phase 9에서 실 연결.
|
* AI 채팅 프록시 (POST).
|
||||||
|
* 향후 prediction 인증 통과 후 SSE 스트리밍으로 전환.
|
||||||
*/
|
*/
|
||||||
@PostMapping("/chat")
|
@PostMapping("/chat")
|
||||||
@RequirePermission(resource = "ai-operations:ai-assistant", operation = "READ")
|
@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(
|
return ResponseEntity.ok(Map.of(
|
||||||
"ok", false,
|
"ok", false,
|
||||||
"serviceAvailable", false,
|
"serviceAvailable", false,
|
||||||
"message", "Prediction 채팅 인증 연동 대기 중 (Phase 9에서 활성화 예정). 입력: "
|
"message", "Prediction 채팅 인증 연동 대기 중 (Phase 9에서 활성화 예정). 입력: " + body.getOrDefault("message", "")
|
||||||
+ 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;
|
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 gc.mda.kcg.permission.annotation.RequirePermission;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
|
||||||
import org.springframework.beans.factory.annotation.Qualifier;
|
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.*;
|
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.*;
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 분석 데이터 API — group_polygon_snapshots / gear_correlation_scores 직접 DB 조회
|
* iran 백엔드 분석 데이터 프록시 + 자체 DB 운영자 결정 합성 (HYBRID).
|
||||||
* + signal-batch 선박 항적 프록시.
|
|
||||||
*
|
*
|
||||||
* 라우팅:
|
* 라우팅:
|
||||||
* GET /api/vessel-analysis/groups → 어구/선단 그룹 + parentResolution 합성
|
* GET /api/vessel-analysis → 전체 분석결과 + 통계 (단순 프록시)
|
||||||
* GET /api/vessel-analysis/groups/{key}/detail → 단일 그룹 상세 + 24h 이력
|
* GET /api/vessel-analysis/groups → 어구/선단 그룹 + parentResolution 합성
|
||||||
|
* GET /api/vessel-analysis/groups/{key}/detail → 단일 그룹 상세
|
||||||
* GET /api/vessel-analysis/groups/{key}/correlations → 상관관계 점수
|
* GET /api/vessel-analysis/groups/{key}/correlations → 상관관계 점수
|
||||||
*
|
*
|
||||||
* 권한: detection:gear-detection (READ)
|
* 권한: detection / detection:gear-detection (READ)
|
||||||
*/
|
*/
|
||||||
@Slf4j
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequestMapping("/api/vessel-analysis")
|
@RequestMapping("/api/vessel-analysis")
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class VesselAnalysisProxyController {
|
public class VesselAnalysisProxyController {
|
||||||
|
|
||||||
private final VesselAnalysisGroupService groupService;
|
private final IranBackendClient iranClient;
|
||||||
|
private final ParentResolutionRepository resolutionRepository;
|
||||||
@Qualifier("signalBatchRestClient")
|
|
||||||
private final RestClient signalBatchClient;
|
|
||||||
|
|
||||||
@GetMapping
|
@GetMapping
|
||||||
@RequirePermission(resource = "detection:dark-vessel", operation = "READ")
|
@RequirePermission(resource = "detection", operation = "READ")
|
||||||
public ResponseEntity<?> getVesselAnalysis() {
|
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(
|
return ResponseEntity.ok(Map.of(
|
||||||
"serviceAvailable", true,
|
"serviceAvailable", false,
|
||||||
"message", "vessel_analysis_results는 /api/analysis/vessels 에서 제공됩니다.",
|
"message", "iran 백엔드 미연결",
|
||||||
"items", List.of(),
|
"items", List.of(),
|
||||||
"stats", Map.of(),
|
"stats", Map.of(),
|
||||||
"count", 0
|
"count", 0
|
||||||
));
|
));
|
||||||
|
}
|
||||||
|
// 통과 + 메타데이터 추가
|
||||||
|
Map<String, Object> enriched = new LinkedHashMap<>(data);
|
||||||
|
enriched.put("serviceAvailable", true);
|
||||||
|
return ResponseEntity.ok(enriched);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 그룹 목록 + 자체 DB의 parentResolution 합성.
|
* 그룹 목록 + 자체 DB의 parentResolution 합성.
|
||||||
|
* 각 그룹에 resolution 필드 추가.
|
||||||
*/
|
*/
|
||||||
@GetMapping("/groups")
|
@GetMapping("/groups")
|
||||||
@RequirePermission(resource = "detection:gear-detection", operation = "READ")
|
@RequirePermission(resource = "detection:gear-detection", operation = "READ")
|
||||||
public ResponseEntity<?> getGroups(
|
public ResponseEntity<?> getGroups() {
|
||||||
@RequestParam(required = false) String groupType
|
Map<String, Object> data = iranClient.getJson("/api/vessel-analysis/groups");
|
||||||
) {
|
if (data == null) {
|
||||||
Map<String, Object> result = groupService.getGroups(groupType);
|
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);
|
return ResponseEntity.ok(result);
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/groups/{groupKey}/detail")
|
@GetMapping("/groups/{groupKey}/detail")
|
||||||
@RequirePermission(resource = "detection:gear-detection", operation = "READ")
|
@RequirePermission(resource = "detection:gear-detection", operation = "READ")
|
||||||
public ResponseEntity<?> getGroupDetail(@PathVariable String groupKey) {
|
public ResponseEntity<?> getGroupDetail(@PathVariable String groupKey) {
|
||||||
Map<String, Object> result = groupService.getGroupDetail(groupKey);
|
Map<String, Object> data = iranClient.getJson("/api/vessel-analysis/groups/" + groupKey + "/detail");
|
||||||
return ResponseEntity.ok(result);
|
if (data == null) {
|
||||||
|
return ResponseEntity.ok(Map.of("serviceAvailable", false, "groupKey", groupKey));
|
||||||
|
}
|
||||||
|
return ResponseEntity.ok(data);
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/groups/{groupKey}/correlations")
|
@GetMapping("/groups/{groupKey}/correlations")
|
||||||
@ -73,57 +112,12 @@ public class VesselAnalysisProxyController {
|
|||||||
@PathVariable String groupKey,
|
@PathVariable String groupKey,
|
||||||
@RequestParam(required = false) Double minScore
|
@RequestParam(required = false) Double minScore
|
||||||
) {
|
) {
|
||||||
Map<String, Object> result = groupService.getGroupCorrelations(groupKey, minScore);
|
String path = "/api/vessel-analysis/groups/" + groupKey + "/correlations";
|
||||||
return ResponseEntity.ok(result);
|
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));
|
||||||
* 후보 상세 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("[]");
|
|
||||||
}
|
}
|
||||||
|
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")
|
@RequirePermission(resource = "enforcement:enforcement-history", operation = "READ")
|
||||||
public Page<EnforcementRecord> listRecords(
|
public Page<EnforcementRecord> listRecords(
|
||||||
@RequestParam(required = false) String violationType,
|
@RequestParam(required = false) String violationType,
|
||||||
@RequestParam(required = false) String vesselMmsi,
|
|
||||||
Pageable pageable
|
Pageable pageable
|
||||||
) {
|
) {
|
||||||
return service.listRecords(violationType, vesselMmsi, pageable);
|
return service.listRecords(violationType, pageable);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
package gc.mda.kcg.domain.enforcement;
|
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.CreatePlanRequest;
|
||||||
import gc.mda.kcg.domain.enforcement.dto.CreateRecordRequest;
|
import gc.mda.kcg.domain.enforcement.dto.CreateRecordRequest;
|
||||||
import gc.mda.kcg.domain.enforcement.dto.UpdateRecordRequest;
|
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) {
|
public Page<EnforcementRecord> listRecords(String violationType, Pageable pageable) {
|
||||||
if (vesselMmsi != null && !vesselMmsi.isBlank()) {
|
|
||||||
return recordRepository.findByVesselMmsiOrderByEnforcedAtDesc(vesselMmsi, pageable);
|
|
||||||
}
|
|
||||||
if (violationType != null && !violationType.isBlank()) {
|
if (violationType != null && !violationType.isBlank()) {
|
||||||
return recordRepository.findByViolationType(violationType, pageable);
|
return recordRepository.findByViolationType(violationType, pageable);
|
||||||
}
|
}
|
||||||
@ -49,7 +45,6 @@ public class EnforcementService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@Auditable(action = "ENFORCEMENT_CREATE", resourceType = "ENFORCEMENT")
|
|
||||||
public EnforcementRecord createRecord(CreateRecordRequest req) {
|
public EnforcementRecord createRecord(CreateRecordRequest req) {
|
||||||
EnforcementRecord record = EnforcementRecord.builder()
|
EnforcementRecord record = EnforcementRecord.builder()
|
||||||
.enfUid(generateEnfUid())
|
.enfUid(generateEnfUid())
|
||||||
@ -89,7 +84,6 @@ public class EnforcementService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@Auditable(action = "ENFORCEMENT_UPDATE", resourceType = "ENFORCEMENT")
|
|
||||||
public EnforcementRecord updateRecord(Long id, UpdateRecordRequest req) {
|
public EnforcementRecord updateRecord(Long id, UpdateRecordRequest req) {
|
||||||
EnforcementRecord record = getRecord(id);
|
EnforcementRecord record = getRecord(id);
|
||||||
if (req.result() != null) record.setResult(req.result());
|
if (req.result() != null) record.setResult(req.result());
|
||||||
@ -110,7 +104,6 @@ public class EnforcementService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@Auditable(action = "ENFORCEMENT_PLAN_CREATE", resourceType = "ENFORCEMENT")
|
|
||||||
public EnforcementPlan createPlan(CreatePlanRequest req) {
|
public EnforcementPlan createPlan(CreatePlanRequest req) {
|
||||||
EnforcementPlan plan = EnforcementPlan.builder()
|
EnforcementPlan plan = EnforcementPlan.builder()
|
||||||
.planUid("PLN-" + LocalDate.now().format(UID_DATE_FMT) + "-" + UUID.randomUUID().toString().substring(0, 4).toUpperCase())
|
.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> {
|
public interface EnforcementRecordRepository extends JpaRepository<EnforcementRecord, Long> {
|
||||||
Page<EnforcementRecord> findAllByOrderByEnforcedAtDesc(Pageable pageable);
|
Page<EnforcementRecord> findAllByOrderByEnforcedAtDesc(Pageable pageable);
|
||||||
Page<EnforcementRecord> findByViolationType(String violationType, 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 gc.mda.kcg.permission.annotation.RequirePermission;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.data.domain.PageRequest;
|
import org.springframework.data.domain.PageRequest;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 알림 조회 API.
|
* 알림 조회 API.
|
||||||
* 예측 이벤트에 대해 발송된 알림(SMS, 푸시 등) 이력을 제공.
|
* 예측 이벤트에 대해 발송된 알림(SMS, 푸시 등) 이력을 제공.
|
||||||
@ -14,7 +17,7 @@ import org.springframework.web.bind.annotation.*;
|
|||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class AlertController {
|
public class AlertController {
|
||||||
|
|
||||||
private final AlertService alertService;
|
private final PredictionAlertRepository alertRepository;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 알림 목록 조회 (페이징). eventId 파라미터로 특정 이벤트의 알림만 필터 가능.
|
* 알림 목록 조회 (페이징). eventId 파라미터로 특정 이벤트의 알림만 필터 가능.
|
||||||
@ -27,8 +30,10 @@ public class AlertController {
|
|||||||
@RequestParam(defaultValue = "20") int size
|
@RequestParam(defaultValue = "20") int size
|
||||||
) {
|
) {
|
||||||
if (eventId != null) {
|
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.annotations.JdbcTypeCode;
|
||||||
import org.hibernate.type.SqlTypes;
|
import org.hibernate.type.SqlTypes;
|
||||||
|
|
||||||
import com.fasterxml.jackson.annotation.JsonIgnore;
|
|
||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.time.OffsetDateTime;
|
import java.time.OffsetDateTime;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
@ -46,7 +44,6 @@ public class PredictionAlert {
|
|||||||
@Column(columnDefinition = "jsonb")
|
@Column(columnDefinition = "jsonb")
|
||||||
private Map<String, Object> metadata;
|
private Map<String, Object> metadata;
|
||||||
|
|
||||||
@JsonIgnore
|
|
||||||
@ManyToOne(fetch = FetchType.LAZY)
|
@ManyToOne(fetch = FetchType.LAZY)
|
||||||
@JoinColumn(name = "event_id", insertable = false, updatable = false)
|
@JoinColumn(name = "event_id", insertable = false, updatable = false)
|
||||||
private PredictionEvent event;
|
private PredictionEvent event;
|
||||||
|
|||||||
@ -7,7 +7,6 @@ import org.hibernate.type.SqlTypes;
|
|||||||
|
|
||||||
import java.math.BigDecimal;
|
import java.math.BigDecimal;
|
||||||
import java.time.OffsetDateTime;
|
import java.time.OffsetDateTime;
|
||||||
import java.util.Map;
|
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -94,10 +93,6 @@ public class PredictionEvent {
|
|||||||
@Column(name = "dedup_key", length = 200)
|
@Column(name = "dedup_key", length = 200)
|
||||||
private String dedupKey;
|
private String dedupKey;
|
||||||
|
|
||||||
@JdbcTypeCode(SqlTypes.JSON)
|
|
||||||
@Column(name = "features", columnDefinition = "jsonb")
|
|
||||||
private Map<String, Object> features;
|
|
||||||
|
|
||||||
@Column(name = "created_at", nullable = false)
|
@Column(name = "created_at", nullable = false)
|
||||||
private OffsetDateTime createdAt;
|
private OffsetDateTime createdAt;
|
||||||
|
|
||||||
|
|||||||
@ -16,8 +16,8 @@ import java.time.OffsetDateTime;
|
|||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 모선 워크플로우 핵심 서비스.
|
* 모선 워크플로우 핵심 서비스 (HYBRID).
|
||||||
* - 후보 데이터: prediction이 kcgaidb 에 저장한 분석 결과를 참조
|
* - 후보 데이터: iran 백엔드 API 호출 (현재 stub)
|
||||||
* - 운영자 결정: 자체 DB (gear_group_parent_resolution 등)
|
* - 운영자 결정: 자체 DB (gear_group_parent_resolution 등)
|
||||||
*
|
*
|
||||||
* 모든 쓰기 액션은 @Auditable로 감사로그 자동 기록.
|
* 모든 쓰기 액션은 @Auditable로 감사로그 자동 기록.
|
||||||
|
|||||||
@ -10,7 +10,7 @@ import java.util.UUID;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* 모선 확정 결과 (운영자 의사결정).
|
* 모선 확정 결과 (운영자 의사결정).
|
||||||
* prediction이 생성한 후보 데이터와 별도로 운영자 결정만 자체 DB에 저장.
|
* iran 백엔드의 후보 데이터(prediction이 생성)와 별도로 운영자 결정만 자체 DB에 저장.
|
||||||
*/
|
*/
|
||||||
@Entity
|
@Entity
|
||||||
@Table(name = "gear_group_parent_resolution", schema = "kcg",
|
@Table(name = "gear_group_parent_resolution", schema = "kcg",
|
||||||
@ -34,27 +34,6 @@ public class ParentResolution {
|
|||||||
@Column(name = "selected_parent_mmsi", length = 20)
|
@Column(name = "selected_parent_mmsi", length = 20)
|
||||||
private String selectedParentMmsi;
|
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)
|
@Column(name = "rejected_candidate_mmsi", length = 20)
|
||||||
private String rejectedCandidateMmsi;
|
private String rejectedCandidateMmsi;
|
||||||
|
|
||||||
|
|||||||
@ -27,7 +27,7 @@ public class StatsController {
|
|||||||
* 실시간 KPI 전체 목록 조회
|
* 실시간 KPI 전체 목록 조회
|
||||||
*/
|
*/
|
||||||
@GetMapping("/kpi")
|
@GetMapping("/kpi")
|
||||||
@RequirePermission(resource = "statistics:statistics", operation = "READ")
|
@RequirePermission(resource = "statistics", operation = "READ")
|
||||||
public List<PredictionKpi> getKpi() {
|
public List<PredictionKpi> getKpi() {
|
||||||
return kpiRepository.findAll();
|
return kpiRepository.findAll();
|
||||||
}
|
}
|
||||||
@ -38,7 +38,7 @@ public class StatsController {
|
|||||||
* @param to 종료 월 (예: 2026-04)
|
* @param to 종료 월 (예: 2026-04)
|
||||||
*/
|
*/
|
||||||
@GetMapping("/monthly")
|
@GetMapping("/monthly")
|
||||||
@RequirePermission(resource = "statistics:statistics", operation = "READ")
|
@RequirePermission(resource = "statistics", operation = "READ")
|
||||||
public List<PredictionStatsMonthly> getMonthly(
|
public List<PredictionStatsMonthly> getMonthly(
|
||||||
@RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate from,
|
@RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate from,
|
||||||
@RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate to
|
@RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate to
|
||||||
@ -52,7 +52,7 @@ public class StatsController {
|
|||||||
* @param to 종료 날짜 (예: 2026-04-07)
|
* @param to 종료 날짜 (예: 2026-04-07)
|
||||||
*/
|
*/
|
||||||
@GetMapping("/daily")
|
@GetMapping("/daily")
|
||||||
@RequirePermission(resource = "statistics:statistics", operation = "READ")
|
@RequirePermission(resource = "statistics", operation = "READ")
|
||||||
public List<PredictionStatsDaily> getDaily(
|
public List<PredictionStatsDaily> getDaily(
|
||||||
@RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate from,
|
@RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate from,
|
||||||
@RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate to
|
@RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate to
|
||||||
@ -65,7 +65,7 @@ public class StatsController {
|
|||||||
* @param hours 조회 시간 범위 (기본 24시간)
|
* @param hours 조회 시간 범위 (기본 24시간)
|
||||||
*/
|
*/
|
||||||
@GetMapping("/hourly")
|
@GetMapping("/hourly")
|
||||||
@RequirePermission(resource = "statistics:statistics", operation = "READ")
|
@RequirePermission(resource = "statistics", operation = "READ")
|
||||||
public List<PredictionStatsHourly> getHourly(
|
public List<PredictionStatsHourly> getHourly(
|
||||||
@RequestParam(defaultValue = "24") int hours
|
@RequestParam(defaultValue = "24") int hours
|
||||||
) {
|
) {
|
||||||
|
|||||||
@ -4,7 +4,9 @@ import gc.mda.kcg.permission.annotation.RequirePermission;
|
|||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.data.domain.Page;
|
import org.springframework.data.domain.Page;
|
||||||
import org.springframework.data.domain.PageRequest;
|
import org.springframework.data.domain.PageRequest;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.web.bind.annotation.*;
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
import org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@ -16,7 +18,10 @@ import java.util.List;
|
|||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class MasterDataController {
|
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")
|
@GetMapping("/api/codes")
|
||||||
public List<CodeMaster> listCodes(@RequestParam String group) {
|
public List<CodeMaster> listCodes(@RequestParam String group) {
|
||||||
return masterDataService.listCodes(group);
|
return codeMasterRepository.findByGroupCodeAndIsActiveTrueOrderBySortOrder(group);
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/api/codes/{codeId}/children")
|
@GetMapping("/api/codes/{codeId}/children")
|
||||||
public List<CodeMaster> listChildren(@PathVariable String codeId) {
|
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")
|
@GetMapping("/api/gear-types")
|
||||||
public List<GearType> listGearTypes() {
|
public List<GearType> listGearTypes() {
|
||||||
return masterDataService.listGearTypes();
|
return gearTypeRepository.findByIsActiveTrueOrderByDisplayOrder();
|
||||||
}
|
}
|
||||||
|
|
||||||
@GetMapping("/api/gear-types/{gearCode}")
|
@GetMapping("/api/gear-types/{gearCode}")
|
||||||
public GearType getGearType(@PathVariable String 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")
|
@PostMapping("/api/gear-types")
|
||||||
@RequirePermission(resource = "admin:system-config", operation = "CREATE")
|
@RequirePermission(resource = "admin:system-config", operation = "CREATE")
|
||||||
public GearType createGearType(@RequestBody GearType gearType) {
|
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}")
|
@PutMapping("/api/gear-types/{gearCode}")
|
||||||
@RequirePermission(resource = "admin:system-config", operation = "UPDATE")
|
@RequirePermission(resource = "admin:system-config", operation = "UPDATE")
|
||||||
public GearType updateGearType(@PathVariable String gearCode, @RequestBody GearType gearType) {
|
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")
|
@GetMapping("/api/patrol-ships")
|
||||||
@RequirePermission(resource = "patrol:patrol-route", operation = "READ")
|
@RequirePermission(resource = "patrol", operation = "READ")
|
||||||
public List<PatrolShip> listPatrolShips() {
|
public List<PatrolShip> listPatrolShips() {
|
||||||
return masterDataService.listPatrolShips();
|
return patrolShipRepository.findByIsActiveTrueOrderByShipCode();
|
||||||
}
|
}
|
||||||
|
|
||||||
@PatchMapping("/api/patrol-ships/{id}/status")
|
@PatchMapping("/api/patrol-ships/{id}/status")
|
||||||
@RequirePermission(resource = "patrol:patrol-route", operation = "UPDATE")
|
@RequirePermission(resource = "patrol", operation = "UPDATE")
|
||||||
public PatrolShip updatePatrolShipStatus(
|
public PatrolShip updatePatrolShipStatus(
|
||||||
@PathVariable Long id,
|
@PathVariable Long id,
|
||||||
@RequestBody PatrolShipStatusRequest request
|
@RequestBody PatrolShipStatusRequest request
|
||||||
) {
|
) {
|
||||||
return masterDataService.updatePatrolShipStatus(id, new MasterDataService.PatrolShipStatusCommand(
|
PatrolShip ship = patrolShipRepository.findById(id)
|
||||||
request.status(), request.lat(), request.lon(), request.zoneCode(), request.fuelPct()
|
.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")
|
@GetMapping("/api/vessel-permits")
|
||||||
|
@RequirePermission(resource = "vessel", operation = "READ")
|
||||||
public Page<VesselPermit> listVesselPermits(
|
public Page<VesselPermit> listVesselPermits(
|
||||||
@RequestParam(required = false) String flag,
|
@RequestParam(required = false) String flag,
|
||||||
@RequestParam(required = false) String permitStatus,
|
@RequestParam(required = false) String permitStatus,
|
||||||
@RequestParam(defaultValue = "0") int page,
|
@RequestParam(defaultValue = "0") int page,
|
||||||
@RequestParam(defaultValue = "20") int size
|
@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}")
|
@GetMapping("/api/vessel-permits/{mmsi}")
|
||||||
|
@RequirePermission(resource = "vessel", operation = "READ")
|
||||||
public VesselPermit getVesselPermit(@PathVariable String mmsi) {
|
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 jakarta.persistence.*;
|
||||||
import lombok.*;
|
import lombok.*;
|
||||||
import org.hibernate.annotations.JdbcTypeCode;
|
|
||||||
import org.hibernate.type.SqlTypes;
|
|
||||||
|
|
||||||
import java.time.OffsetDateTime;
|
import java.time.OffsetDateTime;
|
||||||
|
|
||||||
@ -41,29 +39,6 @@ public class PermTree {
|
|||||||
@Column(name = "use_yn", nullable = false, length = 1)
|
@Column(name = "use_yn", nullable = false, length = 1)
|
||||||
private String useYn;
|
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)
|
@Column(name = "created_at", nullable = false)
|
||||||
private OffsetDateTime createdAt;
|
private OffsetDateTime createdAt;
|
||||||
|
|
||||||
@ -78,7 +53,6 @@ public class PermTree {
|
|||||||
if (useYn == null) useYn = "Y";
|
if (useYn == null) useYn = "Y";
|
||||||
if (sortOrd == null) sortOrd = 0;
|
if (sortOrd == null) sortOrd = 0;
|
||||||
if (rsrcLevel == null) rsrcLevel = 0;
|
if (rsrcLevel == null) rsrcLevel = 0;
|
||||||
if (navSort == null) navSort = 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@PreUpdate
|
@PreUpdate
|
||||||
|
|||||||
@ -47,16 +47,15 @@ public class PermTreeController {
|
|||||||
List<Role> roles = roleRepository.findAllByOrderByRoleSnAsc();
|
List<Role> roles = roleRepository.findAllByOrderByRoleSnAsc();
|
||||||
return roles.stream().<Map<String, Object>>map(r -> {
|
return roles.stream().<Map<String, Object>>map(r -> {
|
||||||
List<Perm> perms = permRepository.findByRoleSn(r.getRoleSn());
|
List<Perm> perms = permRepository.findByRoleSn(r.getRoleSn());
|
||||||
java.util.Map<String, Object> result = new java.util.LinkedHashMap<>();
|
return Map.of(
|
||||||
result.put("roleSn", r.getRoleSn());
|
"roleSn", r.getRoleSn(),
|
||||||
result.put("roleCd", r.getRoleCd());
|
"roleCd", r.getRoleCd(),
|
||||||
result.put("roleNm", r.getRoleNm());
|
"roleNm", r.getRoleNm(),
|
||||||
result.put("roleDc", r.getRoleDc() == null ? "" : r.getRoleDc());
|
"roleDc", r.getRoleDc() == null ? "" : r.getRoleDc(),
|
||||||
result.put("colorHex", r.getColorHex());
|
"dfltYn", r.getDfltYn(),
|
||||||
result.put("dfltYn", r.getDfltYn());
|
"builtinYn", r.getBuiltinYn(),
|
||||||
result.put("builtinYn", r.getBuiltinYn());
|
"permissions", perms
|
||||||
result.put("permissions", perms);
|
);
|
||||||
return result;
|
|
||||||
}).toList();
|
}).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -28,10 +28,6 @@ public class Role {
|
|||||||
@Column(name = "role_dc", columnDefinition = "text")
|
@Column(name = "role_dc", columnDefinition = "text")
|
||||||
private String roleDc;
|
private String roleDc;
|
||||||
|
|
||||||
/** 역할 UI 표기 색상 (#RRGGBB). NULL 가능 — 프론트가 기본 팔레트 사용. */
|
|
||||||
@Column(name = "color_hex", length = 7)
|
|
||||||
private String colorHex;
|
|
||||||
|
|
||||||
@Column(name = "dflt_yn", nullable = false, length = 1)
|
@Column(name = "dflt_yn", nullable = false, length = 1)
|
||||||
private String dfltYn;
|
private String dfltYn;
|
||||||
|
|
||||||
|
|||||||
@ -38,7 +38,6 @@ public class RoleManagementService {
|
|||||||
.roleCd(req.roleCd().toUpperCase())
|
.roleCd(req.roleCd().toUpperCase())
|
||||||
.roleNm(req.roleNm())
|
.roleNm(req.roleNm())
|
||||||
.roleDc(req.roleDc())
|
.roleDc(req.roleDc())
|
||||||
.colorHex(req.colorHex())
|
|
||||||
.dfltYn("Y".equalsIgnoreCase(req.dfltYn()) ? "Y" : "N")
|
.dfltYn("Y".equalsIgnoreCase(req.dfltYn()) ? "Y" : "N")
|
||||||
.builtinYn("N")
|
.builtinYn("N")
|
||||||
.build();
|
.build();
|
||||||
@ -71,7 +70,6 @@ public class RoleManagementService {
|
|||||||
}
|
}
|
||||||
if (req.roleNm() != null) role.setRoleNm(req.roleNm());
|
if (req.roleNm() != null) role.setRoleNm(req.roleNm());
|
||||||
if (req.roleDc() != null) role.setRoleDc(req.roleDc());
|
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");
|
if (req.dfltYn() != null) role.setDfltYn("Y".equalsIgnoreCase(req.dfltYn()) ? "Y" : "N");
|
||||||
return roleRepository.save(role);
|
return roleRepository.save(role);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,6 +6,5 @@ public record RoleCreateRequest(
|
|||||||
@NotBlank String roleCd,
|
@NotBlank String roleCd,
|
||||||
@NotBlank String roleNm,
|
@NotBlank String roleNm,
|
||||||
String roleDc,
|
String roleDc,
|
||||||
String colorHex,
|
|
||||||
String dfltYn
|
String dfltYn
|
||||||
) {}
|
) {}
|
||||||
|
|||||||
@ -3,6 +3,5 @@ package gc.mda.kcg.permission.dto;
|
|||||||
public record RoleUpdateRequest(
|
public record RoleUpdateRequest(
|
||||||
String roleNm,
|
String roleNm,
|
||||||
String roleDc,
|
String roleDc,
|
||||||
String colorHex,
|
|
||||||
String dfltYn
|
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:
|
cache:
|
||||||
type: caffeine
|
type: caffeine
|
||||||
cache-names: permissions,users,menuConfig
|
cache-names: permissions,users
|
||||||
caffeine:
|
caffeine:
|
||||||
spec: maximumSize=1000,expireAfterWrite=10m
|
spec: maximumSize=1000,expireAfterWrite=10m
|
||||||
|
|
||||||
server:
|
server:
|
||||||
port: 8080
|
port: 8080
|
||||||
forward-headers-strategy: framework
|
forward-headers-strategy: framework
|
||||||
compression:
|
|
||||||
enabled: true
|
|
||||||
min-response-size: 1024
|
|
||||||
mime-types: application/json,application/xml,text/html,text/plain
|
|
||||||
|
|
||||||
management:
|
management:
|
||||||
endpoints:
|
endpoints:
|
||||||
@ -64,8 +60,9 @@ logging:
|
|||||||
app:
|
app:
|
||||||
prediction:
|
prediction:
|
||||||
base-url: ${PREDICTION_BASE_URL:http://localhost:8001}
|
base-url: ${PREDICTION_BASE_URL:http://localhost:8001}
|
||||||
signal-batch:
|
iran-backend:
|
||||||
base-url: ${SIGNAL_BATCH_BASE_URL:http://192.168.1.18:18090/signal-batch}
|
# 운영 환경: https://kcg.gc-si.dev (Spring Boot + Prediction 통합)
|
||||||
|
base-url: ${IRAN_BACKEND_BASE_URL:https://kcg.gc-si.dev}
|
||||||
cors:
|
cors:
|
||||||
allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:5173,http://localhost:5174}
|
allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:5173,http://localhost:5174}
|
||||||
jwt:
|
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
|
# 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 표준 위치를 따르므로 SQL 파일은 백엔드 모듈 안에 있습니다.
|
||||||
> Spring Boot 기동 시 Flyway가 자동으로 적용합니다.
|
> Spring Boot 기동 시 Flyway가 자동으로 적용합니다.
|
||||||
@ -10,208 +10,37 @@
|
|||||||
- **User**: `kcg-app`
|
- **User**: `kcg-app`
|
||||||
- **Schema**: `kcg`
|
- **Schema**: `kcg`
|
||||||
- **Host**: `211.208.115.83:5432`
|
- **Host**: `211.208.115.83:5432`
|
||||||
- **현재 버전**: v022 (2026-04-09)
|
|
||||||
|
|
||||||
---
|
## 적용된 마이그레이션 (V001~V013)
|
||||||
|
|
||||||
## 마이그레이션 히스토리 (V001~V022)
|
### Phase 1~8: 인증/권한/감사 (V001~V007)
|
||||||
|
|
||||||
Flyway 마이그레이션은 **증분 방식** — 각 파일은 이전 버전에 대한 변경(ALTER/INSERT/CREATE)만 포함합니다.
|
|
||||||
V001이 처음 테이블을 만들고, 이후 파일들이 컬럼 추가·시드 INSERT·신규 테이블 생성 등을 수행합니다.
|
|
||||||
|
|
||||||
### 인증/권한/감사 (V001~V007)
|
|
||||||
|
|
||||||
| 파일 | 내용 |
|
| 파일 | 내용 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `V001__auth_init.sql` | auth_user, auth_org, auth_role, auth_user_role, auth_login_hist, auth_setting |
|
| `V001__auth_init.sql` | 인증/조직/역할/사용자-역할/로그인 이력 |
|
||||||
| `V002__perm_tree.sql` | auth_perm_tree (권한 트리) + auth_perm (권한 매트릭스) |
|
| `V002__perm_tree.sql` | 권한 트리 + 권한 매트릭스 |
|
||||||
| `V003__perm_seed.sql` | 역할 5종 시드 + 트리 노드 47개 + 역할별 권한 매트릭스 |
|
| `V003__perm_seed.sql` | 초기 역할 5종 + 트리 노드 45개 + 권한 매트릭스 시드 |
|
||||||
| `V004__access_logs.sql` | auth_audit_log + auth_access_log |
|
| `V004__access_logs.sql` | 감사로그/접근이력 |
|
||||||
| `V005__parent_workflow.sql` | gear_group_parent_resolution, review_log, exclusions, label_sessions |
|
| `V005__parent_workflow.sql` | 모선 워크플로우 (resolution/review_log/exclusions/label_sessions) |
|
||||||
| `V006__demo_accounts.sql` | 데모 계정 5종 (admin/operator/analyst/field/viewer) |
|
| `V006__demo_accounts.sql` | 데모 계정 5종 |
|
||||||
| `V007__perm_tree_label_align.sql` | 트리 노드 명칭 일치 조정 |
|
| `V007__perm_tree_label_align.sql` | 트리 노드 명칭을 사이드바 i18n 라벨과 일치 |
|
||||||
|
|
||||||
### 마스터 데이터 (V008~V011)
|
### S1: 마스터 데이터 + Prediction 기반 (V008~V013)
|
||||||
|
|
||||||
| 파일 | 내용 |
|
| 파일 | 내용 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `V008__code_master.sql` | code_master (계층형 72코드: 위반유형/이벤트/단속 등) |
|
| `V008__code_master.sql` | 계층형 코드 마스터 (12그룹, 72코드: 위반유형/이벤트/단속/허가/함정 등) |
|
||||||
| `V009__gear_type_master.sql` | gear_type_master 6종 (어구 분류 룰 + 합법성 기준) |
|
| `V009__gear_type_master.sql` | 어구 유형 마스터 6종 (분류 룰 + 합법성 기준) |
|
||||||
| `V010__zone_polygon_master.sql` | zone_polygon_master (PostGIS, 8개 해역 시드) |
|
| `V010__zone_polygon_master.sql` | 해역 폴리곤 마스터 (PostGIS GEOMETRY, 8개 해역 시드) |
|
||||||
| `V011__vessel_permit_patrol.sql` | vessel_permit_master(9척) + patrol_ship_master(6척) + fleet_companies(2개) |
|
| `V011__vessel_permit_patrol.sql` | 어선 허가 마스터 + 함정 마스터 + fleet_companies (선박 9척, 함정 6척) |
|
||||||
|
| `V012__prediction_events_stats.sql` | vessel_analysis_results(파티션) + 이벤트 허브 + 알림 + 통계(시/일/월) + KPI + 위험격자 + 학습피드백 |
|
||||||
### Prediction 분석 (V012~V015)
|
| `V013__enforcement_operations.sql` | 단속 이력/계획 + 함정 배치 + AI모델 버전/메트릭 (시드 포함) |
|
||||||
|
|
||||||
| 파일 | 내용 |
|
|
||||||
|---|---|
|
|
||||||
| `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)
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 실행 방법
|
## 실행 방법
|
||||||
|
|
||||||
### 최초 1회 - DB/사용자 생성 (관리자 권한 필요)
|
### 최초 1회 - DB/사용자 생성 (관리자 권한 필요)
|
||||||
```sql
|
```sql
|
||||||
|
-- snp 관리자 계정으로 접속
|
||||||
psql -h 211.208.115.83 -U snp -d postgres
|
psql -h 211.208.115.83 -U snp -d postgres
|
||||||
|
|
||||||
CREATE DATABASE kcgaidb;
|
CREATE DATABASE kcgaidb;
|
||||||
@ -232,11 +61,7 @@ cd backend && ./mvnw spring-boot:run
|
|||||||
|
|
||||||
### 수동 적용
|
### 수동 적용
|
||||||
```bash
|
```bash
|
||||||
cd backend && ./mvnw flyway:migrate \
|
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
|
||||||
-Dflyway.url=jdbc:postgresql://211.208.115.83:5432/kcgaidb \
|
|
||||||
-Dflyway.user=kcg-app \
|
|
||||||
-Dflyway.password=Kcg2026ai \
|
|
||||||
-Dflyway.schemas=kcg
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Checksum 불일치 시 (마이그레이션 파일 수정 후)
|
### 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` 형식으로 추가하면 다음 기동 시 자동 적용됩니다.
|
[`backend/src/main/resources/db/migration/`](../../backend/src/main/resources/db/migration/)에 `V00N__설명.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"}'
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|||||||
@ -31,7 +31,7 @@
|
|||||||
| 서비스 | systemd | 포트 | 로그 |
|
| 서비스 | systemd | 포트 | 로그 |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| kcg-ai-prediction | `kcg-ai-prediction.service` | 18092 | `journalctl -u kcg-ai-prediction -f` |
|
| 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` |
|
| 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 |
|
| 443 | nginx (HTTPS) | rocky-211 |
|
||||||
| 18080 | kcg-ai-backend (Spring Boot) | rocky-211 |
|
| 18080 | kcg-ai-backend (Spring Boot) | rocky-211 |
|
||||||
| 18092 | kcg-ai-prediction (FastAPI) | redis-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 |
|
| 18091 | kcg-prediction-lab | redis-211 |
|
||||||
| 5432 | PostgreSQL (kcgaidb, snpdb) | 211.208.115.83 |
|
| 5432 | PostgreSQL (kcgaidb, snpdb) | 211.208.115.83 |
|
||||||
| 6379 | Redis | redis-211 |
|
| 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/.env` | prediction 환경변수 |
|
||||||
| `/home/apps/kcg-ai-prediction/venv/` | Python 3.9 가상환경 |
|
| `/home/apps/kcg-ai-prediction/venv/` | Python 3.9 가상환경 |
|
||||||
| `/etc/systemd/system/kcg-ai-prediction.service` | prediction systemd 서비스 |
|
| `/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) |
|
| `/home/apps/kcg-prediction-lab/` | 기존 lab prediction (포트 18091) |
|
||||||
|
|||||||
@ -4,339 +4,13 @@
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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`) — 시스템 전체 데이터 흐름 시각화
|
- System Flow 뷰어 (`/system-flow.html`) — 시스템 전체 데이터 흐름 시각화
|
||||||
- 102 노드 + 133 엣지, 10개 카테고리 매니페스트
|
- 102 노드 + 133 엣지, 10개 카테고리 매니페스트
|
||||||
- stage/menu 두 가지 그룹화 토글, 검색/필터/딥링크 지원
|
- stage/menu 두 가지 그룹화 토글, 검색/필터/딥링크 지원
|
||||||
- 포커스 모드 (1-hop 연결 노드만 활성화, 나머지 dim)
|
|
||||||
- 메인 SPA와 분리된 별도 entry, 산출문서 노드 ID 참조용
|
- 메인 SPA와 분리된 별도 entry, 산출문서 노드 ID 참조용
|
||||||
- `/version` 스킬 사후 처리로 manifest version 자동 동기화
|
- `/version` 스킬 사후 처리로 manifest version 자동 동기화
|
||||||
- CI/CD에서 버전별 스냅샷을 서버 archive에 영구 보존
|
- 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]
|
## [2026-04-07]
|
||||||
|
|
||||||
|
|||||||
@ -50,7 +50,6 @@ src/
|
|||||||
│ ├── i18n/ # 10 NS (common, dashboard, detection, patrol, enforcement, statistics, ai, fieldOps, admin, auth)
|
│ ├── i18n/ # 10 NS (common, dashboard, detection, patrol, enforcement, statistics, ai, fieldOps, admin, auth)
|
||||||
│ │ ├── config.ts # i18next 초기화 (ko 기본, en 폴백)
|
│ │ ├── config.ts # i18next 초기화 (ko 기본, en 폴백)
|
||||||
│ │ └── locales/ # ko/*.json, en/*.json (10파일 x 2언어)
|
│ │ └── 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)
|
│ └── theme/ # tokens, colors, variants (CVA)
|
||||||
│ ├── tokens.ts # CSS 변수 매핑 + resolved 색상값
|
│ ├── tokens.ts # CSS 변수 매핑 + resolved 색상값
|
||||||
│ ├── colors.ts # 시맨틱 팔레트 (risk, alert, vessel, status, chartSeries)
|
│ ├── colors.ts # 시맨틱 팔레트 (risk, alert, vessel, status, chartSeries)
|
||||||
@ -90,28 +89,20 @@ src/
|
|||||||
│ ├── ws.ts # connectWs (STOMP 스텁, 미구현)
|
│ ├── ws.ts # connectWs (STOMP 스텁, 미구현)
|
||||||
│ └── index.ts # 배럴 export
|
│ └── index.ts # 배럴 export
|
||||||
│
|
│
|
||||||
├── shared/components/ # 공유 UI 컴포넌트 (design-system.html SSOT)
|
├── shared/components/ # 공유 UI 컴포넌트
|
||||||
│ ├── ui/ # 9개 공통 컴포넌트 (2026-04-17 모든 화면 SSOT 준수 완료)
|
│ ├── ui/
|
||||||
│ │ ├── card.tsx # Card(CVA variant), CardHeader, CardTitle, CardContent (4 variant)
|
│ │ ├── card.tsx # Card(CVA variant), CardHeader, CardTitle, CardContent
|
||||||
│ │ ├── badge.tsx # Badge(CVA intent 8종 × size 4단계, LEGACY_MAP 변형 호환)
|
│ │ └── badge.tsx # Badge(CVA intent/size)
|
||||||
│ │ ├── 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 (표준 페이지 루트)
|
|
||||||
│ └── common/
|
│ └── common/
|
||||||
│ ├── DataTable.tsx # 범용 테이블 (가변너비, 검색, 정렬, 페이징, 엑셀, 출력)
|
│ ├── DataTable.tsx # 범용 테이블 (가변너비, 검색, 정렬, 페이징, 엑셀, 출력)
|
||||||
│ ├── Pagination.tsx # 페이지네이션
|
│ ├── Pagination.tsx # 페이지네이션
|
||||||
│ ├── SearchInput.tsx # 검색 입력 (i18n 통합)
|
│ ├── SearchInput.tsx # 검색 입력
|
||||||
│ ├── ExcelExport.tsx # 엑셀 다운로드
|
│ ├── ExcelExport.tsx # 엑셀 다운로드
|
||||||
│ ├── FileUpload.tsx # 파일 업로드
|
│ ├── FileUpload.tsx # 파일 업로드
|
||||||
│ ├── PageToolbar.tsx # 페이지 상단 툴바
|
│ ├── PageToolbar.tsx # 페이지 상단 툴바
|
||||||
│ ├── PrintButton.tsx # 인쇄 버튼
|
│ ├── PrintButton.tsx # 인쇄 버튼
|
||||||
│ ├── SaveButton.tsx # 저장 버튼
|
│ ├── SaveButton.tsx # 저장 버튼
|
||||||
│ └── NotificationBanner.tsx # 알림 배너 (common.aria.closeNotification)
|
│ └── NotificationBanner.tsx # 알림 배너
|
||||||
│
|
│
|
||||||
├── features/ # 13 도메인 그룹 (31 페이지)
|
├── features/ # 13 도메인 그룹 (31 페이지)
|
||||||
│ ├── dashboard/ # 종합 대시보드 (Dashboard)
|
│ ├── dashboard/ # 종합 대시보드 (Dashboard)
|
||||||
|
|||||||
252
docs/data-sharing-analysis.md
Normal file
252
docs/data-sharing-analysis.md
Normal file
@ -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
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
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 요구사항별 화면 사용 가이드
|
# SFR 요구사항별 화면 사용 가이드
|
||||||
|
|
||||||
> **문서 작성일:** 2026-04-06
|
> **문서 작성일:** 2026-04-06
|
||||||
> **최종 업데이트:** 2026-04-17 (2026-04-17 릴리즈 기준)
|
> **시스템 버전:** v0.1.0 (프로토타입)
|
||||||
> **시스템 버전:** 운영 배포 (rocky-211 + redis-211)
|
|
||||||
> **다국어:** 한국어/영어 전환 지원 (헤더 우측 EN/한국어 버튼)
|
> **다국어:** 한국어/영어 전환 지원 (헤더 우측 EN/한국어 버튼)
|
||||||
> **테마:** 다크/라이트 전환 지원 (헤더 우측 해/달 아이콘 버튼)
|
> **테마:** 다크/라이트 전환 지원 (헤더 우측 해/달 아이콘 버튼)
|
||||||
|
|
||||||
@ -12,12 +11,7 @@
|
|||||||
|
|
||||||
이 문서는 **KCG AI 모니터링 시스템**의 각 SFR(소프트웨어 기능 요구사항)이 화면에서 어떻게 구현되어 있는지를 **비개발자**(일반 사용자, 사업 PM, 산출물 작성자)가 이해할 수 있도록 정리한 가이드입니다.
|
이 문서는 **KCG AI 모니터링 시스템**의 각 SFR(소프트웨어 기능 요구사항)이 화면에서 어떻게 구현되어 있는지를 **비개발자**(일반 사용자, 사업 PM, 산출물 작성자)가 이해할 수 있도록 정리한 가이드입니다.
|
||||||
|
|
||||||
### 시스템 현황 (2026-04-17 기준)
|
현재 시스템은 **프로토타입 단계(v0.1.0)**로, 모든 SFR의 UI가 완성되어 있으나 백엔드 서버 연동은 아직 이루어지지 않았습니다. 화면에 표시되는 데이터는 시연용 샘플 데이터입니다.
|
||||||
- **프런트엔드·백엔드·분석엔진(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 참조
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -61,18 +55,17 @@
|
|||||||
- 역할별 데모 계정 선택 (ADMIN, OPERATOR, ANALYST, FIELD, VIEWER)
|
- 역할별 데모 계정 선택 (ADMIN, OPERATOR, ANALYST, FIELD, VIEWER)
|
||||||
- 로그인 후 역할에 따른 메뉴 접근 제어
|
- 로그인 후 역할에 따른 메뉴 접근 제어
|
||||||
|
|
||||||
**구현 완료 (2026-04-17 기준):**
|
**구현 완료:**
|
||||||
- ✅ 로그인 화면 UI + 자체 ID/PW 인증 + JWT 쿠키 세션 + 역할별 데모 계정 5종 실 로그인
|
- ✅ 로그인 화면 UI 및 데모 계정 5종 로그인 기능
|
||||||
- ✅ 비밀번호 정책(9자 이상 영문+숫자+특수) + 5회 실패 30분 잠금 + BCrypt 해시
|
- ✅ 역할 기반 세션 유지 및 메뉴 접근 제어
|
||||||
- ✅ 트리 기반 RBAC (47 리소스 노드, Level 0 13개 + Level 1 32개, 5 operation) + Caffeine 10분 TTL
|
|
||||||
- ✅ 모든 로그인 시도 감사 로그 저장 및 조회 (로그인 이력 화면)
|
|
||||||
- ✅ 역할 기반 세션 유지 및 메뉴 접근 제어 (사이드바/라우트 가드)
|
|
||||||
|
|
||||||
**향후 구현 예정 (기업 환경 연동):**
|
**향후 구현 예정:**
|
||||||
- 🔲 SSO(해양경찰 통합인증) 연동
|
- 🔲 SSO(Single Sign-On) 연동
|
||||||
- 🔲 GPKI(정부 공인인증서) 인증 연동
|
- 🔲 GPKI(정부 공인인증서) 인증 연동
|
||||||
- 🔲 공무원증 기반 인증 연동
|
- 🔲 실제 사용자 DB 연동 및 비밀번호 암호화
|
||||||
- 🔲 인사 시스템 연동 역할 자동 부여
|
|
||||||
|
**보완 필요:**
|
||||||
|
- ⚠️ 현재 데모 계정은 하드코딩되어 있으며, 운영 환경에서는 실제 인증 체계로 대체 필요
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -90,17 +83,16 @@
|
|||||||
- 역할별 접근 가능 메뉴 및 기능 권한 설정
|
- 역할별 접근 가능 메뉴 및 기능 권한 설정
|
||||||
- 사용자 목록 조회 및 역할 할당
|
- 사용자 목록 조회 및 역할 할당
|
||||||
|
|
||||||
**구현 완료 (2026-04-17 기준):**
|
**구현 완료:**
|
||||||
- ✅ 트리 기반 RBAC 실 운영 — 47 리소스 노드 × 5 operation (READ/CREATE/UPDATE/DELETE/EXPORT) × 다중 역할 OR 합집합
|
- ✅ RBAC 5역할 체계 UI 및 역할별 권한 매트릭스 표시
|
||||||
- ✅ 역할별 권한 매트릭스 시각화 (셀 클릭 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)
|
|
||||||
|
|
||||||
**향후 구현 예정:**
|
**향후 구현 예정:**
|
||||||
- 🔲 권한 변경 이력 UI (auth_audit_log 조회는 현재 별도 화면)
|
- 🔲 실제 사용자 DB 연동을 통한 권한 CRUD
|
||||||
- 🔲 역할 템플릿 복제 기능
|
- 🔲 감사 로그(권한 변경 이력) 기록
|
||||||
|
|
||||||
|
**보완 필요:**
|
||||||
|
- ⚠️ 현재 화면의 데이터는 샘플이며 실제 저장/반영되지 않음
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -377,18 +369,17 @@ AIS(선박자동식별장치) 신호를 의도적으로 끈 의심 선박(Dark V
|
|||||||
- 의심 선박 상세 프로필 및 이동 궤적 조회
|
- 의심 선박 상세 프로필 및 이동 궤적 조회
|
||||||
- 위험도 등급별 분류 표시
|
- 위험도 등급별 분류 표시
|
||||||
|
|
||||||
**구현 완료 (2026-04-17 기준):**
|
**구현 완료:**
|
||||||
- ✅ **AI 분석 엔진(prediction) 5분 주기 실시간 탐지 결과 표시** — snpdb AIS 원천 데이터 기반
|
- ✅ 의심 선박 7척 목록/지도 시각화
|
||||||
- ✅ Dark Vessel 11패턴 기반 0~100점 연속 점수 + 4단계 tier(CRITICAL≥70 / HIGH≥50 / WATCH≥30 / NONE)
|
- ✅ 5가지 행동 패턴 분석 결과 UI
|
||||||
- ✅ DarkDetailPanel — 선박 선택 시 ScoreBreakdown으로 P1~P11 각 패턴별 기여도 표시
|
|
||||||
- ✅ 지도 기반 실시간 위치 + tier별 색상 구분 (라이트/다크 모드 대응)
|
|
||||||
- ✅ 최근 1시간 / 중국 선박(MMSI 412*) 필터, MMSI/선박명/패턴 검색
|
|
||||||
- ✅ 특이운항 미니맵 (24h 궤적 + DARK/SPOOFING/TRANSSHIP/GEAR_VIOLATION/HIGH_RISK 구간 병합 하이라이트)
|
|
||||||
|
|
||||||
**향후 구현 예정:**
|
**향후 구현 예정:**
|
||||||
- 🔲 spoofing_score 산출 재설계 (중국 MID 412 선박 전원 0 수렴 이슈, BD-09 필터 + teleport 25kn 임계 재검토)
|
- 🔲 AI Dark Vessel 탐지 엔진 연동
|
||||||
|
- 🔲 실시간 AIS 데이터 분석 연동
|
||||||
- 🔲 SAR(위성영상) 기반 탐지 연동
|
- 🔲 SAR(위성영상) 기반 탐지 연동
|
||||||
- 🔲 과거 이력 차트 (현재는 최근 24h 중심)
|
|
||||||
|
**보완 필요:**
|
||||||
|
- ⚠️ 현재 탐지 결과는 샘플 데이터이며, AI 탐지 엔진 연동 후 실시간 탐지 결과로 교체 필요
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -407,17 +398,16 @@ AIS(선박자동식별장치) 신호를 의도적으로 끈 의심 선박(Dark V
|
|||||||
- 해역별 중국 어선 밀집도 분석
|
- 해역별 중국 어선 밀집도 분석
|
||||||
- 시계열 활동 패턴 분석
|
- 시계열 활동 패턴 분석
|
||||||
|
|
||||||
**구현 완료 (2026-04-17 기준):**
|
**구현 완료:**
|
||||||
- ✅ **3개 탭(AI 감시 대시보드 / 환적접촉탐지 / 어구·어망 판별) 전부 실데이터 연동** — `/api/analysis/*` 경유, MMSI prefix `412` 고정
|
- ✅ 중국 어선 분석 종합 대시보드 UI
|
||||||
- ✅ 중국 선박 전체 분석 결과 실시간 그리드 (최근 1h, 위험도순 상위 200건)
|
- ✅ 지도 기반 활동 현황 시각화
|
||||||
- ✅ 특이운항 판별 — riskScore ≥ 40 상위 목록 + 선박 클릭 시 24h 궤적 미니맵 + 판별 구간 패널
|
|
||||||
- ✅ 해역별 통항량 + 안전도 분석 (종합 위험/안전 지수) + 위험도 도넛
|
|
||||||
- ✅ 자동탐지 결과(어구 판별 탭) row 클릭 시 상단 입력 폼 자동 프리필
|
|
||||||
|
|
||||||
**향후 구현 예정:**
|
**향후 구현 예정:**
|
||||||
- 🔲 관심영역 / VIIRS 위성영상 / 기상 예보 / VTS연계 (현재 "데모 데이터" 배지)
|
- 🔲 AI 탐지 엔진 연동 (Dark Vessel + 어구 탐지 통합)
|
||||||
- 🔲 비허가 / 제재 / 관심 선박 탭 데이터 소스 연동 (현재 "준비중" 배지)
|
- 🔲 실시간 데이터 기반 분석 갱신
|
||||||
- 🔲 월별 집계 API 연동 (현재 통계 탭 "준비중")
|
|
||||||
|
**보완 필요:**
|
||||||
|
- ⚠️ 현재 분석 데이터는 샘플이며, 실제 탐지 엔진 연동 필요
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -436,17 +426,17 @@ AIS(선박자동식별장치) 신호를 의도적으로 끈 의심 선박(Dark V
|
|||||||
- 탐지 결과 상세 정보 (위치, 크기, 어구 유형, 위험도)
|
- 탐지 결과 상세 정보 (위치, 크기, 어구 유형, 위험도)
|
||||||
- 탐지 이미지 확인
|
- 탐지 이미지 확인
|
||||||
|
|
||||||
**구현 완료 (2026-04-17 기준):**
|
**구현 완료:**
|
||||||
- ✅ **DAR-03 G-01~G-06 실시간 탐지 결과** — prediction 5분 주기 + 한중어업협정 906척 레지스트리(V029) 매칭 53%+
|
- ✅ 어구 6건 탐지 결과 목록/지도 UI
|
||||||
- ✅ 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·교차 검증 파이프라인
|
|
||||||
|
|
||||||
**향후 구현 예정:**
|
**향후 구현 예정:**
|
||||||
- 🔲 영상(CCTV/SAR) 기반 어구 자동 분류
|
- 🔲 AI 어구 탐지 모델 연동 (영상 분석 기반)
|
||||||
- 🔲 한·중 어구 5종 구조 비교 이미지 라이브러리 확장
|
- 🔲 실시간 CCTV/SAR 영상 분석 연동
|
||||||
|
- 🔲 탐지 결과 자동 분류 및 알림
|
||||||
|
|
||||||
|
**보완 필요:**
|
||||||
|
- ⚠️ 현재 탐지 결과는 샘플 데이터이며, AI 탐지 모델 연동 후 실제 탐지 결과로 교체 필요
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -465,17 +455,17 @@ AIS(선박자동식별장치) 신호를 의도적으로 끈 의심 선박(Dark V
|
|||||||
- 이력 상세 정보 조회 및 검색/필터
|
- 이력 상세 정보 조회 및 검색/필터
|
||||||
- 이력 데이터 엑셀 내보내기
|
- 이력 데이터 엑셀 내보내기
|
||||||
|
|
||||||
**구현 완료 (2026-04-17 기준):**
|
**구현 완료:**
|
||||||
- ✅ **실시간 이벤트 조회** — `/api/events` 페이징/필터/확인(ACK)/상태 변경
|
- ✅ 단속 이력 6건 목록/상세 UI
|
||||||
- ✅ **단속 이력 CRUD** — `/api/enforcement/records` (GET/POST/PATCH) + ENF-yyyyMMdd-NNNN UID 자동 발급
|
- ✅ AI 매칭 검증 결과 표시
|
||||||
- ✅ 이벤트 발생 → 확인 → 단속 등록 → 오탐 처리 워크플로우 (액션 버튼 4종)
|
|
||||||
- ✅ 모든 쓰기 액션 `auth_audit_log` 자동 기록 (ENFORCEMENT_CREATE / ENFORCEMENT_UPDATE / ACK_EVENT / UPDATE_EVENT_STATUS)
|
|
||||||
- ✅ KPI 카운트 (CRITICAL/HIGH/MEDIUM/LOW) + 엑셀 내보내기 + 출력
|
|
||||||
- ✅ 단속 완료 시 prediction_events.status 자동 RESOLVED 갱신
|
|
||||||
|
|
||||||
**향후 구현 예정:**
|
**향후 구현 예정:**
|
||||||
- 🔲 증거 파일(사진/영상) 업로드 서버 연동
|
- 🔲 단속 이력 DB 연동 (조회/등록/수정)
|
||||||
- 🔲 AI 매칭 검증 정량 지표 (탐지↔단속 confusion matrix)
|
- 🔲 AI 매칭 검증 엔진 연동
|
||||||
|
- 🔲 탐지-단속 연계 자동 분석
|
||||||
|
|
||||||
|
**보완 필요:**
|
||||||
|
- ⚠️ 현재 이력 데이터는 샘플이며, DB 연동 후 실제 단속 데이터로 교체 필요
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -497,15 +487,17 @@ AIS(선박자동식별장치) 신호를 의도적으로 끈 의심 선박(Dark V
|
|||||||
- 함정 배치 현황 요약
|
- 함정 배치 현황 요약
|
||||||
- 실시간 경보 알림 표시
|
- 실시간 경보 알림 표시
|
||||||
|
|
||||||
**구현 완료 (2026-04-17 기준):**
|
**구현 완료:**
|
||||||
- ✅ **실시간 KPI 카드** — `/api/stats/kpi` 연동, prediction 5분 주기 결과 기반
|
- ✅ KPI 카드 + 히트맵 + 타임라인 + 함정 현황 통합 대시보드 UI
|
||||||
- ✅ 실시간 상황 타임라인 — 최근 `prediction_events` 스트림 (긴급/경고 카운트 실시간)
|
- ✅ 반응형 레이아웃 (화면 크기에 따른 자동 배치)
|
||||||
- ✅ 함정 배치 현황 + 경보 알림 + 순찰 현황 통합
|
|
||||||
- ✅ 라이트/다크 모드 반응형 (2026-04-17 PR #C 하드코딩 색상 제거)
|
|
||||||
|
|
||||||
**향후 구현 예정:**
|
**향후 구현 예정:**
|
||||||
- 🔲 WebSocket 기반 실시간 push (현재는 주기 polling)
|
- 🔲 실시간 데이터 연동 (WebSocket 등)
|
||||||
- 🔲 맞춤형 대시보드 레이아웃 (드래그/리사이즈)
|
- 🔲 KPI 수치 실시간 갱신
|
||||||
|
- 🔲 히트맵/타임라인 실시간 업데이트
|
||||||
|
|
||||||
|
**보완 필요:**
|
||||||
|
- ⚠️ 현재 모든 수치는 샘플 데이터이며, 실시간 연동 후 정확한 운영 데이터로 교체 필요
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -524,15 +516,17 @@ AIS(선박자동식별장치) 신호를 의도적으로 끈 의심 선박(Dark V
|
|||||||
- 경보 처리(확인/대응/종결) 워크플로우
|
- 경보 처리(확인/대응/종결) 워크플로우
|
||||||
- 경보 발생 이력 조회
|
- 경보 발생 이력 조회
|
||||||
|
|
||||||
**구현 완료 (2026-04-17 기준):**
|
**구현 완료:**
|
||||||
- ✅ **실시간 경보 수신** — `/api/events` + `/api/alerts` 실 API 연동, prediction event_generator 4룰 기반
|
- ✅ 경보 등급별 현황판 UI
|
||||||
- ✅ 경보 등급별(CRITICAL/HIGH/MEDIUM/LOW) 현황 + KPI 카운트
|
- ✅ 경보 목록/상세 조회 화면
|
||||||
- ✅ 경보 처리 워크플로우 — 확인(ACK) → 단속 등록 → 오탐 처리 (DB 저장 + `auth_audit_log` 기록)
|
|
||||||
- ✅ 시스템 상태 패널 (백엔드/AI 분석 엔진/DB 상태 실시간 표시, 30초 자동 갱신)
|
|
||||||
|
|
||||||
**향후 구현 예정:**
|
**향후 구현 예정:**
|
||||||
- 🔲 경보 자동 에스컬레이션 정책
|
- 🔲 실시간 경보 수신 연동
|
||||||
- 🔲 경보 룰 커스터마이즈 UI
|
- 🔲 경보 처리 워크플로우 DB 연동
|
||||||
|
- 🔲 경보 자동 에스컬레이션
|
||||||
|
|
||||||
|
**보완 필요:**
|
||||||
|
- ⚠️ 현재 경보 데이터는 샘플이며, 실시간 연동 후 실제 경보 데이터로 교체 필요
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -551,15 +545,17 @@ AIS(선박자동식별장치) 신호를 의도적으로 끈 의심 선박(Dark V
|
|||||||
- 선박/이벤트 클릭 시 상세 정보 팝업
|
- 선박/이벤트 클릭 시 상세 정보 팝업
|
||||||
- 지도 확대/축소 및 해역 필터링
|
- 지도 확대/축소 및 해역 필터링
|
||||||
|
|
||||||
**구현 완료 (2026-04-17 기준):**
|
**구현 완료:**
|
||||||
- ✅ **실시간 선박 위치 + 이벤트 마커** — prediction 5분 주기 분석 결과(`vessel_analysis_results.lat/lon`) + `prediction_events` 기반
|
- ✅ LiveMap 기반 실시간 감시 지도 UI
|
||||||
- ✅ MapLibre GL 5 + deck.gl 9 GPU 렌더링 (40만척+ 지원)
|
- ✅ 선박/이벤트 마커 및 팝업 인터랙션
|
||||||
- ✅ 위험도별 마커 opacity/radius 차등 (2026-04-17 `ALERT_LEVEL_MARKER_OPACITY/RADIUS` 헬퍼 적용)
|
|
||||||
- ✅ 이벤트 상세 패널 + 고위험 사건 실시간 알림 (LIVE 표시)
|
|
||||||
|
|
||||||
**향후 구현 예정:**
|
**향후 구현 예정:**
|
||||||
- 🔲 WebSocket 기반 실시간 push (현재는 주기 갱신)
|
- 🔲 실시간 AIS/VMS 데이터 연동
|
||||||
- 🔲 SAR 위성영상 오버레이
|
- 🔲 WebSocket 기반 실시간 위치 업데이트
|
||||||
|
- 🔲 이벤트 발생 시 자동 지도 포커스 이동
|
||||||
|
|
||||||
|
**보완 필요:**
|
||||||
|
- ⚠️ 현재 선박 위치는 샘플 데이터이며, 실시간 데이터 연동 필요
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -605,15 +601,17 @@ AIS(선박자동식별장치) 신호를 의도적으로 끈 의심 선박(Dark V
|
|||||||
- 기간별/해역별/유형별 필터링
|
- 기간별/해역별/유형별 필터링
|
||||||
- 통계 데이터 엑셀 내보내기 및 인쇄
|
- 통계 데이터 엑셀 내보내기 및 인쇄
|
||||||
|
|
||||||
**구현 완료 (2026-04-17 기준):**
|
**구현 완료:**
|
||||||
- ✅ **실시간 통계 데이터** — `/api/stats/monthly|daily|hourly` 연동, prediction `stats_aggregator` 집계 결과 기반
|
- ✅ 월별 추이 차트 및 KPI 5개 대시보드 UI
|
||||||
- ✅ 월별/일별/시간별 추이 그래프 (ECharts, KST 기준)
|
- ✅ 필터링 및 엑셀 내보내기/인쇄 기능
|
||||||
- ✅ 해역별/유형별 필터링 + 엑셀 내보내기/인쇄
|
|
||||||
- ✅ 감사·보안 통계 — `/api/admin/stats/audit|access|login` (2026-04-17 AdminStatsService 계층 분리)
|
|
||||||
|
|
||||||
**향후 구현 예정:**
|
**향후 구현 예정:**
|
||||||
- 🔲 보고서 자동 생성 (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 계층 분리)
|
- ✅ 알림 5건 전송 현황 UI
|
||||||
- ✅ prediction `alert_dispatcher` 모듈이 event_generator 결과 기반으로 `prediction_alerts` 테이블에 자동 기록
|
- ✅ 알림 유형별 분류 및 상세 조회
|
||||||
- ✅ 알림 유형별 분류 + DataTable 검색/정렬/페이징/엑셀 내보내기
|
|
||||||
|
|
||||||
**향후 구현 예정:**
|
**향후 구현 예정:**
|
||||||
- 🔲 실제 SMS/푸시 발송 게이트웨이 연동 (현재는 DB 기록만)
|
- 🔲 실제 알림 발송 기능 구현 (SMS, 이메일, Push 등)
|
||||||
- 🔲 알림 템플릿 엔진
|
- 🔲 AI 분석 결과 기반 자동 알림 트리거
|
||||||
- 🔲 수신자 그룹 관리
|
- 🔲 알림 발송 이력 DB 연동
|
||||||
|
|
||||||
|
**보완 필요:**
|
||||||
|
- ⚠️ 현재 알림은 실제 발송되지 않으며, 발송 채널(SMS/이메일/Push) 연동 필요
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -857,27 +857,15 @@ AI에게 질문하고 답변을 받을 수 있는 대화형(채팅) 인터페이
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 부록: 현재 시스템 상태 요약 (2026-04-17 기준)
|
## 부록: 현재 시스템 상태 요약
|
||||||
|
|
||||||
| 항목 | 상태 |
|
| 항목 | 상태 |
|
||||||
|------|------|
|
|------|------|
|
||||||
| UI 구현 | 모든 SFR 완료 |
|
| 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) |
|
| 인증 체계 | 데모 계정 5종 (SSO/GPKI 미연동) |
|
||||||
| **인증 체계** | 자체 ID/PW + JWT + 트리 기반 RBAC + 5회 실패 잠금 (SSO/GPKI 미연동) |
|
| 실시간 기능 | 미구현 (WebSocket 등 미연동) |
|
||||||
| **실시간 기능** | prediction 5분 주기 + 프론트 30초 폴링 (WebSocket push 미도입) |
|
| AI 모델 | 미연동 (탐지/예측/최적화 등) |
|
||||||
| **AI 모델** | Dark Vessel 11패턴 / DAR-03 G-01~G-06 / 환적 5단계 / 경량 risk 등 14종 운영 중 (일부 모델은 Mock 계획 단계) |
|
| 외부 시스템 | 미연동 (GICOMS, MTIS 등) |
|
||||||
| **외부 시스템** | 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 반영 |
|
|
||||||
|
|||||||
Binary file not shown.
@ -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>
|
|
||||||
14
frontend/package-lock.json
generated
14
frontend/package-lock.json
generated
@ -11,7 +11,6 @@
|
|||||||
"@deck.gl/mapbox": "^9.2.11",
|
"@deck.gl/mapbox": "^9.2.11",
|
||||||
"@xyflow/react": "^12.10.2",
|
"@xyflow/react": "^12.10.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
|
||||||
"deck.gl": "^9.2.11",
|
"deck.gl": "^9.2.11",
|
||||||
"echarts": "^6.0.0",
|
"echarts": "^6.0.0",
|
||||||
"i18next": "^26.0.3",
|
"i18next": "^26.0.3",
|
||||||
@ -21,7 +20,6 @@
|
|||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"react-i18next": "^17.0.2",
|
"react-i18next": "^17.0.2",
|
||||||
"react-router-dom": "^7.12.0",
|
"react-router-dom": "^7.12.0",
|
||||||
"tailwind-merge": "^3.5.0",
|
|
||||||
"zustand": "^5.0.12"
|
"zustand": "^5.0.12"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
@ -2826,7 +2824,7 @@
|
|||||||
},
|
},
|
||||||
"node_modules/clsx": {
|
"node_modules/clsx": {
|
||||||
"version": "2.1.1",
|
"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==",
|
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@ -4909,16 +4907,6 @@
|
|||||||
"kdbush": "^4.0.2"
|
"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": {
|
"node_modules/tailwindcss": {
|
||||||
"version": "4.2.2",
|
"version": "4.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz",
|
||||||
|
|||||||
@ -15,7 +15,6 @@
|
|||||||
"@deck.gl/mapbox": "^9.2.11",
|
"@deck.gl/mapbox": "^9.2.11",
|
||||||
"@xyflow/react": "^12.10.2",
|
"@xyflow/react": "^12.10.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
|
||||||
"deck.gl": "^9.2.11",
|
"deck.gl": "^9.2.11",
|
||||||
"echarts": "^6.0.0",
|
"echarts": "^6.0.0",
|
||||||
"i18next": "^26.0.3",
|
"i18next": "^26.0.3",
|
||||||
@ -25,7 +24,6 @@
|
|||||||
"react-dom": "^19.2.4",
|
"react-dom": "^19.2.4",
|
||||||
"react-i18next": "^17.0.2",
|
"react-i18next": "^17.0.2",
|
||||||
"react-router-dom": "^7.12.0",
|
"react-router-dom": "^7.12.0",
|
||||||
"tailwind-merge": "^3.5.0",
|
|
||||||
"zustand": "^5.0.12"
|
"zustand": "^5.0.12"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"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 { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||||
import { AuthProvider, useAuth } from '@/app/auth/AuthContext';
|
import { AuthProvider, useAuth } from '@/app/auth/AuthContext';
|
||||||
import { MainLayout } from '@/app/layout/MainLayout';
|
import { MainLayout } from '@/app/layout/MainLayout';
|
||||||
import { LoginPage } from '@features/auth';
|
import { LoginPage } from '@features/auth';
|
||||||
import { useMenuStore } from '@stores/menuStore';
|
/* SFR-01 */ import { AccessControl } from '@features/admin';
|
||||||
import { COMPONENT_REGISTRY } from '@/app/componentRegistry';
|
/* SFR-02 */ import { SystemConfig, NoticeManagement } from '@features/admin';
|
||||||
|
/* SFR-03 */ import { DataHub } from '@features/admin';
|
||||||
// 권한 노드 없는 드릴다운 라우트 (인증만 체크)
|
/* SFR-04 */ import { AIModelManagement } from '@features/ai-operations';
|
||||||
const VesselDetail = lazy(() => import('@features/vessel').then((m) => ({ default: m.VesselDetail })));
|
/* 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}</>;
|
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() {
|
export default function App() {
|
||||||
return (
|
return (
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
<AuthProvider>
|
<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>
|
</AuthProvider>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
);
|
);
|
||||||
|
|||||||
@ -1,6 +1,5 @@
|
|||||||
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react';
|
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react';
|
||||||
import { fetchMe, loginApi, logoutApi, LoginError, type BackendUser, type MenuConfigItem } from '@/services/authApi';
|
import { fetchMe, loginApi, logoutApi, LoginError, type BackendUser } from '@/services/authApi';
|
||||||
import { useMenuStore } from '@stores/menuStore';
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* SFR-01: 시스템 로그인 및 권한 관리
|
* SFR-01: 시스템 로그인 및 권한 관리
|
||||||
@ -34,6 +33,55 @@ export interface AuthUser {
|
|||||||
// ─── 세션 타임아웃 (30분) ──────────────────
|
// ─── 세션 타임아웃 (30분) ──────────────────
|
||||||
const SESSION_TIMEOUT = 30 * 60 * 1000;
|
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 {
|
interface AuthContextType {
|
||||||
user: AuthUser | null;
|
user: AuthUser | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
@ -93,10 +141,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
let alive = true;
|
let alive = true;
|
||||||
fetchMe()
|
fetchMe()
|
||||||
.then((b) => {
|
.then((b) => {
|
||||||
if (alive && b) {
|
if (alive && b) setUser(backendToAuthUser(b));
|
||||||
setUser(backendToAuthUser(b));
|
|
||||||
if (b.menuConfig) useMenuStore.getState().setMenuConfig(b.menuConfig);
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
.finally(() => {
|
.finally(() => {
|
||||||
if (alive) setLoading(false);
|
if (alive) setLoading(false);
|
||||||
@ -138,7 +183,6 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
try {
|
try {
|
||||||
const b = await loginApi(account, password);
|
const b = await loginApi(account, password);
|
||||||
setUser(backendToAuthUser(b));
|
setUser(backendToAuthUser(b));
|
||||||
if (b.menuConfig) useMenuStore.getState().setMenuConfig(b.menuConfig);
|
|
||||||
setLastActivity(Date.now());
|
setLastActivity(Date.now());
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e instanceof LoginError) throw e;
|
if (e instanceof LoginError) throw e;
|
||||||
@ -151,7 +195,6 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
await logoutApi();
|
await logoutApi();
|
||||||
} finally {
|
} finally {
|
||||||
setUser(null);
|
setUser(null);
|
||||||
useMenuStore.getState().clear();
|
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@ -166,9 +209,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
const hasAccess = useCallback(
|
const hasAccess = useCallback(
|
||||||
(path: string) => {
|
(path: string) => {
|
||||||
if (!user) return false;
|
if (!user) return false;
|
||||||
// DB menu_config 기반 longest-match (PATH_TO_RESOURCE 대체)
|
// 경로의 첫 세그먼트로 매핑
|
||||||
const resource = useMenuStore.getState().getResourceForPath(path);
|
const matched = Object.keys(PATH_TO_RESOURCE).find((p) => path.startsWith(p));
|
||||||
if (!resource) return true;
|
if (!matched) return true; // 매핑 없는 경로는 허용 (안전한 기본값으로 변경 가능)
|
||||||
|
const resource = PATH_TO_RESOURCE[matched];
|
||||||
return hasPermission(resource, 'READ');
|
return hasPermission(resource, 'READ');
|
||||||
},
|
},
|
||||||
[user, hasPermission],
|
[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 { useTranslation } from 'react-i18next';
|
||||||
import { Outlet, NavLink, useNavigate, useLocation } from 'react-router-dom';
|
import { Outlet, NavLink, useNavigate, useLocation } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
LogOut, ChevronLeft, ChevronRight,
|
LayoutDashboard, Map, List, Ship, Anchor, Radar,
|
||||||
Shield, Bell, Search, Clock, Lock,
|
FileText, Settings, LogOut, ChevronLeft, ChevronRight,
|
||||||
Download, FileSpreadsheet, Printer,
|
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';
|
} from 'lucide-react';
|
||||||
import { useAuth } from '@/app/auth/AuthContext';
|
import { useAuth, type UserRole } from '@/app/auth/AuthContext';
|
||||||
import { getRoleColorHex } from '@shared/constants/userRoles';
|
|
||||||
import { NotificationBanner, NotificationPopup, type SystemNotice } from '@shared/components/common/NotificationBanner';
|
import { NotificationBanner, NotificationPopup, type SystemNotice } from '@shared/components/common/NotificationBanner';
|
||||||
import { useSettingsStore } from '@stores/settingsStore';
|
import { useSettingsStore } from '@stores/settingsStore';
|
||||||
import { useMenuStore, getMenuLabel, type MenuConfigItem } from '@stores/menuStore';
|
|
||||||
import { resolveIcon } from '@/app/iconRegistry';
|
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* SFR-01 반영 사항:
|
* 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> = {
|
const AUTH_METHOD_LABELS: Record<string, string> = {
|
||||||
password: 'ID/PW',
|
password: 'ID/PW',
|
||||||
@ -32,12 +42,155 @@ const AUTH_METHOD_LABELS: Record<string, string> = {
|
|||||||
sso: 'SSO',
|
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) {
|
function formatRemaining(seconds: number) {
|
||||||
const m = Math.floor(seconds / 60);
|
const m = Math.floor(seconds / 60);
|
||||||
const s = seconds % 60;
|
const s = seconds % 60;
|
||||||
return `${m}:${String(s).padStart(2, '0')}`;
|
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() {
|
export function MainLayout() {
|
||||||
const { t } = useTranslation('common');
|
const { t } = useTranslation('common');
|
||||||
const { theme, toggleTheme, language, toggleLanguage } = useSettingsStore();
|
const { theme, toggleTheme, language, toggleLanguage } = useSettingsStore();
|
||||||
@ -46,18 +199,43 @@ export function MainLayout() {
|
|||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const { user, logout, hasAccess, sessionRemaining } = useAuth();
|
const { user, logout, hasAccess, sessionRemaining } = useAuth();
|
||||||
const contentRef = useRef<HTMLDivElement>(null);
|
const contentRef = useRef<HTMLDivElement>(null);
|
||||||
const { getTopLevelEntries, getChildren } = useMenuStore();
|
|
||||||
|
|
||||||
// getPageLabel: DB 메뉴에서 현재 라우트 페이지명 (DB labels 기반)
|
// getPageLabel: 현재 라우트에서 페이지명 가져오기 (i18n)
|
||||||
const getPageLabel = (pathname: string): string => {
|
const getPageLabel = (pathname: string): string => {
|
||||||
const allItems = useMenuStore.getState().items.filter((i) => i.menuType === 'ITEM' && i.urlPath);
|
const item = NAV_ITEMS.find((n) => pathname.startsWith(n.to));
|
||||||
const item = allItems.find((n) => pathname.startsWith(n.urlPath!));
|
return item ? t(item.labelKey) : '';
|
||||||
return item ? getMenuLabel(item, language) : '';
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// 공통 검색
|
// 공통 검색
|
||||||
const [pageSearch, setPageSearch] = useState('');
|
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 handlePrint = () => {
|
||||||
const el = contentRef.current;
|
const el = contentRef.current;
|
||||||
@ -122,7 +300,7 @@ export function MainLayout() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// RBAC
|
// RBAC
|
||||||
const roleColor = user ? getRoleColorHex(user.role) : null;
|
const roleColor = user ? ROLE_COLORS[user.role] : null;
|
||||||
const isSessionWarning = sessionRemaining <= 5 * 60;
|
const isSessionWarning = sessionRemaining <= 5 * 60;
|
||||||
|
|
||||||
// SFR-02: 공통알림 데이터
|
// 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="mx-2 mt-2 px-3 py-2 rounded-lg bg-surface-overlay border border-border">
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<Lock className="w-3 h-3 text-hint" />
|
<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>
|
||||||
<div className="text-[8px] text-hint mt-0.5">
|
<div className="text-[8px] text-hint mt-0.5">
|
||||||
{t('layout.auth')} {AUTH_METHOD_LABELS[user.authMethod] || user.authMethod}
|
{t('layout.auth')} {AUTH_METHOD_LABELS[user.authMethod] || user.authMethod}
|
||||||
@ -183,31 +361,120 @@ export function MainLayout() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 네비게이션 — DB menu_config 기반 동적 렌더링 + RBAC 필터 */}
|
{/* 네비게이션 — RBAC 기반 필터링 + 그룹 메뉴 */}
|
||||||
<nav className="flex-1 overflow-y-auto py-2 px-2 space-y-0.5">
|
<nav className="flex-1 overflow-y-auto py-2 px-2 space-y-0.5">
|
||||||
{getTopLevelEntries().map((entry) => {
|
{NAV_ENTRIES.map((entry) => {
|
||||||
if (entry.menuType === 'GROUP') {
|
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 (
|
return (
|
||||||
<GroupMenu
|
<div key={entry.groupKey}>
|
||||||
key={entry.menuCd}
|
{/* 1단: 그룹 헤더 */}
|
||||||
group={entry}
|
<button
|
||||||
children={getChildren(entry.menuCd)}
|
type="button"
|
||||||
collapsed={collapsed}
|
onClick={() => toggleGroup(entry.groupKey)}
|
||||||
hasAccess={hasAccess}
|
className={`flex items-center gap-2.5 px-3 py-2 rounded-lg text-[12px] font-medium w-full transition-colors ${
|
||||||
openGroups={openGroups}
|
isAnyActive || openGroups.has(entry.groupKey)
|
||||||
toggleGroup={toggleGroup}
|
? 'text-foreground bg-surface-overlay'
|
||||||
location={location}
|
: 'text-hint hover:bg-surface-overlay hover:text-label'
|
||||||
language={language}
|
}`}
|
||||||
/>
|
>
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// 일반 NavItem (2단 직속)
|
||||||
|
if (!accessiblePaths.has(child.to)) return null;
|
||||||
|
return (
|
||||||
|
<NavLink
|
||||||
|
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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
// 일반 ITEM
|
// 일반 메뉴 아이템
|
||||||
if (!entry.urlPath || !hasAccess(entry.urlPath)) return null;
|
if (!hasAccess(entry.to)) return null;
|
||||||
const Icon = resolveIcon(entry.icon);
|
|
||||||
return (
|
return (
|
||||||
<NavLink
|
<NavLink
|
||||||
key={entry.menuCd}
|
key={entry.to}
|
||||||
to={entry.urlPath}
|
to={entry.to}
|
||||||
className={({ isActive }) =>
|
className={({ isActive }) =>
|
||||||
`flex items-center gap-2.5 px-3 py-2 rounded-lg text-[12px] font-medium transition-colors ${
|
`flex items-center gap-2.5 px-3 py-2 rounded-lg text-[12px] font-medium transition-colors ${
|
||||||
isActive
|
isActive
|
||||||
@ -216,8 +483,8 @@ export function MainLayout() {
|
|||||||
}`
|
}`
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{Icon && <Icon className="w-4 h-4 shrink-0" />}
|
<entry.icon className="w-4 h-4 shrink-0" />
|
||||||
{!collapsed && <span className="whitespace-nowrap overflow-hidden text-ellipsis">{getMenuLabel(entry, language)}</span>}
|
{!collapsed && <span className="whitespace-nowrap overflow-hidden text-ellipsis">{t(entry.labelKey)}</span>}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@ -261,7 +528,7 @@ export function MainLayout() {
|
|||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-hint" />
|
<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"
|
type="text"
|
||||||
placeholder={t('layout.searchPlaceholder')}
|
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"
|
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" />
|
<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>
|
<span className="text-[10px] text-red-400 font-bold whitespace-nowrap">{t('layout.alertCount', { count: 3 })}</span>
|
||||||
</div>
|
</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" />
|
<Bell className="w-4 h-4" />
|
||||||
<span className="absolute top-0.5 right-0.5 w-2 h-2 bg-red-500 rounded-full" />
|
<span className="absolute top-0.5 right-0.5 w-2 h-2 bg-red-500 rounded-full" />
|
||||||
</button>
|
</button>
|
||||||
@ -282,9 +549,8 @@ export function MainLayout() {
|
|||||||
{/* 언어 토글 */}
|
{/* 언어 토글 */}
|
||||||
<button
|
<button
|
||||||
onClick={toggleLanguage}
|
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"
|
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' : '한국어'}
|
{language === 'ko' ? 'EN' : '한국어'}
|
||||||
</button>
|
</button>
|
||||||
@ -318,7 +584,7 @@ export function MainLayout() {
|
|||||||
<div className="text-[8px] text-hint">{user.org}</div>
|
<div className="text-[8px] text-hint">{user.org}</div>
|
||||||
</div>
|
</div>
|
||||||
{roleColor && (
|
{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}
|
{user.role}
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
@ -339,7 +605,6 @@ export function MainLayout() {
|
|||||||
<div className="relative flex items-center">
|
<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" />
|
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3 h-3 text-hint pointer-events-none" />
|
||||||
<input
|
<input
|
||||||
aria-label={t('aria.searchInPage')}
|
|
||||||
value={pageSearch}
|
value={pageSearch}
|
||||||
onChange={(e) => setPageSearch(e.target.value)}
|
onChange={(e) => setPageSearch(e.target.value)}
|
||||||
onKeyDown={(e) => {
|
onKeyDown={(e) => {
|
||||||
@ -356,7 +621,7 @@ export function MainLayout() {
|
|||||||
(window as unknown as { find: (s: string) => boolean }).find?.(pageSearch);
|
(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" />
|
<Search className="w-3 h-3" />
|
||||||
{t('action.search')}
|
{t('action.search')}
|
||||||
@ -393,9 +658,19 @@ export function MainLayout() {
|
|||||||
<main
|
<main
|
||||||
ref={contentRef}
|
ref={contentRef}
|
||||||
className="flex-1 overflow-auto"
|
className="flex-1 overflow-auto"
|
||||||
|
onScroll={handleScroll}
|
||||||
>
|
>
|
||||||
<Outlet />
|
<Outlet />
|
||||||
</main>
|
</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>
|
</div>
|
||||||
|
|
||||||
{/* SFR-02: 공통알림 팝업 */}
|
{/* SFR-02: 공통알림 팝업 */}
|
||||||
@ -403,88 +678,3 @@ export function MainLayout() {
|
|||||||
</div>
|
</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-<카테고리>-<슬러그></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"><PageContainer fullBleed></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">
|
|
||||||
<Badge intent="info" className="w-full justify-center">
|
|
||||||
</code>
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-label">
|
|
||||||
<strong className="text-red-400">금지:</strong>{' '}
|
|
||||||
<code className="font-mono"><Badge className="bg-red-500 text-white"></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"><button className="bg-blue-600 ..."></code> — Button 컴포넌트 사용 필수</li>
|
|
||||||
<li>❌ <code className="font-mono"><input className="bg-surface ..."></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
불러오는 중...
Reference in New Issue
Block a user