Compare commits

..

No commits in common. "main" and "feature/add-cicd" have entirely different histories.

566개의 변경된 파일6748개의 추가작업 그리고 57547개의 파일을 삭제

파일 보기

@ -46,42 +46,5 @@
"Read(./**/.env.*)",
"Read(./**/secrets/**)"
]
},
"hooks": {
"SessionStart": [
{
"matcher": "compact",
"hooks": [
{
"type": "command",
"command": "bash .claude/scripts/on-post-compact.sh",
"timeout": 10
}
]
}
],
"PreCompact": [
{
"hooks": [
{
"type": "command",
"command": "bash .claude/scripts/on-pre-compact.sh",
"timeout": 30
}
]
}
],
"PostToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "bash .claude/scripts/on-commit.sh",
"timeout": 15
}
]
}
]
}
}

파일 보기

@ -1,7 +1,6 @@
{
"applied_global_version": "1.6.1",
"applied_date": "2026-04-07",
"applied_date": "2026-04-06",
"project_type": "react-ts",
"gitea_url": "https://gitea.gc-si.dev",
"custom_pre_commit": true
"gitea_url": "https://gitea.gc-si.dev"
}

파일 보기

@ -1,11 +1,9 @@
name: Build and Deploy KCG AI Monitoring (Frontend)
name: Build and Deploy KCG AI Monitoring
on:
push:
branches:
- main
paths:
- 'frontend/**'
jobs:
build-and-deploy:
@ -20,52 +18,20 @@ jobs:
node-version: '24'
- name: Configure npm registry
working-directory: frontend
run: |
echo "registry=https://nexus.gc-si.dev/repository/npm-public/" > .npmrc
echo "//nexus.gc-si.dev/repository/npm-public/:_auth=${{ secrets.NEXUS_NPM_AUTH }}" >> .npmrc
- name: Install dependencies
working-directory: frontend
run: npm ci --legacy-peer-deps
- name: Build
working-directory: frontend
run: npm run build
- name: Deploy to server
run: |
mkdir -p /deploy/kcg-ai-monitoring
rm -rf /deploy/kcg-ai-monitoring/*
cp -r frontend/dist/* /deploy/kcg-ai-monitoring/
echo "Frontend deployed at $(date '+%Y-%m-%d %H:%M:%S')"
cp -r dist/* /deploy/kcg-ai-monitoring/
echo "Deployed at $(date '+%Y-%m-%d %H:%M:%S')"
ls -la /deploy/kcg-ai-monitoring/
- name: Archive system-flow snapshot (per version)
run: |
# system-flow.html을 manifest version별로 영구 보존 (서버 로컬, nginx 노출 X)
ARCHIVE=/deploy/kcg-ai-monitoring-archive/system-flow
mkdir -p $ARCHIVE
if [ ! -f "frontend/src/flow/manifest/meta.json" ]; then
echo "[archive] meta.json not found, skip"
exit 0
fi
VERSION=$(node -e "console.log(JSON.parse(require('fs').readFileSync('frontend/src/flow/manifest/meta.json')).version)")
DATE=$(date +%Y-%m-%d)
SNAPSHOT="$ARCHIVE/v${VERSION}_${DATE}"
if [ -d "$SNAPSHOT" ]; then
echo "[archive] v${VERSION} already exists, skip"
exit 0
fi
mkdir -p "$SNAPSHOT/assets"
cp /deploy/kcg-ai-monitoring/system-flow.html "$SNAPSHOT/" 2>/dev/null || true
cp /deploy/kcg-ai-monitoring/assets/systemFlow-*.* "$SNAPSHOT/assets/" 2>/dev/null || true
cp /deploy/kcg-ai-monitoring/assets/index-*.* "$SNAPSHOT/assets/" 2>/dev/null || true
# manifest 전체 스냅샷 (JSON 형태로 별도 참조 가능)
cp -r frontend/src/flow/manifest "$SNAPSHOT/manifest" 2>/dev/null || true
echo "[archive] system-flow v${VERSION} snapshot saved → $SNAPSHOT"
ls -la "$SNAPSHOT/"

파일 보기

@ -1,62 +0,0 @@
#!/bin/bash
#==============================================================================
# pre-commit hook (모노레포: frontend/ 디렉토리 기준)
# TypeScript 컴파일 + 린트 검증 — 실패 시 커밋 차단
#==============================================================================
# frontend 변경 파일이 있는지 확인
FRONTEND_CHANGED=$(git diff --cached --name-only -- 'frontend/' | head -1)
if [ -z "$FRONTEND_CHANGED" ]; then
echo "pre-commit: frontend 변경 없음, 검증 건너뜀"
exit 0
fi
echo "pre-commit: TypeScript 타입 체크 중..."
# npm 확인
if ! command -v npx &>/dev/null; then
echo "경고: npx가 설치되지 않았습니다. 검증을 건너뜁니다."
exit 0
fi
# node_modules 확인 (모노레포: frontend/ 기준)
if [ ! -d "frontend/node_modules" ]; then
echo "경고: frontend/node_modules가 없습니다. 'cd frontend && npm install' 실행 후 다시 시도하세요."
exit 1
fi
# TypeScript 타입 체크 (frontend/ 디렉토리에서 실행)
(cd frontend && npx tsc --noEmit --pretty 2>&1)
TSC_RESULT=$?
if [ $TSC_RESULT -ne 0 ]; then
echo ""
echo "╔══════════════════════════════════════════════════════════╗"
echo "║ TypeScript 타입 에러! 커밋이 차단되었습니다. ║"
echo "║ 타입 에러를 수정한 후 다시 커밋해주세요. ║"
echo "╚══════════════════════════════════════════════════════════╝"
echo ""
exit 1
fi
echo "pre-commit: 타입 체크 성공"
# ESLint 검증 (설정 파일이 있는 경우만)
if [ -f "frontend/.eslintrc.js" ] || [ -f "frontend/.eslintrc.json" ] || [ -f "frontend/.eslintrc.cjs" ] || [ -f "frontend/eslint.config.js" ] || [ -f "frontend/eslint.config.mjs" ]; then
echo "pre-commit: ESLint 검증 중..."
(cd frontend && npx eslint src/ --ext .ts,.tsx --quiet 2>&1)
LINT_RESULT=$?
if [ $LINT_RESULT -ne 0 ]; then
echo ""
echo "╔══════════════════════════════════════════════════════════╗"
echo "║ ESLint 에러! 커밋이 차단되었습니다. ║"
echo "║ 'cd frontend && npm run lint -- --fix'로 수정하세요. ║"
echo "╚══════════════════════════════════════════════════════════╝"
echo ""
exit 1
fi
echo "pre-commit: ESLint 통과"
fi

35
.gitignore vendored
파일 보기

@ -1,19 +1,8 @@
# === Build ===
frontend/dist/
frontend/build/
backend/target/
backend/build/
# === Python (prediction) ===
.venv/
prediction/.venv/
prediction/__pycache__/
prediction/**/__pycache__/
prediction/*.pyc
prediction/.env
dist/
build/
# === Dependencies ===
frontend/node_modules/
node_modules/
# === IDE ===
@ -30,8 +19,6 @@ Thumbs.db
.env
.env.*
!.env.example
# 프론트엔드 환경 예시 (.env.example만 커밋)
!frontend/.env.example
secrets/
# === Debug ===
@ -40,22 +27,18 @@ yarn-debug.log*
yarn-error.log*
# === Test ===
frontend/coverage/
backend/coverage/
coverage/
# === Cache ===
frontend/.eslintcache
frontend/.prettiercache
frontend/*.tsbuildinfo
frontend/.vite/
.vite/
.eslintcache
.prettiercache
*.tsbuildinfo
# === Code Review Graph (로컬 전용) ===
.code-review-graph/
# === 대용량/참고 문서 ===
*.hwpx
*.docx
# === Claude Code ===
!.claude/
@ -72,9 +55,3 @@ frontend/.vite/
.claude/skills/version/
.claude/skills/fix-issue/
.claude/scripts/
# === Backend (Spring Boot) ===
backend/.mvn/wrapper/maven-wrapper.jar
backend/.gradle/
backend/HELP.md
backend/*.log

287
CLAUDE.md
파일 보기

@ -1,287 +0,0 @@
# KCG AI Monitoring (모노레포)
해양경찰청 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>` 필수
위반 시 리뷰 단계에서 반려 대상. 신규 페이지는 하단의 **"페이지 작성 표준 템플릿"** 을 시작점으로 사용한다.
---
## 모노레포 구조
```
kcg-ai-monitoring/
├── frontend/ # React 19 + TypeScript + Vite (UI)
├── backend/ # Spring Boot 3.x + Java 21 (인증/권한/감사 + 분석 API)
├── prediction/ # Python 3.9 + FastAPI (AIS 분석 엔진, 5분 주기)
├── database/ # PostgreSQL 마이그레이션 (Flyway V001~V016, 48 테이블)
│ └── migration/
├── deploy/ # 배포 가이드 + 서버 설정 문서
├── docs/ # 프로젝트 문서 (SFR, 아키텍처)
├── .gitea/ # Gitea Actions CI/CD (프론트 자동배포)
├── .claude/ # Claude Code 워크플로우
├── .githooks/ # Git hooks
└── Makefile # 통합 dev/build 명령
```
## 시스템 구성
```
[Frontend Vite :5173] ──→ [Backend Spring :8080] ──→ [PostgreSQL kcgaidb]
↑ write
[Prediction FastAPI :18092] ─────┘ (5분 주기 분석 결과 저장)
↑ read
[SNPDB PostgreSQL] (AIS 원본)
```
- **자체 백엔드**: 인증/권한/감사로그/관리자 + 운영자 의사결정 (확정/제외/학습) + prediction 분석 결과 조회 API (`/api/analysis/*`)
- **Prediction**: AIS → 분석 결과를 kcgaidb 에 직접 write (백엔드 호출 없음)
- **DB 공유 아키텍처**: 백엔드와 prediction 은 HTTP 호출 없이 kcgaidb 를 통해서만 연동
## 명령어
```bash
make install # 전체 의존성 설치
make dev # 프론트 + 백엔드 동시 실행
make dev-all # 프론트 + 백엔드 + prediction 동시 실행
make dev-frontend # 프론트만
make dev-backend # 백엔드만
make dev-prediction # prediction 분석 엔진만 (FastAPI :8001)
make build # 전체 빌드
make lint # 프론트 lint
make format # 프론트 prettier
```
## 기술 스택
### Frontend (`frontend/`)
- React 19, TypeScript 5.9, Vite 8
- Tailwind CSS 4 + CVA
- MapLibre GL 5 + deck.gl 9 (지도)
- ECharts 6 (차트)
- Zustand 5 (상태관리)
- i18next (ko/en)
- React Router 7
- ESLint 10 + Prettier
### Prediction (`prediction/`) — 분석 엔진
- Python 3.11+, FastAPI, APScheduler
- 14개 알고리즘 (어구 추론, 다크베셀, 스푸핑, 환적, 위험도 등)
- 7단계 분류 파이프라인 (전처리→행동→리샘플→특징→분류→클러스터→계절)
- AIS 원본: SNPDB (5분 증분), 결과: kcgaidb (직접 write)
- prediction과 backend는 DB만 공유 (HTTP 호출 X)
### Backend (`backend/`)
- Spring Boot 3.x + Java 21
- Spring Security + JWT
- PostgreSQL + Flyway
- Caffeine (권한 캐싱)
- 트리 기반 RBAC (wing 패턴)
### Database (`kcgaidb`)
- PostgreSQL
- 사용자: `kcg-app`
- 스키마: `kcg`
## 배포 환경
| 서비스 | 서버 (SSH) | 포트 | 관리 |
|---|---|---|---|
| 프론트엔드 | rocky-211 | nginx 443 | Gitea Actions 자동배포 |
| 백엔드 | rocky-211 | 18080 | `systemctl restart kcg-ai-backend` |
| prediction | redis-211 | 18092 | `systemctl restart kcg-ai-prediction` |
- **URL**: https://kcg-ai-monitoring.gc-si.dev
- **배포 상세**: `deploy/README.md` 참조
- **CI/CD**: `.gitea/workflows/deploy.yml` (프론트만 자동, 백엔드/prediction 수동)
## 권한 체계
좌측 탭(메뉴) = 권한 그룹, 내부 패널/액션 = 자식 자원, CRUD 단위 개별 제어.
상세는 `.claude/plans/vast-tinkering-knuth.md` 참조.
## 팀 컨벤션
- 팀 규칙: `.claude/rules/`
- 커밋: Conventional Commits (한국어), `.githooks/commit-msg` 검증
- pre-commit: `frontend/` 디렉토리 기준 TypeScript + ESLint 검증
## 디자인 시스템 (필수 준수)
프론트엔드 UI는 **`/design-system.html` 쇼케이스를 단일 진실 공급원(SSOT)** 으로 한다.
모든 페이지/컴포넌트는 쇼케이스에 정의된 컴포넌트와 토큰만 사용한다.
### 쇼케이스 진입
- **URL**: https://kcg-ai-monitoring.gc-si.dev/design-system.html (메인 SPA와 별개)
- **소스**: `frontend/design-system.html` + `frontend/src/designSystemMain.tsx` + `frontend/src/design-system/`
- **추적 ID 체계**: `TRK-<카테고리>-<슬러그>` (예: `TRK-BADGE-critical-sm`)
- 호버 시 툴팁, "ID 복사 모드"에서 클릭 시 클립보드 복사
- URL 해시 딥링크: `#trk=TRK-BUTTON-primary-md`
- **단축키 `A`**: 다크/라이트 테마 토글
### 공통 컴포넌트 (반드시 사용)
| 컴포넌트 | 위치 | 용도 |
|---|---|---|
| `Badge` | `@shared/components/ui/badge` | 8 intent × 4 size, **className으로 색상 override 금지** |
| `Button` | `@shared/components/ui/button` | 5 variant × 3 size (primary/secondary/ghost/outline/destructive) |
| `Input` / `Select` / `Textarea` / `Checkbox` / `Radio` | `@shared/components/ui/` | 폼 요소 (Select는 aria-label 타입 강제) |
| `TabBar` / `TabButton` | `@shared/components/ui/tabs` | underline / pill / segmented 3 variant |
| `Card` / `CardHeader` / `CardTitle` / `CardContent` | `@shared/components/ui/card` | 4 variant |
| `PageContainer` | `@shared/components/layout` | 페이지 루트 (size sm/md/lg + fullBleed) |
| `PageHeader` | `@shared/components/layout` | 페이지 헤더 (icon + title + description + demo + actions) |
| `Section` | `@shared/components/layout` | Card + CardHeader + CardTitle + CardContent 단축 |
### 카탈로그 기반 라벨/색상
분류 데이터는 `frontend/src/shared/constants/`의 19+ 카탈로그를 참조한다.
중앙 레지스트리는 `catalogRegistry.ts`이며, 쇼케이스가 자동 열거한다.
```tsx
import { Badge } from '@shared/components/ui/badge';
import { getAlertLevelIntent, getAlertLevelLabel } from '@shared/constants/alertLevels';
<Badge intent={getAlertLevelIntent(event.level)} size="sm">
{getAlertLevelLabel(event.level, t, lang)}
</Badge>
```
ad-hoc 한글/영문 상태 문자열은 `getStatusIntent()` (statusIntent.ts) 사용.
숫자 위험도는 `getRiskIntent(0~100)` 사용.
### CSS 작성 규칙
1. **인라인 색상 금지**`style={{ backgroundColor: '#ef4444' }}` 같은 정적 색상은 작성 금지
- 예외: 동적 데이터 기반 (`backgroundColor: meta.hex`, progress width `${value}%`)
2. **하드코딩 Tailwind 색상 금지**`bg-red-500/20 text-red-400` 같은 직접 작성 금지
- 반드시 Badge intent 또는 카탈로그 API 호출
3. **className override 정책**
- ✅ 레이아웃/위치 보정: `<Badge intent="info" className="w-full justify-center">`
- ❌ 색상/글자 크기 override: `<Badge intent="info" className="bg-red-500 text-xs">`
4. **시맨틱 토큰 우선**`theme.css @layer utilities`의 토큰 사용
- `text-heading` / `text-label` / `text-hint` / `text-on-vivid` / `text-on-bright`
- `bg-surface-raised` / `bg-surface-overlay` / `bg-card` / `bg-background`
5. **!important 절대 금지** — `cn()` + `tailwind-merge`로 충돌 해결
6. **`-webkit-` 벤더 prefix** — 수동 작성 CSS는 `backdrop-filter` 등 prefix 직접 추가 (Tailwind는 자동)
### 페이지 작성 표준 템플릿
```tsx
import { PageContainer, PageHeader, Section } from '@shared/components/layout';
import { Button } from '@shared/components/ui/button';
import { Badge } from '@shared/components/ui/badge';
import { getAlertLevelIntent, getAlertLevelLabel } from '@shared/constants/alertLevels';
import { Shield, Plus } from 'lucide-react';
import { useTranslation } from 'react-i18next';
export function MyPage() {
const { t, i18n } = useTranslation('common');
const lang = i18n.language as 'ko' | 'en';
return (
<PageContainer>
<PageHeader
icon={Shield}
iconColor="text-blue-400"
title="페이지 제목"
description="페이지 설명"
actions={
<Button variant="primary" icon={<Plus className="w-4 h-4" />}>
추가
</Button>
}
/>
<Section title="데이터 목록">
<Badge intent={getAlertLevelIntent('HIGH')} size="sm">
{getAlertLevelLabel('HIGH', t, lang)}
</Badge>
</Section>
</PageContainer>
);
}
```
### 접근성 (a11y) 필수
- **`<button>`**: `type="button"` 명시 + 아이콘 전용은 `aria-label` 필수
- **`<input>` / `<textarea>` / `<select>`**: `aria-label` 또는 `<label htmlFor>` 필수
- **`Select` 컴포넌트**: TypeScript union type으로 `aria-label`/`aria-labelledby`/`title` 중 하나 컴파일 타임 강제
- 위반 시 WCAG 2.1 Level A 위반 + axe DevTools 경고
### 변경 사이클
1. 디자인 변경이 필요하면 → **쇼케이스에서 먼저 미세조정** → 시각 검증
2. 카탈로그 라벨/색상 변경 → `shared/constants/*` 또는 `variantMeta.ts`만 수정
3. 컴포넌트 변형 추가 → `lib/theme/variants.ts` CVA에만 추가
4. 실 페이지는 **컴포넌트만 사용**, 변경 시 자동 반영
### 금지 패턴 체크리스트
- ❌ `<Badge className="bg-red-500/20 text-red-400">``<Badge intent="critical">`
- ❌ `<button className="bg-blue-600 ...">``<Button variant="primary">`
- ❌ `<input className="bg-surface ...">``<Input>`
- ❌ `<div className="p-5 space-y-4">` 페이지 루트 → `<PageContainer>`
- ❌ `-m-4` negative margin 해킹 → `<PageContainer fullBleed>`
- ❌ `style={{ color: '#ef4444' }}` 정적 색상 → 시맨틱 토큰 또는 카탈로그
- ❌ `!important``cn()` 활용
- ❌ 페이지 내 `const STATUS_COLORS = {...}` 로컬 재정의 → shared/constants 카탈로그
## System Flow 뷰어 (개발 단계용)
- **URL**: https://kcg-ai-monitoring.gc-si.dev/system-flow.html (메인 SPA와 별개)
- **소스**: `frontend/system-flow.html` + `frontend/src/systemFlowMain.tsx` + `frontend/src/flow/`
- **매니페스트**: `frontend/src/flow/manifest/` (10개 카테고리 JSON + meta.json + edges.json)
- **노드 ID 명명**: `<category>.<snake_case>` (예: `output.event_generator`, `ui.parent_review`)
- **딥링크**: `/system-flow.html#node=<node_id>` — 산출문서에서 노드 직접 참조
- **가이드**: `docs/system-flow-guide.md` 참조
### `/version` 스킬 사후 처리 (필수)
`/version` 스킬을 실행하여 새 SemVer 버전이 결정되면, Claude는 이어서 다음 작업을 **자동으로** 수행한다 (`/version` 스킬 자체는 팀 공통 파일이라 직접 수정하지 않음):
1. **manifest 동기화**: `/version`이 결정한 새 버전을 `frontend/src/flow/manifest/meta.json`에 반영
- `version`: 새 SemVer (예: `"1.2.0"`)
- `updatedAt`: 현재 ISO datetime (`new Date().toISOString()`)
- `releaseDate`: 오늘 날짜 (`YYYY-MM-DD`)
2. **같은 커밋에 포함**: `frontend/src/flow/manifest/meta.json``/version` 스킬이 만든 커밋에 amend하거나, `docs: VERSION-HISTORY 갱신 + system-flow manifest 동기화`로 통합 커밋
3. **서버 archive는 CI/CD가 자동 처리**: 별도 작업 불필요. main 머지 후 Gitea Actions가 빌드 + dist 배포 + `/deploy/kcg-ai-monitoring-archive/system-flow/v{version}_{date}/`에 스냅샷 영구 보존
### 노드 ID 안정성
- **노드 ID는 절대 변경 금지** (산출문서가 참조하므로 깨짐)
- 노드 제거 시 `status: 'deprecated'`로 마킹 (1~2 릴리즈 유지 후 삭제)
- 새 노드 추가 시 `status: 'implemented'` 또는 `'planned'`

파일 보기

@ -1,56 +0,0 @@
.PHONY: help install dev dev-frontend dev-backend dev-prediction build build-frontend build-backend lint format test clean
help:
@echo "사용 가능한 명령:"
@echo " make install - 전체 의존성 설치"
@echo " make dev - 프론트엔드 + 백엔드 동시 실행"
@echo " make dev-all - 프론트 + 백엔드 + prediction 동시 실행"
@echo " make dev-frontend - 프론트엔드 dev 서버만 실행 (Vite)"
@echo " make dev-backend - 백엔드 dev 서버만 실행 (Spring Boot)"
@echo " make dev-prediction - prediction 분석 엔진만 실행 (FastAPI :8001)"
@echo " make build - 프론트엔드 + 백엔드 빌드"
@echo " make build-frontend - 프론트엔드 빌드"
@echo " make build-backend - 백엔드 빌드"
@echo " make lint - 프론트엔드 lint 검사"
@echo " make format - 프론트엔드 prettier 포맷팅"
@echo " make clean - 빌드 산출물 삭제"
install:
cd frontend && npm install
@if [ -f backend/pom.xml ]; then cd backend && ./mvnw dependency:resolve || true; fi
@if [ -f prediction/requirements.txt ]; then cd prediction && pip install -r requirements.txt 2>/dev/null || echo "prediction 의존성 설치는 가상환경에서 실행하세요: cd prediction && uv venv && source .venv/bin/activate && uv pip install -r requirements.txt"; fi
dev-frontend:
cd frontend && npm run dev
dev-backend:
@if [ -f backend/pom.xml ]; then cd backend && ./mvnw spring-boot:run -Dspring-boot.run.profiles=local; \
else echo "백엔드가 아직 초기화되지 않았습니다 (Phase 2에서 추가)"; fi
dev-prediction:
cd prediction && python main.py
dev:
@$(MAKE) -j2 dev-frontend dev-backend
dev-all:
@$(MAKE) -j3 dev-frontend dev-backend dev-prediction
build-frontend:
cd frontend && npm run build
build-backend:
@if [ -f backend/pom.xml ]; then cd backend && ./mvnw clean package -DskipTests; \
else echo "백엔드가 아직 초기화되지 않았습니다 (Phase 2에서 추가)"; fi
build: build-frontend build-backend
lint:
cd frontend && npm run lint
format:
cd frontend && npm run format
clean:
rm -rf frontend/dist frontend/node_modules/.vite
@if [ -f backend/pom.xml ]; then cd backend && ./mvnw clean; fi

파일 보기

@ -1,3 +0,0 @@
wrapperVersion=3.3.4
distributionType=only-script
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.14/apache-maven-3.9.14-bin.zip

파일 보기

@ -1 +0,0 @@
java=21.0.9-amzn

파일 보기

@ -1,18 +0,0 @@
# Backend (Spring Boot)
Phase 2에서 초기화 예정.
## 계획된 구성
- Spring Boot 3.x + Java 21
- PostgreSQL + Flyway
- Spring Security + JWT
- Caffeine 캐시
- 트리 기반 RBAC 권한 체계 (wing 패턴)
## 책임
- 자체 인증/권한/감사로그
- 운영자 의사결정 (모선 확정/제외/학습)
- prediction 분석 결과 조회 API (`/api/analysis/*`)
- 관리자 화면 API
상세 설계: `.claude/plans/vast-tinkering-knuth.md`

295
backend/mvnw vendored
파일 보기

@ -1,295 +0,0 @@
#!/bin/sh
# ----------------------------------------------------------------------------
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
# ----------------------------------------------------------------------------
# ----------------------------------------------------------------------------
# Apache Maven Wrapper startup batch script, version 3.3.4
#
# Optional ENV vars
# -----------------
# JAVA_HOME - location of a JDK home dir, required when download maven via java source
# MVNW_REPOURL - repo url base for downloading maven distribution
# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output
# ----------------------------------------------------------------------------
set -euf
[ "${MVNW_VERBOSE-}" != debug ] || set -x
# OS specific support.
native_path() { printf %s\\n "$1"; }
case "$(uname)" in
CYGWIN* | MINGW*)
[ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")"
native_path() { cygpath --path --windows "$1"; }
;;
esac
# set JAVACMD and JAVACCMD
set_java_home() {
# For Cygwin and MinGW, ensure paths are in Unix format before anything is touched
if [ -n "${JAVA_HOME-}" ]; then
if [ -x "$JAVA_HOME/jre/sh/java" ]; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
JAVACCMD="$JAVA_HOME/jre/sh/javac"
else
JAVACMD="$JAVA_HOME/bin/java"
JAVACCMD="$JAVA_HOME/bin/javac"
if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then
echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2
echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2
return 1
fi
fi
else
JAVACMD="$(
'set' +e
'unset' -f command 2>/dev/null
'command' -v java
)" || :
JAVACCMD="$(
'set' +e
'unset' -f command 2>/dev/null
'command' -v javac
)" || :
if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then
echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2
return 1
fi
fi
}
# hash string like Java String::hashCode
hash_string() {
str="${1:-}" h=0
while [ -n "$str" ]; do
char="${str%"${str#?}"}"
h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296))
str="${str#?}"
done
printf %x\\n $h
}
verbose() { :; }
[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; }
die() {
printf %s\\n "$1" >&2
exit 1
}
trim() {
# MWRAPPER-139:
# Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds.
# Needed for removing poorly interpreted newline sequences when running in more
# exotic environments such as mingw bash on Windows.
printf "%s" "${1}" | tr -d '[:space:]'
}
scriptDir="$(dirname "$0")"
scriptName="$(basename "$0")"
# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties
while IFS="=" read -r key value; do
case "${key-}" in
distributionUrl) distributionUrl=$(trim "${value-}") ;;
distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;;
esac
done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties"
[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
case "${distributionUrl##*/}" in
maven-mvnd-*bin.*)
MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/
case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in
*AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;;
:Darwin*x86_64) distributionPlatform=darwin-amd64 ;;
:Darwin*arm64) distributionPlatform=darwin-aarch64 ;;
:Linux*x86_64*) distributionPlatform=linux-amd64 ;;
*)
echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2
distributionPlatform=linux-amd64
;;
esac
distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip"
;;
maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;;
*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;;
esac
# apply MVNW_REPOURL and calculate MAVEN_HOME
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}"
distributionUrlName="${distributionUrl##*/}"
distributionUrlNameMain="${distributionUrlName%.*}"
distributionUrlNameMain="${distributionUrlNameMain%-bin}"
MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}"
MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")"
exec_maven() {
unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || :
exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD"
}
if [ -d "$MAVEN_HOME" ]; then
verbose "found existing MAVEN_HOME at $MAVEN_HOME"
exec_maven "$@"
fi
case "${distributionUrl-}" in
*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;;
*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;;
esac
# prepare tmp dir
if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then
clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; }
trap clean HUP INT TERM EXIT
else
die "cannot create temp dir"
fi
mkdir -p -- "${MAVEN_HOME%/*}"
# Download and Install Apache Maven
verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
verbose "Downloading from: $distributionUrl"
verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
# select .zip or .tar.gz
if ! command -v unzip >/dev/null; then
distributionUrl="${distributionUrl%.zip}.tar.gz"
distributionUrlName="${distributionUrl##*/}"
fi
# verbose opt
__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR=''
[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v
# normalize http auth
case "${MVNW_PASSWORD:+has-password}" in
'') MVNW_USERNAME='' MVNW_PASSWORD='' ;;
has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;;
esac
if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then
verbose "Found wget ... using wget"
wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl"
elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then
verbose "Found curl ... using curl"
curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl"
elif set_java_home; then
verbose "Falling back to use Java to download"
javaSource="$TMP_DOWNLOAD_DIR/Downloader.java"
targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName"
cat >"$javaSource" <<-END
public class Downloader extends java.net.Authenticator
{
protected java.net.PasswordAuthentication getPasswordAuthentication()
{
return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() );
}
public static void main( String[] args ) throws Exception
{
setDefault( new Downloader() );
java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() );
}
}
END
# For Cygwin/MinGW, switch paths to Windows format before running javac and java
verbose " - Compiling Downloader.java ..."
"$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java"
verbose " - Running Downloader.java ..."
"$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")"
fi
# If specified, validate the SHA-256 sum of the Maven distribution zip file
if [ -n "${distributionSha256Sum-}" ]; then
distributionSha256Result=false
if [ "$MVN_CMD" = mvnd.sh ]; then
echo "Checksum validation is not supported for maven-mvnd." >&2
echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
exit 1
elif command -v sha256sum >/dev/null; then
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then
distributionSha256Result=true
fi
elif command -v shasum >/dev/null; then
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then
distributionSha256Result=true
fi
else
echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2
echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
exit 1
fi
if [ $distributionSha256Result = false ]; then
echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2
echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2
exit 1
fi
fi
# unzip and move
if command -v unzip >/dev/null; then
unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip"
else
tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar"
fi
# Find the actual extracted directory name (handles snapshots where filename != directory name)
actualDistributionDir=""
# First try the expected directory name (for regular distributions)
if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then
if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then
actualDistributionDir="$distributionUrlNameMain"
fi
fi
# If not found, search for any directory with the Maven executable (for snapshots)
if [ -z "$actualDistributionDir" ]; then
# enable globbing to iterate over items
set +f
for dir in "$TMP_DOWNLOAD_DIR"/*; do
if [ -d "$dir" ]; then
if [ -f "$dir/bin/$MVN_CMD" ]; then
actualDistributionDir="$(basename "$dir")"
break
fi
fi
done
set -f
fi
if [ -z "$actualDistributionDir" ]; then
verbose "Contents of $TMP_DOWNLOAD_DIR:"
verbose "$(ls -la "$TMP_DOWNLOAD_DIR")"
die "Could not find Maven distribution directory in extracted archive"
fi
verbose "Found extracted Maven distribution directory: $actualDistributionDir"
printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url"
mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME"
clean || :
exec_maven "$@"

189
backend/mvnw.cmd vendored
파일 보기

@ -1,189 +0,0 @@
<# : batch portion
@REM ----------------------------------------------------------------------------
@REM Licensed to the Apache Software Foundation (ASF) under one
@REM or more contributor license agreements. See the NOTICE file
@REM distributed with this work for additional information
@REM regarding copyright ownership. The ASF licenses this file
@REM to you under the Apache License, Version 2.0 (the
@REM "License"); you may not use this file except in compliance
@REM with the License. You may obtain a copy of the License at
@REM
@REM http://www.apache.org/licenses/LICENSE-2.0
@REM
@REM Unless required by applicable law or agreed to in writing,
@REM software distributed under the License is distributed on an
@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
@REM KIND, either express or implied. See the License for the
@REM specific language governing permissions and limitations
@REM under the License.
@REM ----------------------------------------------------------------------------
@REM ----------------------------------------------------------------------------
@REM Apache Maven Wrapper startup batch script, version 3.3.4
@REM
@REM Optional ENV vars
@REM MVNW_REPOURL - repo url base for downloading maven distribution
@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output
@REM ----------------------------------------------------------------------------
@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0)
@SET __MVNW_CMD__=
@SET __MVNW_ERROR__=
@SET __MVNW_PSMODULEP_SAVE=%PSModulePath%
@SET PSModulePath=
@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @(
IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B)
)
@SET PSModulePath=%__MVNW_PSMODULEP_SAVE%
@SET __MVNW_PSMODULEP_SAVE=
@SET __MVNW_ARG0_NAME__=
@SET MVNW_USERNAME=
@SET MVNW_PASSWORD=
@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*)
@echo Cannot start maven from wrapper >&2 && exit /b 1
@GOTO :EOF
: end batch / begin powershell #>
$ErrorActionPreference = "Stop"
if ($env:MVNW_VERBOSE -eq "true") {
$VerbosePreference = "Continue"
}
# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties
$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl
if (!$distributionUrl) {
Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
}
switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) {
"maven-mvnd-*" {
$USE_MVND = $true
$distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip"
$MVN_CMD = "mvnd.cmd"
break
}
default {
$USE_MVND = $false
$MVN_CMD = $script -replace '^mvnw','mvn'
break
}
}
# apply MVNW_REPOURL and calculate MAVEN_HOME
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
if ($env:MVNW_REPOURL) {
$MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" }
$distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')"
}
$distributionUrlName = $distributionUrl -replace '^.*/',''
$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$',''
$MAVEN_M2_PATH = "$HOME/.m2"
if ($env:MAVEN_USER_HOME) {
$MAVEN_M2_PATH = "$env:MAVEN_USER_HOME"
}
if (-not (Test-Path -Path $MAVEN_M2_PATH)) {
New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null
}
$MAVEN_WRAPPER_DISTS = $null
if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) {
$MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists"
} else {
$MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists"
}
$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain"
$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join ''
$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME"
if (Test-Path -Path "$MAVEN_HOME" -PathType Container) {
Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME"
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
exit $?
}
if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) {
Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl"
}
# prepare tmp dir
$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile
$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir"
$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null
trap {
if ($TMP_DOWNLOAD_DIR.Exists) {
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
}
}
New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null
# Download and Install Apache Maven
Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
Write-Verbose "Downloading from: $distributionUrl"
Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
$webclient = New-Object System.Net.WebClient
if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) {
$webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD)
}
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null
# If specified, validate the SHA-256 sum of the Maven distribution zip file
$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum
if ($distributionSha256Sum) {
if ($USE_MVND) {
Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties."
}
Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash
if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) {
Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property."
}
}
# unzip and move
Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null
# Find the actual extracted directory name (handles snapshots where filename != directory name)
$actualDistributionDir = ""
# First try the expected directory name (for regular distributions)
$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain"
$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD"
if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) {
$actualDistributionDir = $distributionUrlNameMain
}
# If not found, search for any directory with the Maven executable (for snapshots)
if (!$actualDistributionDir) {
Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object {
$testPath = Join-Path $_.FullName "bin/$MVN_CMD"
if (Test-Path -Path $testPath -PathType Leaf) {
$actualDistributionDir = $_.Name
}
}
}
if (!$actualDistributionDir) {
Write-Error "Could not find Maven distribution directory in extracted archive"
}
Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir"
Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null
try {
Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null
} catch {
if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) {
Write-Error "fail to move MAVEN_HOME"
}
} finally {
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
}
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"

파일 보기

@ -1,179 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.5.7</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>gc.mda.kcg</groupId>
<artifactId>kcg-ai-backend</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>kcg-ai-backend</name>
<description/>
<url/>
<licenses>
<license/>
</licenses>
<developers>
<developer/>
</developers>
<scm>
<connection/>
<developerConnection/>
<tag/>
<url/>
</scm>
<properties>
<java.version>21</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-core</artifactId>
</dependency>
<dependency>
<groupId>org.flywaydb</groupId>
<artifactId>flyway-database-postgresql</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- Caffeine cache (권한 캐싱용) -->
<dependency>
<groupId>com.github.ben-manes.caffeine</groupId>
<artifactId>caffeine</artifactId>
</dependency>
<!-- JJWT (Phase 3 인증에서 사용) -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.6</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>0.12.6</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
<!-- PostGIS / Hibernate Spatial -->
<dependency>
<groupId>org.hibernate.orm</groupId>
<artifactId>hibernate-spatial</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<executions>
<execution>
<id>default-compile</id>
<phase>compile</phase>
<goals>
<goal>compile</goal>
</goals>
<configuration>
<parameters>true</parameters>
<annotationProcessorPaths>
<path>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-configuration-processor</artifactId>
</path>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</path>
</annotationProcessorPaths>
</configuration>
</execution>
<execution>
<id>default-testCompile</id>
<phase>test-compile</phase>
<goals>
<goal>testCompile</goal>
</goals>
<configuration>
<parameters>true</parameters>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</path>
</annotationProcessorPaths>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>

파일 보기

@ -1,15 +0,0 @@
package gc.mda.kcg;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cache.annotation.EnableCaching;
@SpringBootApplication
@EnableCaching
public class KcgAiApplication {
public static void main(String[] args) {
SpringApplication.run(KcgAiApplication.class, args);
}
}

파일 보기

@ -1,59 +0,0 @@
package gc.mda.kcg.admin;
import gc.mda.kcg.audit.AccessLog;
import gc.mda.kcg.audit.AccessLogRepository;
import gc.mda.kcg.audit.AuditLog;
import gc.mda.kcg.audit.AuditLogRepository;
import gc.mda.kcg.auth.LoginHistory;
import gc.mda.kcg.auth.LoginHistoryRepository;
import gc.mda.kcg.permission.annotation.RequirePermission;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* 관리자 로그 조회 API.
* - 감사 로그 (auth_audit_log)
* - 접근 이력 (auth_access_log)
* - 로그인 이력 (auth_login_hist)
*/
@RestController
@RequestMapping("/api/admin")
@RequiredArgsConstructor
public class AdminLogController {
private final AuditLogRepository auditLogRepository;
private final AccessLogRepository accessLogRepository;
private final LoginHistoryRepository loginHistoryRepository;
@GetMapping("/audit-logs")
@RequirePermission(resource = "admin:audit-logs", operation = "READ")
public Page<AuditLog> getAuditLogs(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "50") int size
) {
return auditLogRepository.findAllByOrderByCreatedAtDesc(PageRequest.of(page, size));
}
@GetMapping("/access-logs")
@RequirePermission(resource = "admin:access-logs", operation = "READ")
public Page<AccessLog> getAccessLogs(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "50") int size
) {
return accessLogRepository.findAllByOrderByCreatedAtDesc(PageRequest.of(page, size));
}
@GetMapping("/login-history")
@RequirePermission(resource = "admin:login-history", operation = "READ")
public Page<LoginHistory> getLoginHistory(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "50") int size
) {
return loginHistoryRepository.findAllByOrderByLoginDtmDesc(PageRequest.of(page, size));
}
}

파일 보기

@ -1,44 +0,0 @@
package gc.mda.kcg.admin;
import gc.mda.kcg.permission.annotation.RequirePermission;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
/**
* 시스템 관리 대시보드 메트릭 API.
*
* - 감사 로그 / 접근 로그 / 로그인 이력 통계
* - 24시간 / 7일 추세
* - 액션별 / 상태별 분포
*
* 권한: admin:audit-logs, admin:access-logs, admin:login-history (READ)
*/
@RestController
@RequestMapping("/api/admin/stats")
@RequiredArgsConstructor
public class AdminStatsController {
private final AdminStatsService adminStatsService;
@GetMapping("/audit")
@RequirePermission(resource = "admin:audit-logs", operation = "READ")
public Map<String, Object> auditStats() {
return adminStatsService.auditStats();
}
@GetMapping("/access")
@RequirePermission(resource = "admin:access-logs", operation = "READ")
public Map<String, Object> accessStats() {
return adminStatsService.accessStats();
}
@GetMapping("/login")
@RequirePermission(resource = "admin:login-history", operation = "READ")
public Map<String, Object> loginStats() {
return adminStatsService.loginStats();
}
}

파일 보기

@ -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;
}
}

파일 보기

@ -1,138 +0,0 @@
package gc.mda.kcg.admin;
import gc.mda.kcg.audit.annotation.Auditable;
import gc.mda.kcg.auth.User;
import gc.mda.kcg.auth.UserRepository;
import gc.mda.kcg.permission.PermissionService;
import gc.mda.kcg.permission.UserRoleRepository;
import gc.mda.kcg.permission.annotation.RequirePermission;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.web.bind.annotation.*;
import java.util.*;
import java.util.stream.Collectors;
/**
* 사용자 관리 API.
* 권한: admin:user-management
*/
@Slf4j
@RestController
@RequestMapping("/api/admin/users")
@RequiredArgsConstructor
public class UserManagementController {
private final UserRepository userRepository;
private final UserRoleRepository userRoleRepository;
private final PermissionService permissionService;
/**
* 사용자 목록 조회 (역할 코드 포함).
*/
@GetMapping
@RequirePermission(resource = "admin:user-management", operation = "READ")
public List<Map<String, Object>> listUsers() {
List<User> users = userRepository.findAll(
org.springframework.data.domain.Sort.by("userAcnt").ascending());
return users.stream().<Map<String, Object>>map(u -> {
List<String> roles = userRoleRepository.findRoleCodesByUserId(u.getUserId());
Map<String, Object> m = new LinkedHashMap<>();
m.put("userId", u.getUserId().toString());
m.put("userAcnt", u.getUserAcnt());
m.put("userNm", u.getUserNm());
m.put("rnkpNm", u.getRnkpNm());
m.put("email", u.getEmail());
m.put("userSttsCd", u.getUserSttsCd());
m.put("authProvider", u.getAuthProvider());
m.put("failCnt", u.getFailCnt());
m.put("lastLoginDtm", u.getLastLoginDtm());
m.put("createdAt", u.getCreatedAt());
m.put("roles", roles);
return m;
}).toList();
}
/**
* 사용자 통계 (역할별 카운트, 상태별 카운트).
*/
@GetMapping("/stats")
@RequirePermission(resource = "admin:user-management", operation = "READ")
public Map<String, Object> stats() {
List<User> users = userRepository.findAll();
Map<String, Long> byStatus = users.stream()
.collect(Collectors.groupingBy(User::getUserSttsCd, Collectors.counting()));
Map<String, Long> byProvider = users.stream()
.collect(Collectors.groupingBy(User::getAuthProvider, Collectors.counting()));
// 역할별 사용자
Map<String, Long> byRole = new LinkedHashMap<>();
for (User u : users) {
for (String role : userRoleRepository.findRoleCodesByUserId(u.getUserId())) {
byRole.merge(role, 1L, Long::sum);
}
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("total", (long) users.size());
result.put("active", byStatus.getOrDefault("ACTIVE", 0L));
result.put("locked", byStatus.getOrDefault("LOCKED", 0L));
result.put("inactive", byStatus.getOrDefault("INACTIVE", 0L));
result.put("pending", byStatus.getOrDefault("PENDING", 0L));
result.put("byStatus", byStatus);
result.put("byProvider", byProvider);
result.put("byRole", byRole);
return result;
}
/**
* 잠긴 계정 해제.
*/
@Auditable(action = "USER_UNLOCK", resourceType = "USER")
@PostMapping("/{userId}/unlock")
@RequirePermission(resource = "admin:user-management", operation = "UPDATE")
public Map<String, Object> unlockUser(@PathVariable String userId) {
UUID uid = UUID.fromString(userId);
User user = userRepository.findById(uid)
.orElseThrow(() -> new IllegalArgumentException("USER_NOT_FOUND: " + userId));
user.setUserSttsCd("ACTIVE");
user.setFailCnt(0);
userRepository.save(user);
permissionService.evictUserPermissions(uid);
log.info("계정 잠금 해제: {}", user.getUserAcnt());
return Map.of(
"userId", userId,
"userAcnt", user.getUserAcnt(),
"userSttsCd", user.getUserSttsCd()
);
}
/**
* 계정 상태 변경 (ACTIVE/LOCKED/INACTIVE).
*/
@Auditable(action = "USER_STATUS_CHANGE", resourceType = "USER")
@PutMapping("/{userId}/status")
@RequirePermission(resource = "admin:user-management", operation = "UPDATE")
public Map<String, Object> changeStatus(@PathVariable String userId, @RequestBody Map<String, String> body) {
String newStatus = body.get("status");
if (newStatus == null || !Set.of("ACTIVE", "LOCKED", "INACTIVE", "PENDING").contains(newStatus)) {
throw new IllegalArgumentException("INVALID_STATUS: " + newStatus);
}
UUID uid = UUID.fromString(userId);
User user = userRepository.findById(uid)
.orElseThrow(() -> new IllegalArgumentException("USER_NOT_FOUND: " + userId));
user.setUserSttsCd(newStatus);
if ("ACTIVE".equals(newStatus)) {
user.setFailCnt(0);
}
userRepository.save(user);
permissionService.evictUserPermissions(uid);
return Map.of("userId", userId, "userAcnt", user.getUserAcnt(), "userSttsCd", newStatus);
}
}

파일 보기

@ -1,60 +0,0 @@
package gc.mda.kcg.audit;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
import java.time.OffsetDateTime;
import java.util.UUID;
@Entity
@Table(name = "auth_access_log", schema = "kcg")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class AccessLog {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "access_sn")
private Long accessSn;
@JdbcTypeCode(SqlTypes.UUID)
@Column(name = "user_id")
private UUID userId;
@Column(name = "user_acnt", length = 50)
private String userAcnt;
@Column(name = "http_method", length = 10)
private String httpMethod;
@Column(name = "request_path", length = 500)
private String requestPath;
@Column(name = "query_string", columnDefinition = "text")
private String queryString;
@Column(name = "status_code")
private Integer statusCode;
@Column(name = "duration_ms")
private Integer durationMs;
@Column(name = "ip_address", length = 45)
private String ipAddress;
@Column(name = "user_agent", columnDefinition = "text")
private String userAgent;
@Column(name = "created_at", nullable = false)
private OffsetDateTime createdAt;
@PrePersist
void prePersist() {
if (createdAt == null) createdAt = OffsetDateTime.now();
}
}

파일 보기

@ -1,106 +0,0 @@
package gc.mda.kcg.audit;
import gc.mda.kcg.auth.AuthPrincipal;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.core.annotation.Order;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.Executors;
/**
* 모든 HTTP 요청을 auth_access_log에 기록.
* 비동기 기반 요청 처리 지연 최소화.
*/
@Slf4j
@Component
@Order(100) // JwtAuthFilter(Spring 기본 -100) 이후 실행
@RequiredArgsConstructor
public class AccessLogFilter extends OncePerRequestFilter {
private final AccessLogRepository accessLogRepository;
private static final BlockingQueue<AccessLog> QUEUE = new ArrayBlockingQueue<>(10000);
private static volatile boolean workerStarted = false;
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res, FilterChain chain)
throws ServletException, IOException {
long start = System.currentTimeMillis();
try {
chain.doFilter(req, res);
} finally {
ensureWorkerStarted();
try {
AuthPrincipal principal = currentPrincipal();
AccessLog log = AccessLog.builder()
.userId(principal != null ? principal.getUserId() : null)
.userAcnt(principal != null ? principal.getUserAcnt() : null)
.httpMethod(req.getMethod())
.requestPath(req.getRequestURI())
.queryString(req.getQueryString())
.statusCode(res.getStatus())
.durationMs((int) (System.currentTimeMillis() - start))
.ipAddress(extractIp(req))
.userAgent(req.getHeader("User-Agent"))
.build();
QUEUE.offer(log);
} catch (Exception ignored) {
// 접근 로그 실패가 응답을 막지 않도록
}
}
}
@Override
protected boolean shouldNotFilter(HttpServletRequest req) {
String path = req.getRequestURI();
return path.startsWith("/actuator/health") || path.startsWith("/error") || path.equals("/favicon.ico");
}
private void ensureWorkerStarted() {
if (workerStarted) return;
synchronized (AccessLogFilter.class) {
if (workerStarted) return;
workerStarted = true;
Executors.newSingleThreadExecutor(r -> {
Thread t = new Thread(r, "access-log-writer");
t.setDaemon(true);
return t;
}).submit(() -> {
while (true) {
try {
AccessLog log = QUEUE.take();
accessLogRepository.save(log);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
} catch (Exception e) {
AccessLogFilter.log.error("AccessLog 저장 실패", e);
}
}
});
}
}
private AuthPrincipal currentPrincipal() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null && auth.getPrincipal() instanceof AuthPrincipal p) return p;
return null;
}
private String extractIp(HttpServletRequest req) {
String fwd = req.getHeader("X-Forwarded-For");
if (fwd != null && !fwd.isBlank()) return fwd.split(",")[0].trim();
return req.getRemoteAddr();
}
}

파일 보기

@ -1,9 +0,0 @@
package gc.mda.kcg.audit;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
public interface AccessLogRepository extends JpaRepository<AccessLog, Long> {
Page<AccessLog> findAllByOrderByCreatedAtDesc(Pageable pageable);
}

파일 보기

@ -1,62 +0,0 @@
package gc.mda.kcg.audit;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
import java.time.OffsetDateTime;
import java.util.Map;
import java.util.UUID;
@Entity
@Table(name = "auth_audit_log", schema = "kcg")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class AuditLog {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "audit_sn")
private Long auditSn;
@JdbcTypeCode(SqlTypes.UUID)
@Column(name = "user_id")
private UUID userId;
@Column(name = "user_acnt", length = 50)
private String userAcnt;
@Column(name = "action_cd", nullable = false, length = 50)
private String actionCd;
@Column(name = "resource_type", length = 50)
private String resourceType;
@Column(name = "resource_id", length = 100)
private String resourceId;
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "detail", columnDefinition = "jsonb")
private Map<String, Object> detail;
@Column(name = "ip_address", length = 45)
private String ipAddress;
@Column(name = "result", length = 20)
private String result; // SUCCESS / FAILED
@Column(name = "fail_reason", columnDefinition = "text")
private String failReason;
@Column(name = "created_at", nullable = false)
private OffsetDateTime createdAt;
@PrePersist
void prePersist() {
if (createdAt == null) createdAt = OffsetDateTime.now();
}
}

파일 보기

@ -1,13 +0,0 @@
package gc.mda.kcg.audit;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.UUID;
public interface AuditLogRepository extends JpaRepository<AuditLog, Long> {
Page<AuditLog> findAllByOrderByCreatedAtDesc(Pageable pageable);
Page<AuditLog> findByUserIdOrderByCreatedAtDesc(UUID userId, Pageable pageable);
Page<AuditLog> findByActionCdOrderByCreatedAtDesc(String actionCd, Pageable pageable);
}

파일 보기

@ -1,104 +0,0 @@
package gc.mda.kcg.audit.annotation;
import gc.mda.kcg.audit.AuditLog;
import gc.mda.kcg.audit.AuditLogRepository;
import gc.mda.kcg.auth.AuthPrincipal;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.aspectj.lang.ProceedingJoinPoint;
import org.aspectj.lang.annotation.Around;
import org.aspectj.lang.annotation.Aspect;
import org.aspectj.lang.reflect.MethodSignature;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.stereotype.Component;
import org.springframework.web.context.request.RequestContextHolder;
import org.springframework.web.context.request.ServletRequestAttributes;
import java.util.HashMap;
import java.util.Map;
/**
* @Auditable 어노테이션 AOP가 메서드 실행 전후 auth_audit_log 기록.
* 성공/실패 모두 기록.
*/
@Slf4j
@Aspect
@Component
@RequiredArgsConstructor
public class AuditAspect {
private final AuditLogRepository auditLogRepository;
@Around("@annotation(auditable)")
public Object audit(ProceedingJoinPoint pjp, Auditable auditable) throws Throwable {
AuthPrincipal principal = currentPrincipal();
String ipAddress = currentIp();
Map<String, Object> detail = new HashMap<>();
detail.put("method", ((MethodSignature) pjp.getSignature()).getMethod().getName());
// 파라미터 이름은 컴파일 옵션 -parameters 필요 - 여기서는 단순 인덱스로 기록
Object[] args = pjp.getArgs();
if (args != null) {
Map<String, Object> argMap = new HashMap<>();
for (int i = 0; i < args.length; i++) {
Object a = args[i];
if (a == null) continue;
if (a instanceof CharSequence || a instanceof Number || a instanceof Boolean) {
argMap.put("arg" + i, a.toString());
}
}
if (!argMap.isEmpty()) detail.put("args", argMap);
}
try {
Object result = pjp.proceed();
saveLog(principal, auditable, detail, ipAddress, "SUCCESS", null);
return result;
} catch (Throwable e) {
detail.put("exception", e.getClass().getSimpleName());
saveLog(principal, auditable, detail, ipAddress, "FAILED", e.getMessage());
throw e;
}
}
private void saveLog(AuthPrincipal principal, Auditable ann, Map<String, Object> detail,
String ipAddress, String result, String failReason) {
try {
AuditLog log = AuditLog.builder()
.userId(principal != null ? principal.getUserId() : null)
.userAcnt(principal != null ? principal.getUserAcnt() : null)
.actionCd(ann.action())
.resourceType(ann.resourceType())
.ipAddress(ipAddress)
.detail(detail)
.result(result)
.failReason(failReason)
.build();
auditLogRepository.save(log);
} catch (Exception ex) {
// 감사 기록 실패가 비즈니스를 막지 않도록
AuditAspect.log.error("감사로그 기록 실패", ex);
}
}
private AuthPrincipal currentPrincipal() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null && auth.getPrincipal() instanceof AuthPrincipal p) return p;
return null;
}
private String currentIp() {
try {
ServletRequestAttributes attrs = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes();
if (attrs == null) return null;
HttpServletRequest req = attrs.getRequest();
String fwd = req.getHeader("X-Forwarded-For");
if (fwd != null && !fwd.isBlank()) return fwd.split(",")[0].trim();
return req.getRemoteAddr();
} catch (Exception e) {
return null;
}
}
}

파일 보기

@ -1,23 +0,0 @@
package gc.mda.kcg.audit.annotation;
import java.lang.annotation.*;
/**
* 메서드 실행 감사로그 자동 기록.
*
* 사용 :
* <pre>
* @Auditable(action = "CONFIRM_PARENT", resourceType = "GEAR_GROUP")
* public void confirmParent(String groupKey, ...) { ... }
* </pre>
*/
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Auditable {
/** 액션 코드 (예: CONFIRM_PARENT, REJECT_PARENT, USER_CREATE, ROLE_GRANT, PERM_UPDATE) */
String action();
/** 리소스 타입 (예: VESSEL, GROUP, USER, ROLE, SYSTEM) */
String resourceType() default "SYSTEM";
}

파일 보기

@ -1,63 +0,0 @@
package gc.mda.kcg.auth;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.ApplicationRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.password.PasswordEncoder;
import java.util.Map;
/**
* 데모 계정 5종의 BCrypt 해시 시드/갱신 (시동 1회).
* V006이 PLACEHOLDER로 계정을 만들었고, Runner가 실제 해시를 채워넣음.
*
* 데모 계정 비밀번호 (LoginPage의 DEMO_ACCOUNTS와 동일):
* admin / admin1234!
* operator / oper12345!
* analyst / anal12345!
* field / field1234!
* viewer / view12345!
*
* 기존 해시가 PLACEHOLDER가 아니면 갱신하지 않음 (운영 비밀번호 변경 보존).
*/
@Slf4j
@Configuration
@RequiredArgsConstructor
public class AccountSeeder {
private static final String PLACEHOLDER = "PLACEHOLDER_TO_BE_SEEDED";
private static final Map<String, String> DEMO_PASSWORDS = Map.of(
"admin", "admin1234!",
"operator", "oper12345!",
"analyst", "anal12345!",
"field", "field1234!",
"viewer", "view12345!"
);
@Bean
public ApplicationRunner seedDemoAccounts(UserRepository userRepository, PasswordEncoder passwordEncoder) {
return args -> {
int updated = 0;
for (Map.Entry<String, String> e : DEMO_PASSWORDS.entrySet()) {
String acnt = e.getKey();
String rawPw = e.getValue();
userRepository.findByUserAcnt(acnt).ifPresent(user -> {
if (PLACEHOLDER.equals(user.getPswdHash())) {
user.setPswdHash(passwordEncoder.encode(rawPw));
userRepository.save(user);
log.info("데모 계정 BCrypt 해시 시드: {}", acnt);
}
});
if (userRepository.findByUserAcnt(acnt)
.map(u -> u.getPswdHash() != null && !PLACEHOLDER.equals(u.getPswdHash()))
.orElse(false)) {
updated++;
}
}
log.info("AccountSeeder 완료: {}개 데모 계정 활성", updated);
};
}
}

파일 보기

@ -1,110 +0,0 @@
package gc.mda.kcg.auth;
import gc.mda.kcg.auth.dto.LoginRequest;
import gc.mda.kcg.auth.dto.UserInfoResponse;
import gc.mda.kcg.auth.provider.AuthProvider;
import gc.mda.kcg.config.AppProperties;
import gc.mda.kcg.menu.MenuConfigService;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;
import java.util.Map;
@Slf4j
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {
private final AuthService authService;
private final JwtService jwtService;
private final AppProperties appProperties;
private final MenuConfigService menuConfigService;
@PostMapping("/login")
public ResponseEntity<?> login(@RequestBody LoginRequest req,
HttpServletRequest http,
HttpServletResponse res) {
String ip = extractIp(http);
String ua = http.getHeader("User-Agent");
try {
AuthService.AuthResult result = authService.login(req.account(), req.password(), ip, ua);
User user = result.user();
var roles = authService.getUserInfo(user.getUserId()).roles();
String token = jwtService.generateToken(user.getUserId(), user.getUserAcnt(), user.getUserNm(), roles);
Cookie cookie = new Cookie(JwtAuthFilter.COOKIE_NAME, token);
cookie.setHttpOnly(true);
cookie.setPath("/");
cookie.setMaxAge((int) (jwtService.getExpirationMs() / 1000));
// Production에서는 secure=true 권장 (HTTPS)
cookie.setSecure(false);
res.addCookie(cookie);
return ResponseEntity.ok(toUserInfo(user.getUserId()));
} catch (AuthProvider.AuthenticationException e) {
log.warn("Login failed for {}: {}", req.account(), e.getReason());
return ResponseEntity.status(401).body(Map.of(
"error", "LOGIN_FAILED",
"reason", e.getReason()
));
}
}
@PostMapping("/logout")
public ResponseEntity<?> logout(HttpServletRequest http, HttpServletResponse res) {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null && auth.getPrincipal() instanceof AuthPrincipal principal) {
authService.logout(principal.getUserId(), principal.getUserAcnt(), extractIp(http));
}
Cookie cookie = new Cookie(JwtAuthFilter.COOKIE_NAME, "");
cookie.setHttpOnly(true);
cookie.setPath("/");
cookie.setMaxAge(0);
res.addCookie(cookie);
return ResponseEntity.ok(Map.of("ok", true));
}
@GetMapping("/me")
public ResponseEntity<?> me() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || !(auth.getPrincipal() instanceof AuthPrincipal principal)) {
return ResponseEntity.status(401).body(Map.of("error", "UNAUTHENTICATED"));
}
return ResponseEntity.ok(toUserInfo(principal.getUserId()));
}
private UserInfoResponse toUserInfo(java.util.UUID userId) {
AuthService.UserInfo info = authService.getUserInfo(userId);
User u = info.user();
return new UserInfoResponse(
u.getUserId().toString(),
u.getUserAcnt(),
u.getUserNm(),
u.getRnkpNm(),
u.getEmail(),
u.getUserSttsCd(),
u.getAuthProvider(),
info.roles(),
info.permissions(),
menuConfigService.getActiveMenuConfig()
);
}
private String extractIp(HttpServletRequest req) {
String fwd = req.getHeader("X-Forwarded-For");
if (fwd != null && !fwd.isBlank()) return fwd.split(",")[0].trim();
return req.getRemoteAddr();
}
}

파일 보기

@ -1,19 +0,0 @@
package gc.mda.kcg.auth;
import lombok.Builder;
import lombok.Getter;
import java.util.List;
import java.util.UUID;
/**
* 인증된 사용자 컨텍스트 (SecurityContextHolder의 principal 객체).
*/
@Getter
@Builder
public class AuthPrincipal {
private final UUID userId;
private final String userAcnt;
private final String userNm;
private final List<String> roles;
}

파일 보기

@ -1,80 +0,0 @@
package gc.mda.kcg.auth;
import gc.mda.kcg.audit.AuditLog;
import gc.mda.kcg.audit.AuditLogRepository;
import gc.mda.kcg.auth.provider.AuthProvider;
import gc.mda.kcg.auth.provider.PasswordAuthProvider;
import gc.mda.kcg.permission.PermissionService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* 인증 + 로그인 이력/감사 기록.
* 로그인 이력 기록은 LoginAuditWriter (REQUIRES_NEW 트랜잭션) 위임 실패 시에도 기록 보존.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AuthService {
private final PasswordAuthProvider passwordAuthProvider;
private final UserRepository userRepository;
private final AuditLogRepository auditLogRepository;
private final PermissionService permissionService;
private final LoginAuditWriter loginAuditWriter;
/**
* ID/PW 로그인.
* 트랜잭션을 별도 분리: 인증 실패가 외부 호출자(Controller)에서 catch되더라도
* LoginAuditWriter는 REQUIRES_NEW로 별도 커밋되어 기록이 남는다.
*/
public AuthResult login(String userAcnt, String password, String ipAddress, String userAgent) {
AuthProvider.AuthRequest req = new AuthProvider.AuthRequest(userAcnt, password, ipAddress, userAgent);
try {
User user = passwordAuthProvider.authenticate(req);
loginAuditWriter.recordSuccess(user.getUserId(), user.getUserAcnt(), ipAddress, userAgent);
return AuthResult.success(user);
} catch (AuthProvider.AuthenticationException e) {
loginAuditWriter.recordFailure(userAcnt, ipAddress, userAgent, e.getReason());
throw e;
}
}
/**
* 로그아웃 - 감사로그만 기록.
*/
@Transactional
public void logout(UUID userId, String userAcnt, String ipAddress) {
auditLogRepository.save(AuditLog.builder()
.userId(userId)
.userAcnt(userAcnt)
.actionCd("LOGOUT")
.resourceType("SYSTEM")
.resourceId("auth")
.ipAddress(ipAddress)
.result("SUCCESS")
.build());
}
@Transactional(readOnly = true)
public UserInfo getUserInfo(UUID userId) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new IllegalStateException("User not found: " + userId));
List<String> roles = permissionService.getRoleCodesByUserId(userId);
Map<String, List<String>> perms = permissionService.getResolvedPermissionsByUserId(userId);
return new UserInfo(user, roles, perms);
}
public record AuthResult(User user) {
public static AuthResult success(User user) { return new AuthResult(user); }
}
public record UserInfo(User user, List<String> roles, Map<String, List<String>> permissions) {}
}

파일 보기

@ -1,82 +0,0 @@
package gc.mda.kcg.auth;
import io.jsonwebtoken.Claims;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.List;
import java.util.UUID;
@Slf4j
@Component
@RequiredArgsConstructor
public class JwtAuthFilter extends OncePerRequestFilter {
public static final String COOKIE_NAME = "kcg_token";
public static final String AUTH_HEADER = "Authorization";
public static final String BEARER_PREFIX = "Bearer ";
private final JwtService jwtService;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
throws ServletException, IOException {
String token = extractToken(request);
if (token != null && jwtService.isValid(token)) {
try {
Claims claims = jwtService.parseToken(token);
UUID userId = UUID.fromString(claims.getSubject());
String userAcnt = claims.get("acnt", String.class);
String userNm = claims.get("name", String.class);
@SuppressWarnings("unchecked")
List<String> roles = claims.get("roles", List.class);
AuthPrincipal principal = AuthPrincipal.builder()
.userId(userId)
.userAcnt(userAcnt)
.userNm(userNm)
.roles(roles)
.build();
List<SimpleGrantedAuthority> authorities = roles == null ? List.of() :
roles.stream().map(r -> new SimpleGrantedAuthority("ROLE_" + r)).toList();
UsernamePasswordAuthenticationToken auth =
new UsernamePasswordAuthenticationToken(principal, null, authorities);
auth.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(auth);
} catch (Exception e) {
log.debug("JWT processing failed: {}", e.getMessage());
}
}
chain.doFilter(request, response);
}
private String extractToken(HttpServletRequest req) {
// 1. Cookie 우선
if (req.getCookies() != null) {
for (Cookie c : req.getCookies()) {
if (COOKIE_NAME.equals(c.getName())) return c.getValue();
}
}
// 2. Authorization 헤더 fallback
String header = req.getHeader(AUTH_HEADER);
if (header != null && header.startsWith(BEARER_PREFIX)) {
return header.substring(BEARER_PREFIX.length());
}
return null;
}
}

파일 보기

@ -1,74 +0,0 @@
package gc.mda.kcg.auth;
import gc.mda.kcg.config.AppProperties;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.Date;
import java.util.List;
import java.util.UUID;
@Slf4j
@Service
@RequiredArgsConstructor
public class JwtService {
private final AppProperties appProperties;
private SecretKey signingKey;
private SecretKey getSigningKey() {
if (signingKey == null) {
byte[] keyBytes = appProperties.getJwt().getSecret().getBytes(StandardCharsets.UTF_8);
signingKey = Keys.hmacShaKeyFor(keyBytes);
}
return signingKey;
}
public String generateToken(UUID userId, String userAcnt, String userNm, List<String> roles) {
Instant now = Instant.now();
Instant exp = now.plusMillis(appProperties.getJwt().getExpirationMs());
return Jwts.builder()
.subject(userId.toString())
.claim("acnt", userAcnt)
.claim("name", userNm)
.claim("roles", roles)
.issuedAt(Date.from(now))
.expiration(Date.from(exp))
.signWith(getSigningKey())
.compact();
}
public Claims parseToken(String token) {
return Jwts.parser()
.verifyWith(getSigningKey())
.build()
.parseSignedClaims(token)
.getPayload();
}
public UUID extractUserId(String token) {
return UUID.fromString(parseToken(token).getSubject());
}
public boolean isValid(String token) {
try {
parseToken(token);
return true;
} catch (Exception e) {
log.debug("Invalid JWT: {}", e.getMessage());
return false;
}
}
public long getExpirationMs() {
return appProperties.getJwt().getExpirationMs();
}
}

파일 보기

@ -1,68 +0,0 @@
package gc.mda.kcg.auth;
import gc.mda.kcg.audit.AuditLog;
import gc.mda.kcg.audit.AuditLogRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.util.UUID;
/**
* 로그인 이력 + 감사 로그 기록 전용 컴포넌트.
* REQUIRES_NEW 트랜잭션으로 분리 인증 실패로 외부 트랜잭션이 롤백되어도 기록 보존.
*/
@Component
@RequiredArgsConstructor
public class LoginAuditWriter {
private final LoginHistoryRepository loginHistoryRepository;
private final AuditLogRepository auditLogRepository;
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void recordSuccess(UUID userId, String userAcnt, String ipAddress, String userAgent) {
loginHistoryRepository.save(LoginHistory.builder()
.userId(userId)
.userAcnt(userAcnt)
.loginIp(ipAddress)
.userAgent(userAgent)
.result("SUCCESS")
.authProvider("PASSWORD")
.build());
auditLogRepository.save(AuditLog.builder()
.userId(userId)
.userAcnt(userAcnt)
.actionCd("LOGIN")
.resourceType("SYSTEM")
.resourceId("auth")
.ipAddress(ipAddress)
.result("SUCCESS")
.build());
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void recordFailure(String userAcnt, String ipAddress, String userAgent, String failReason) {
String result = failReason != null && failReason.startsWith("MAX_FAIL") ? "LOCKED" : "FAILED";
loginHistoryRepository.save(LoginHistory.builder()
.userAcnt(userAcnt)
.loginIp(ipAddress)
.userAgent(userAgent)
.result(result)
.failReason(failReason)
.authProvider("PASSWORD")
.build());
auditLogRepository.save(AuditLog.builder()
.userAcnt(userAcnt)
.actionCd("LOGIN")
.resourceType("SYSTEM")
.resourceId("auth")
.ipAddress(ipAddress)
.result("FAILED")
.failReason(failReason)
.build());
}
}

파일 보기

@ -1,54 +0,0 @@
package gc.mda.kcg.auth;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
import java.time.OffsetDateTime;
import java.util.UUID;
@Entity
@Table(name = "auth_login_hist", schema = "kcg")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class LoginHistory {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "hist_sn")
private Long histSn;
@JdbcTypeCode(SqlTypes.UUID)
@Column(name = "user_id")
private UUID userId;
@Column(name = "user_acnt", length = 50)
private String userAcnt;
@Column(name = "login_dtm", nullable = false)
private OffsetDateTime loginDtm;
@Column(name = "login_ip", length = 45)
private String loginIp;
@Column(name = "user_agent", columnDefinition = "text")
private String userAgent;
@Column(name = "result", nullable = false, length = 20)
private String result; // SUCCESS, FAILED, LOCKED
@Column(name = "fail_reason", length = 255)
private String failReason;
@Column(name = "auth_provider", length = 20)
private String authProvider;
@PrePersist
void prePersist() {
if (loginDtm == null) loginDtm = OffsetDateTime.now();
}
}

파일 보기

@ -1,12 +0,0 @@
package gc.mda.kcg.auth;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.UUID;
public interface LoginHistoryRepository extends JpaRepository<LoginHistory, Long> {
Page<LoginHistory> findByUserIdOrderByLoginDtmDesc(UUID userId, Pageable pageable);
Page<LoginHistory> findAllByOrderByLoginDtmDesc(Pageable pageable);
}

파일 보기

@ -1,87 +0,0 @@
package gc.mda.kcg.auth;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
import java.time.OffsetDateTime;
import java.util.UUID;
@Entity
@Table(name = "auth_user", schema = "kcg")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class User {
@Id
@JdbcTypeCode(SqlTypes.UUID)
@Column(name = "user_id", updatable = false, nullable = false)
private UUID userId;
@Column(name = "user_acnt", nullable = false, unique = true, length = 50)
private String userAcnt;
@Column(name = "pswd_hash", length = 255)
private String pswdHash;
@Column(name = "user_nm", nullable = false, length = 100)
private String userNm;
@Column(name = "rnkp_nm", length = 50)
private String rnkpNm;
@Column(name = "email", length = 255)
private String email;
@Column(name = "org_sn")
private Long orgSn;
@Column(name = "user_stts_cd", nullable = false, length = 20)
private String userSttsCd; // PENDING/ACTIVE/LOCKED/INACTIVE/REJECTED
@Column(name = "fail_cnt", nullable = false)
private Integer failCnt;
@Column(name = "last_login_dtm")
private OffsetDateTime lastLoginDtm;
@Column(name = "auth_provider", nullable = false, length = 20)
private String authProvider; // PASSWORD/GPKI/SSO
@Column(name = "provider_sub", length = 255)
private String providerSub;
@Column(name = "created_at", nullable = false)
private OffsetDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private OffsetDateTime updatedAt;
@PrePersist
void prePersist() {
if (userId == null) userId = UUID.randomUUID();
OffsetDateTime now = OffsetDateTime.now();
if (createdAt == null) createdAt = now;
if (updatedAt == null) updatedAt = now;
if (failCnt == null) failCnt = 0;
if (userSttsCd == null) userSttsCd = "PENDING";
if (authProvider == null) authProvider = "PASSWORD";
}
@PreUpdate
void preUpdate() {
updatedAt = OffsetDateTime.now();
}
public boolean isActive() {
return "ACTIVE".equals(userSttsCd);
}
public boolean isLocked() {
return "LOCKED".equals(userSttsCd);
}
}

파일 보기

@ -1,11 +0,0 @@
package gc.mda.kcg.auth;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
import java.util.UUID;
public interface UserRepository extends JpaRepository<User, UUID> {
Optional<User> findByUserAcnt(String userAcnt);
boolean existsByUserAcnt(String userAcnt);
}

파일 보기

@ -1,8 +0,0 @@
package gc.mda.kcg.auth.dto;
import jakarta.validation.constraints.NotBlank;
public record LoginRequest(
@NotBlank String account,
@NotBlank String password
) {}

파일 보기

@ -1,19 +0,0 @@
package gc.mda.kcg.auth.dto;
import gc.mda.kcg.menu.MenuConfigDto;
import java.util.List;
import java.util.Map;
public record UserInfoResponse(
String id,
String account,
String name,
String rank,
String email,
String status,
String authProvider,
List<String> roles,
Map<String, List<String>> permissions,
List<MenuConfigDto> menuConfig
) {}

파일 보기

@ -1,39 +0,0 @@
package gc.mda.kcg.auth.provider;
import gc.mda.kcg.auth.User;
/**
* 인증 방식 확장 포인트.
* Phase 3: PASSWORD만 구현.
* Phase 9 (TODO): GPKI(공무원 인증서), SSO(SAML/OIDC) 추가.
*/
public interface AuthProvider {
/**
* 인증 방식 식별자: PASSWORD / GPKI / SSO
*/
String getProviderType();
/**
* 인증 수행. 성공 User 반환, 실패 AuthenticationException 발생.
*/
User authenticate(AuthRequest request) throws AuthenticationException;
record AuthRequest(
String userAcnt,
String credential, // 비밀번호 또는 인증서/SSO 토큰
String ipAddress,
String userAgent
) {}
class AuthenticationException extends RuntimeException {
private final String reason;
public AuthenticationException(String reason) {
super(reason);
this.reason = reason;
}
public String getReason() { return reason; }
}
}

파일 보기

@ -1,71 +0,0 @@
package gc.mda.kcg.auth.provider;
import gc.mda.kcg.auth.User;
import gc.mda.kcg.auth.UserRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import java.time.OffsetDateTime;
/**
* 자체 ID/PW 인증 (BCrypt).
* Phase 1 인증 방식 Phase 9에서 GPKI/SSO 추가 예정.
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class PasswordAuthProvider implements AuthProvider {
private static final int MAX_FAIL_ATTEMPTS = 5;
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
@Override
public String getProviderType() {
return "PASSWORD";
}
@Override
@Transactional(propagation = Propagation.REQUIRES_NEW, noRollbackFor = AuthenticationException.class)
public User authenticate(AuthRequest request) {
User user = userRepository.findByUserAcnt(request.userAcnt())
.orElseThrow(() -> new AuthenticationException("USER_NOT_FOUND"));
// 상태 검증
if (user.isLocked()) {
throw new AuthenticationException("ACCOUNT_LOCKED");
}
if (!user.isActive()) {
throw new AuthenticationException("ACCOUNT_NOT_ACTIVE:" + user.getUserSttsCd());
}
// PASSWORD provider만 처리
if (!"PASSWORD".equals(user.getAuthProvider())) {
throw new AuthenticationException("WRONG_PROVIDER:" + user.getAuthProvider());
}
// BCrypt 비교
if (user.getPswdHash() == null || !passwordEncoder.matches(request.credential(), user.getPswdHash())) {
int newFailCnt = user.getFailCnt() + 1;
user.setFailCnt(newFailCnt);
if (newFailCnt >= MAX_FAIL_ATTEMPTS) {
user.setUserSttsCd("LOCKED");
userRepository.save(user);
throw new AuthenticationException("MAX_FAIL_LOCKED");
}
userRepository.save(user);
throw new AuthenticationException("WRONG_PASSWORD:" + newFailCnt);
}
// 로그인 성공: 실패 카운터 초기화 + 마지막 로그인 시각 갱신
user.setFailCnt(0);
user.setLastLoginDtm(OffsetDateTime.now());
userRepository.save(user);
return user;
}
}

파일 보기

@ -1,55 +0,0 @@
package gc.mda.kcg.common.exception;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.authentication.AuthenticationCredentialsNotFoundException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.util.Map;
/**
* 전역 예외 처리.
* - IllegalArgumentException 400
* - AccessDeniedException 403
* - AuthenticationCredentialsNotFoundException 401
*/
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(IllegalArgumentException.class)
public ResponseEntity<Map<String, Object>> handleIllegal(IllegalArgumentException e) {
log.debug("400 Bad Request: {}", e.getMessage());
return ResponseEntity.badRequest().body(Map.of(
"error", "BAD_REQUEST",
"message", e.getMessage() == null ? "" : e.getMessage()
));
}
@ExceptionHandler(IllegalStateException.class)
public ResponseEntity<Map<String, Object>> handleIllegalState(IllegalStateException e) {
log.debug("409 Conflict: {}", e.getMessage());
return ResponseEntity.status(409).body(Map.of(
"error", "CONFLICT",
"message", e.getMessage() == null ? "" : e.getMessage()
));
}
@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity<Map<String, Object>> handleAccessDenied(AccessDeniedException e) {
return ResponseEntity.status(403).body(Map.of(
"error", "FORBIDDEN",
"message", e.getMessage() == null ? "" : e.getMessage()
));
}
@ExceptionHandler(AuthenticationCredentialsNotFoundException.class)
public ResponseEntity<Map<String, Object>> handleNoAuth(AuthenticationCredentialsNotFoundException e) {
return ResponseEntity.status(401).body(Map.of(
"error", "UNAUTHENTICATED",
"message", e.getMessage() == null ? "" : e.getMessage()
));
}
}

파일 보기

@ -1,39 +0,0 @@
package gc.mda.kcg.config;
import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;
@Configuration
@ConfigurationProperties(prefix = "app")
@Getter
@Setter
public class AppProperties {
private Prediction prediction = new Prediction();
private SignalBatch signalBatch = new SignalBatch();
private Cors cors = new Cors();
private Jwt jwt = new Jwt();
@Getter @Setter
public static class Prediction {
private String baseUrl;
}
@Getter @Setter
public static class SignalBatch {
private String baseUrl;
}
@Getter @Setter
public static class Cors {
private String allowedOrigins;
}
@Getter @Setter
public static class Jwt {
private String secret;
private long expirationMs;
}
}

파일 보기

@ -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,84 +0,0 @@
package gc.mda.kcg.config;
import gc.mda.kcg.auth.JwtAuthFilter;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.Arrays;
import java.util.List;
/**
* Phase 3: JWT 기반 인증 + 트리 RBAC 권한 체계.
*
* - JwtAuthFilter가 토큰 파싱 SecurityContext에 AuthPrincipal 주입
* - 권한 체크는 @RequirePermission 어노테이션 (PermissionAspect) 담당
* - 세션 STATELESS
*/
@Configuration
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtAuthFilter jwtAuthFilter;
private final AppProperties appProperties;
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
String origins = appProperties.getCors().getAllowedOrigins();
if (origins != null && !origins.isBlank()) {
config.setAllowedOrigins(Arrays.asList(origins.split(",")));
}
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"));
config.setAllowedHeaders(List.of("*"));
config.setAllowCredentials(true);
config.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return source;
}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/actuator/health", "/actuator/info").permitAll()
.requestMatchers("/api/auth/login", "/api/auth/logout").permitAll()
.requestMatchers("/error").permitAll()
.anyRequest().authenticated()
)
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
.exceptionHandling(eh -> eh
.authenticationEntryPoint((req, res, ex) -> {
res.setStatus(401);
res.setContentType("application/json");
res.getWriter().write("{\"error\":\"UNAUTHENTICATED\",\"message\":\"" + ex.getMessage() + "\"}");
})
.accessDeniedHandler((req, res, ex) -> {
res.setStatus(403);
res.setContentType("application/json");
res.getWriter().write("{\"error\":\"FORBIDDEN\",\"message\":\"" + ex.getMessage() + "\"}");
})
);
return http.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;
}
}

파일 보기

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

파일 보기

@ -1,134 +0,0 @@
package gc.mda.kcg.domain.analysis;
import gc.mda.kcg.permission.annotation.RequirePermission;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.web.bind.annotation.*;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
import java.util.List;
/**
* vessel_analysis_results 직접 조회 API.
* prediction이 kcgaidb에 저장한 분석 결과를 프론트엔드에 직접 제공한다 (/api/analysis/*).
*/
@RestController
@RequestMapping("/api/analysis")
@RequiredArgsConstructor
public class VesselAnalysisController {
private final VesselAnalysisService service;
/**
* 분석 결과 목록 조회 (필터 + 페이징).
* 기본: 최근 1시간 결과.
*/
@GetMapping("/vessels")
@RequirePermission(resource = "detection:dark-vessel", operation = "READ")
public Page<VesselAnalysisResponse> listVessels(
@RequestParam(required = false) String mmsi,
@RequestParam(required = false) String zoneCode,
@RequestParam(required = false) String riskLevel,
@RequestParam(required = false) Boolean isDark,
@RequestParam(required = false) String mmsiPrefix,
@RequestParam(required = false) Integer minRiskScore,
@RequestParam(required = false) BigDecimal minFishingPct,
@RequestParam(defaultValue = "1") int hours,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "50") int size
) {
OffsetDateTime after = OffsetDateTime.now().minusHours(hours);
return service.getAnalysisResults(
mmsi, zoneCode, riskLevel, isDark,
mmsiPrefix, minRiskScore, minFishingPct, after,
PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "analyzedAt"))
).map(VesselAnalysisResponse::from);
}
/**
* MMSI별 최신 row 기준 집계 (단일 쿼리 COUNT FILTER).
* - hours: 윈도우 (기본 1시간)
* - mmsiPrefix: '412' 같은 MMSI prefix 필터 (선택)
*/
@GetMapping("/stats")
@RequirePermission(resource = "detection:dark-vessel", operation = "READ")
public AnalysisStatsResponse getStats(
@RequestParam(defaultValue = "1") int hours,
@RequestParam(required = false) String mmsiPrefix
) {
return service.getStats(hours, mmsiPrefix);
}
/**
* prediction 자동 어구 탐지 결과 목록.
* gear_code/gear_judgment NOT NULL row만 MMSI 중복 제거 반환.
*/
@GetMapping("/gear-detections")
@RequirePermission(resource = "detection:dark-vessel", operation = "READ")
public Page<GearDetectionResponse> listGearDetections(
@RequestParam(defaultValue = "1") int hours,
@RequestParam(required = false) String mmsiPrefix,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "50") int size
) {
return service.getGearDetections(hours, mmsiPrefix,
PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "analyzedAt"))
).map(GearDetectionResponse::from);
}
/**
* 특정 선박 최신 분석 결과 (features 포함).
*/
@GetMapping("/vessels/{mmsi}")
@RequirePermission(resource = "detection:dark-vessel", operation = "READ")
public VesselAnalysisResponse getLatest(@PathVariable String mmsi) {
return VesselAnalysisResponse.from(service.getLatestByMmsi(mmsi));
}
/**
* 특정 선박 분석 이력 (기본 24시간).
*/
@GetMapping("/vessels/{mmsi}/history")
@RequirePermission(resource = "detection:dark-vessel", operation = "READ")
public List<VesselAnalysisResponse> getHistory(
@PathVariable String mmsi,
@RequestParam(defaultValue = "24") int hours
) {
return service.getHistory(mmsi, hours).stream()
.map(VesselAnalysisResponse::from)
.toList();
}
/**
* 다크 베셀 목록 (최신 분석, MMSI 중복 제거).
*/
@GetMapping("/dark")
@RequirePermission(resource = "detection:dark-vessel", operation = "READ")
public Page<VesselAnalysisResponse> listDarkVessels(
@RequestParam(defaultValue = "1") int hours,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "50") int size
) {
return service.getDarkVessels(hours,
PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "riskScore"))
).map(VesselAnalysisResponse::from);
}
/**
* 환적 의심 목록 (최신 분석, MMSI 중복 제거).
*/
@GetMapping("/transship")
@RequirePermission(resource = "detection:dark-vessel", operation = "READ")
public Page<VesselAnalysisResponse> listTransshipSuspects(
@RequestParam(defaultValue = "1") int hours,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "50") int size
) {
return service.getTransshipSuspects(hours,
PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "riskScore"))
).map(VesselAnalysisResponse::from);
}
}

파일 보기

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

파일 보기

@ -1,129 +0,0 @@
package gc.mda.kcg.domain.analysis;
import gc.mda.kcg.permission.annotation.RequirePermission;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.client.RestClient;
import org.springframework.web.client.RestClientException;
import java.util.List;
import java.util.Map;
/**
* 분석 데이터 API group_polygon_snapshots / gear_correlation_scores 직접 DB 조회
* + signal-batch 선박 항적 프록시.
*
* 라우팅:
* GET /api/vessel-analysis/groups 어구/선단 그룹 + parentResolution 합성
* GET /api/vessel-analysis/groups/{key}/detail 단일 그룹 상세 + 24h 이력
* GET /api/vessel-analysis/groups/{key}/correlations 상관관계 점수
*
* 권한: detection:gear-detection (READ)
*/
@Slf4j
@RestController
@RequestMapping("/api/vessel-analysis")
@RequiredArgsConstructor
public class VesselAnalysisProxyController {
private final VesselAnalysisGroupService groupService;
@Qualifier("signalBatchRestClient")
private final RestClient signalBatchClient;
@GetMapping
@RequirePermission(resource = "detection:dark-vessel", operation = "READ")
public ResponseEntity<?> getVesselAnalysis() {
// vessel_analysis_results 직접 조회는 /api/analysis/vessels 사용.
// 엔드포인트는 하위 호환을 위해 구조 반환.
return ResponseEntity.ok(Map.of(
"serviceAvailable", true,
"message", "vessel_analysis_results는 /api/analysis/vessels 에서 제공됩니다.",
"items", List.of(),
"stats", Map.of(),
"count", 0
));
}
/**
* 그룹 목록 + 자체 DB의 parentResolution 합성.
*/
@GetMapping("/groups")
@RequirePermission(resource = "detection:gear-detection", operation = "READ")
public ResponseEntity<?> getGroups(
@RequestParam(required = false) String groupType
) {
Map<String, Object> result = groupService.getGroups(groupType);
return ResponseEntity.ok(result);
}
@GetMapping("/groups/{groupKey}/detail")
@RequirePermission(resource = "detection:gear-detection", operation = "READ")
public ResponseEntity<?> getGroupDetail(@PathVariable String groupKey) {
Map<String, Object> result = groupService.getGroupDetail(groupKey);
return ResponseEntity.ok(result);
}
@GetMapping("/groups/{groupKey}/correlations")
@RequirePermission(resource = "detection:gear-detection", operation = "READ")
public ResponseEntity<?> getGroupCorrelations(
@PathVariable String groupKey,
@RequestParam(required = false) Double minScore
) {
Map<String, Object> result = groupService.getGroupCorrelations(groupKey, minScore);
return ResponseEntity.ok(result);
}
/**
* 후보 상세 raw metrics (최근 20건 관측 이력).
*/
@GetMapping("/groups/{groupKey}/candidates/{targetMmsi}/metrics")
@RequirePermission(resource = "detection:gear-detection", operation = "READ")
public ResponseEntity<?> getCandidateMetrics(
@PathVariable String groupKey,
@PathVariable String targetMmsi
) {
return ResponseEntity.ok(groupService.getCandidateMetrics(groupKey, targetMmsi));
}
/**
* 모선 확정/제외.
* POST body: { "action": "confirm"|"reject", "targetMmsi": "...", "comment": "..." }
*/
@PostMapping("/groups/{groupKey}/resolve")
@RequirePermission(resource = "detection:gear-detection", operation = "UPDATE")
public ResponseEntity<?> resolveParent(
@PathVariable String groupKey,
@RequestBody Map<String, String> body
) {
String action = body.getOrDefault("action", "");
String targetMmsi = body.getOrDefault("targetMmsi", "");
String comment = body.getOrDefault("comment", "");
return ResponseEntity.ok(groupService.resolveParent(groupKey, action, targetMmsi, comment));
}
/**
* 선박 항적 일괄 조회 (signal-batch 프록시).
* POST /api/vessel-analysis/tracks signal-batch /api/v2/tracks/vessels
*/
@PostMapping("/tracks")
@RequirePermission(resource = "detection:gear-detection", operation = "READ")
public ResponseEntity<?> vesselTracks(@RequestBody Map<String, Object> body) {
try {
String json = signalBatchClient.post()
.uri("/api/v2/tracks/vessels")
.body(body)
.retrieve()
.body(String.class);
return ResponseEntity.ok()
.header("Content-Type", "application/json")
.body(json != null ? json : "[]");
} catch (RestClientException e) {
log.warn("signal-batch 항적 조회 실패: {}", e.getMessage());
return ResponseEntity.ok("[]");
}
}
}

파일 보기

@ -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);
}
}

파일 보기

@ -1,98 +0,0 @@
package gc.mda.kcg.domain.enforcement;
import gc.mda.kcg.domain.enforcement.dto.CreatePlanRequest;
import gc.mda.kcg.domain.enforcement.dto.CreateRecordRequest;
import gc.mda.kcg.domain.enforcement.dto.UpdateRecordRequest;
import gc.mda.kcg.permission.annotation.RequirePermission;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
/**
* 단속 이력/계획 CRUD API.
* enforcement_records, enforcement_plans 테이블 기반.
*/
@RestController
@RequestMapping("/api/enforcement")
@RequiredArgsConstructor
public class EnforcementController {
private final EnforcementService service;
// ========================================================================
// 단속 이력 (Records)
// ========================================================================
/**
* 단속 이력 목록 조회 (violationType 필터, 페이징)
*/
@GetMapping("/records")
@RequirePermission(resource = "enforcement:enforcement-history", operation = "READ")
public Page<EnforcementRecord> listRecords(
@RequestParam(required = false) String violationType,
@RequestParam(required = false) String vesselMmsi,
Pageable pageable
) {
return service.listRecords(violationType, vesselMmsi, pageable);
}
/**
* 단속 이력 상세 조회
*/
@GetMapping("/records/{id}")
@RequirePermission(resource = "enforcement:enforcement-history", operation = "READ")
public EnforcementRecord getRecord(@PathVariable Long id) {
return service.getRecord(id);
}
/**
* 단속 이력 신규 등록. UID 자동 생성 (ENF-yyyyMMdd-NNNN).
* event_id가 있으면 해당 prediction_events.status를 RESOLVED로 갱신.
*/
@PostMapping("/records")
@ResponseStatus(HttpStatus.CREATED)
@RequirePermission(resource = "enforcement:enforcement-history", operation = "CREATE")
public EnforcementRecord createRecord(@RequestBody CreateRecordRequest req) {
return service.createRecord(req);
}
/**
* 단속 이력 결과 수정 (result, ai_match_status, remarks)
*/
@PatchMapping("/records/{id}")
@RequirePermission(resource = "enforcement:enforcement-history", operation = "UPDATE")
public EnforcementRecord updateRecord(
@PathVariable Long id,
@RequestBody UpdateRecordRequest req
) {
return service.updateRecord(id, req);
}
// ========================================================================
// 단속 계획 (Plans)
// ========================================================================
/**
* 단속 계획 목록 조회 (status 필터, 페이징)
*/
@GetMapping("/plans")
@RequirePermission(resource = "enforcement:enforcement-history", operation = "READ")
public Page<EnforcementPlan> listPlans(
@RequestParam(required = false) String status,
Pageable pageable
) {
return service.listPlans(status, pageable);
}
/**
* 단속 계획 생성
*/
@PostMapping("/plans")
@ResponseStatus(HttpStatus.CREATED)
@RequirePermission(resource = "enforcement:enforcement-history", operation = "CREATE")
public EnforcementPlan createPlan(@RequestBody CreatePlanRequest req) {
return service.createPlan(req);
}
}

파일 보기

@ -1,100 +0,0 @@
package gc.mda.kcg.domain.enforcement;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.util.UUID;
/**
* 단속 계획.
* 향후 단속 예정 계획을 관리.
*/
@Entity
@Table(name = "enforcement_plans", schema = "kcg",
uniqueConstraints = @UniqueConstraint(columnNames = "plan_uid"))
@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder
public class EnforcementPlan {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "plan_uid", nullable = false, length = 50, unique = true)
private String planUid;
@Column(name = "title", length = 200)
private String title;
@Column(name = "zone_code", length = 30)
private String zoneCode;
@Column(name = "area_name", length = 100)
private String areaName;
@Column(name = "lat")
private Double lat;
@Column(name = "lon")
private Double lon;
@Column(name = "planned_date")
private LocalDate plannedDate;
@Column(name = "planned_from")
private OffsetDateTime plannedFrom;
@Column(name = "planned_to")
private OffsetDateTime plannedTo;
@Column(name = "risk_level", length = 20)
private String riskLevel;
@Column(name = "risk_score")
private Integer riskScore;
@Column(name = "assigned_ship_count")
private Integer assignedShipCount;
@Column(name = "assigned_crew")
private Integer assignedCrew;
@Column(name = "status", nullable = false, length = 20)
private String status;
@Column(name = "alert_status", length = 20)
private String alertStatus;
@JdbcTypeCode(SqlTypes.UUID)
@Column(name = "created_by")
private UUID createdBy;
@JdbcTypeCode(SqlTypes.UUID)
@Column(name = "approved_by")
private UUID approvedBy;
@Column(name = "remarks", columnDefinition = "text")
private String remarks;
@Column(name = "created_at", nullable = false)
private OffsetDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private OffsetDateTime updatedAt;
@PrePersist
void prePersist() {
OffsetDateTime now = OffsetDateTime.now();
if (createdAt == null) createdAt = now;
if (updatedAt == null) updatedAt = now;
if (status == null) status = "DRAFT";
}
@PreUpdate
void preUpdate() {
updatedAt = OffsetDateTime.now();
}
}

파일 보기

@ -1,101 +0,0 @@
package gc.mda.kcg.domain.enforcement;
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.UUID;
/**
* 단속 이력.
* 실제 단속 수행 기록을 저장.
*/
@Entity
@Table(name = "enforcement_records", schema = "kcg",
uniqueConstraints = @UniqueConstraint(columnNames = "enf_uid"))
@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder
public class EnforcementRecord {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "enf_uid", nullable = false, length = 50, unique = true)
private String enfUid;
@Column(name = "event_id")
private Long eventId;
@Column(name = "enforced_at")
private OffsetDateTime enforcedAt;
@Column(name = "zone_code", length = 30)
private String zoneCode;
@Column(name = "area_name", length = 100)
private String areaName;
@Column(name = "lat")
private Double lat;
@Column(name = "lon")
private Double lon;
@Column(name = "vessel_mmsi", length = 20)
private String vesselMmsi;
@Column(name = "vessel_name", length = 100)
private String vesselName;
@Column(name = "flag_country", length = 10)
private String flagCountry;
@Column(name = "violation_type", length = 50)
private String violationType;
@Column(name = "action", length = 50)
private String action;
@Column(name = "result", length = 50)
private String result;
@Column(name = "ai_match_status", length = 20)
private String aiMatchStatus;
@Column(name = "ai_confidence", precision = 5, scale = 4)
private BigDecimal aiConfidence;
@Column(name = "patrol_ship_id")
private Long patrolShipId;
@JdbcTypeCode(SqlTypes.UUID)
@Column(name = "enforced_by")
private UUID enforcedBy;
@Column(name = "enforced_by_name", length = 100)
private String enforcedByName;
@Column(name = "remarks", columnDefinition = "text")
private String remarks;
@Column(name = "created_at", nullable = false)
private OffsetDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private OffsetDateTime updatedAt;
@PrePersist
void prePersist() {
OffsetDateTime now = OffsetDateTime.now();
if (createdAt == null) createdAt = now;
if (updatedAt == null) updatedAt = now;
}
@PreUpdate
void preUpdate() {
updatedAt = OffsetDateTime.now();
}
}

파일 보기

@ -1,153 +0,0 @@
package gc.mda.kcg.domain.enforcement;
import gc.mda.kcg.audit.annotation.Auditable;
import gc.mda.kcg.domain.enforcement.dto.CreatePlanRequest;
import gc.mda.kcg.domain.enforcement.dto.CreateRecordRequest;
import gc.mda.kcg.domain.enforcement.dto.UpdateRecordRequest;
import gc.mda.kcg.domain.enforcement.repository.EnforcementPlanRepository;
import gc.mda.kcg.domain.enforcement.repository.EnforcementRecordRepository;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityNotFoundException;
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.time.LocalDate;
import java.time.OffsetDateTime;
import java.time.format.DateTimeFormatter;
import java.util.UUID;
@Service
@RequiredArgsConstructor
public class EnforcementService {
private final EnforcementRecordRepository recordRepository;
private final EnforcementPlanRepository planRepository;
private final EntityManager entityManager;
private static final DateTimeFormatter UID_DATE_FMT = DateTimeFormatter.ofPattern("yyyyMMdd");
// ========================================================================
// 단속 이력
// ========================================================================
public Page<EnforcementRecord> listRecords(String violationType, String vesselMmsi, Pageable pageable) {
if (vesselMmsi != null && !vesselMmsi.isBlank()) {
return recordRepository.findByVesselMmsiOrderByEnforcedAtDesc(vesselMmsi, pageable);
}
if (violationType != null && !violationType.isBlank()) {
return recordRepository.findByViolationType(violationType, pageable);
}
return recordRepository.findAllByOrderByEnforcedAtDesc(pageable);
}
public EnforcementRecord getRecord(Long id) {
return recordRepository.findById(id)
.orElseThrow(() -> new EntityNotFoundException("EnforcementRecord not found: " + id));
}
@Transactional
@Auditable(action = "ENFORCEMENT_CREATE", resourceType = "ENFORCEMENT")
public EnforcementRecord createRecord(CreateRecordRequest req) {
EnforcementRecord record = EnforcementRecord.builder()
.enfUid(generateEnfUid())
.eventId(req.eventId())
.enforcedAt(req.enforcedAt())
.zoneCode(req.zoneCode())
.areaName(req.areaName())
.lat(req.lat())
.lon(req.lon())
.vesselMmsi(req.vesselMmsi())
.vesselName(req.vesselName())
.flagCountry(req.flagCountry())
.violationType(req.violationType())
.action(req.action())
.result(req.result())
.aiMatchStatus(req.aiMatchStatus())
.aiConfidence(req.aiConfidence())
.patrolShipId(req.patrolShipId())
.enforcedBy(req.enforcedBy())
.enforcedByName(req.enforcedByName())
.remarks(req.remarks())
.build();
EnforcementRecord saved = recordRepository.save(record);
// event_id가 있으면 prediction_events.status를 RESOLVED로 갱신
if (req.eventId() != null) {
entityManager.createQuery(
"UPDATE PredictionEvent e SET e.status = 'RESOLVED', e.resolvedAt = :now, e.updatedAt = :now WHERE e.id = :eventId"
)
.setParameter("now", OffsetDateTime.now())
.setParameter("eventId", req.eventId())
.executeUpdate();
}
return saved;
}
@Transactional
@Auditable(action = "ENFORCEMENT_UPDATE", resourceType = "ENFORCEMENT")
public EnforcementRecord updateRecord(Long id, UpdateRecordRequest req) {
EnforcementRecord record = getRecord(id);
if (req.result() != null) record.setResult(req.result());
if (req.aiMatchStatus() != null) record.setAiMatchStatus(req.aiMatchStatus());
if (req.remarks() != null) record.setRemarks(req.remarks());
return recordRepository.save(record);
}
// ========================================================================
// 단속 계획
// ========================================================================
public Page<EnforcementPlan> listPlans(String status, Pageable pageable) {
if (status != null && !status.isBlank()) {
return planRepository.findByStatusOrderByPlannedDateAsc(status, pageable);
}
return planRepository.findAllByOrderByPlannedDateDesc(pageable);
}
@Transactional
@Auditable(action = "ENFORCEMENT_PLAN_CREATE", resourceType = "ENFORCEMENT")
public EnforcementPlan createPlan(CreatePlanRequest req) {
EnforcementPlan plan = EnforcementPlan.builder()
.planUid("PLN-" + LocalDate.now().format(UID_DATE_FMT) + "-" + UUID.randomUUID().toString().substring(0, 4).toUpperCase())
.title(req.title())
.zoneCode(req.zoneCode())
.areaName(req.areaName())
.lat(req.lat())
.lon(req.lon())
.plannedDate(req.plannedDate())
.plannedFrom(req.plannedFrom())
.plannedTo(req.plannedTo())
.riskLevel(req.riskLevel())
.riskScore(req.riskScore())
.assignedShipCount(req.assignedShipCount())
.assignedCrew(req.assignedCrew())
.alertStatus(req.alertStatus())
.createdBy(req.createdBy())
.remarks(req.remarks())
.build();
return planRepository.save(plan);
}
// ========================================================================
// UID 생성: ENF-yyyyMMdd-NNNN ( 단위 시퀀스)
// ========================================================================
private String generateEnfUid() {
String dateStr = LocalDate.now().format(UID_DATE_FMT);
String prefix = "ENF-" + dateStr + "-";
Long count = (Long) entityManager.createQuery(
"SELECT COUNT(r) FROM EnforcementRecord r WHERE r.enfUid LIKE :prefix"
)
.setParameter("prefix", prefix + "%")
.getSingleResult();
return prefix + String.format("%04d", count + 1);
}
}

파일 보기

@ -1,23 +0,0 @@
package gc.mda.kcg.domain.enforcement.dto;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.util.UUID;
public record CreatePlanRequest(
String title,
String zoneCode,
String areaName,
Double lat,
Double lon,
LocalDate plannedDate,
OffsetDateTime plannedFrom,
OffsetDateTime plannedTo,
String riskLevel,
Integer riskScore,
Integer assignedShipCount,
Integer assignedCrew,
String alertStatus,
UUID createdBy,
String remarks
) {}

파일 보기

@ -1,26 +0,0 @@
package gc.mda.kcg.domain.enforcement.dto;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
import java.util.UUID;
public record CreateRecordRequest(
Long eventId,
OffsetDateTime enforcedAt,
String zoneCode,
String areaName,
Double lat,
Double lon,
String vesselMmsi,
String vesselName,
String flagCountry,
String violationType,
String action,
String result,
String aiMatchStatus,
BigDecimal aiConfidence,
Long patrolShipId,
UUID enforcedBy,
String enforcedByName,
String remarks
) {}

파일 보기

@ -1,7 +0,0 @@
package gc.mda.kcg.domain.enforcement.dto;
public record UpdateRecordRequest(
String result,
String aiMatchStatus,
String remarks
) {}

파일 보기

@ -1,11 +0,0 @@
package gc.mda.kcg.domain.enforcement.repository;
import gc.mda.kcg.domain.enforcement.EnforcementPlan;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
public interface EnforcementPlanRepository extends JpaRepository<EnforcementPlan, Long> {
Page<EnforcementPlan> findByStatusOrderByPlannedDateAsc(String status, Pageable pageable);
Page<EnforcementPlan> findAllByOrderByPlannedDateDesc(Pageable pageable);
}

파일 보기

@ -1,12 +0,0 @@
package gc.mda.kcg.domain.enforcement.repository;
import gc.mda.kcg.domain.enforcement.EnforcementRecord;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
public interface EnforcementRecordRepository extends JpaRepository<EnforcementRecord, Long> {
Page<EnforcementRecord> findAllByOrderByEnforcedAtDesc(Pageable pageable);
Page<EnforcementRecord> findByViolationType(String violationType, Pageable pageable);
Page<EnforcementRecord> findByVesselMmsiOrderByEnforcedAtDesc(String vesselMmsi, Pageable pageable);
}

파일 보기

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

파일 보기

@ -1,112 +0,0 @@
package gc.mda.kcg.domain.event;
import gc.mda.kcg.auth.AuthPrincipal;
import gc.mda.kcg.domain.event.dto.EventStatusUpdateRequest;
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.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
/**
* 이벤트 관리 API.
* 예측 이벤트의 조회, 확인, 상태 변경, 처리 이력을 제공.
*/
@RestController
@RequestMapping("/api/events")
@RequiredArgsConstructor
public class EventController {
private final EventService eventService;
/**
* 이벤트 목록 조회 (필터 + 페이징).
*/
@GetMapping
@RequirePermission(resource = "monitoring", operation = "READ")
public Page<PredictionEvent> getEvents(
@RequestParam(required = false) String status,
@RequestParam(required = false) String level,
@RequestParam(required = false) String category,
@RequestParam(required = false) String vesselMmsi,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size
) {
return eventService.getEvents(
status, level, category, vesselMmsi,
PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "occurredAt"))
);
}
/**
* 이벤트 상세 조회.
*/
@GetMapping("/{id}")
@RequirePermission(resource = "monitoring", operation = "READ")
public PredictionEvent getEvent(@PathVariable Long id) {
return eventService.getEventById(id);
}
/**
* 이벤트 처리 이력 조회.
*/
@GetMapping("/{id}/workflow")
@RequirePermission(resource = "monitoring", operation = "READ")
public List<EventWorkflow> getWorkflowHistory(@PathVariable Long id) {
return eventService.getEventWorkflowHistory(id);
}
/**
* 이벤트 확인 처리 (NEW ACK).
*/
@PatchMapping("/{id}/ack")
@RequirePermission(resource = "monitoring", operation = "UPDATE")
public PredictionEvent acknowledgeEvent(@PathVariable Long id) {
AuthPrincipal principal = currentPrincipal();
return eventService.acknowledgeEvent(
id,
principal != null ? principal.getUserId() : null,
principal != null ? principal.getUserNm() : null
);
}
/**
* 이벤트 상태 변경 (범용).
*/
@PatchMapping("/{id}/status")
@RequirePermission(resource = "monitoring", operation = "UPDATE")
public PredictionEvent updateStatus(
@PathVariable Long id,
@Valid @RequestBody EventStatusUpdateRequest req
) {
AuthPrincipal principal = currentPrincipal();
return eventService.updateEventStatus(
id,
req.status(),
principal != null ? principal.getUserId() : null,
principal != null ? principal.getUserNm() : null,
req.comment()
);
}
/**
* 상태별 이벤트 카운트 통계.
*/
@GetMapping("/stats")
@RequirePermission(resource = "monitoring", operation = "READ")
public Map<String, Long> getEventStats() {
return eventService.getEventStats();
}
private AuthPrincipal currentPrincipal() {
var auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null && auth.getPrincipal() instanceof AuthPrincipal p) return p;
return null;
}
}

파일 보기

@ -1,165 +0,0 @@
package gc.mda.kcg.domain.event;
import gc.mda.kcg.audit.annotation.Auditable;
import gc.mda.kcg.auth.AuthPrincipal;
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.*;
/**
* 이벤트 조회/상태 관리 서비스.
* 모든 상태 변경은 EventWorkflow에 이력 기록.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class EventService {
private static final Set<String> RESOLVED_STATUSES = Set.of("RESOLVED", "FALSE_POSITIVE");
private final PredictionEventRepository eventRepository;
private final EventWorkflowRepository workflowRepository;
/**
* 이벤트 목록 조회 (필터 조합).
*/
@Transactional(readOnly = true)
public Page<PredictionEvent> getEvents(String status, String level, String category, String vesselMmsi, Pageable pageable) {
Specification<PredictionEvent> spec = Specification.where(null);
if (status != null && !status.isBlank()) {
spec = spec.and((root, query, cb) -> cb.equal(root.get("status"), status));
}
if (level != null && !level.isBlank()) {
spec = spec.and((root, query, cb) -> cb.equal(root.get("level"), level));
}
if (category != null && !category.isBlank()) {
spec = spec.and((root, query, cb) -> cb.equal(root.get("category"), category));
}
if (vesselMmsi != null && !vesselMmsi.isBlank()) {
spec = spec.and((root, query, cb) -> cb.equal(root.get("vesselMmsi"), vesselMmsi));
}
// 기본 정렬: occurredAt DESC
return eventRepository.findAll(spec, pageable);
}
/**
* 이벤트 상세 조회.
*/
@Transactional(readOnly = true)
public PredictionEvent getEventById(Long id) {
return eventRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("EVENT_NOT_FOUND: " + id));
}
/**
* 이벤트 확인 처리 (NEW ACK).
*/
@Auditable(action = "ACK_EVENT", resourceType = "PREDICTION_EVENT")
@Transactional
public PredictionEvent acknowledgeEvent(Long id, UUID actorId, String actorName) {
PredictionEvent event = getEventById(id);
String prevStatus = event.getStatus();
if (!"NEW".equals(prevStatus)) {
throw new IllegalStateException("ACK_ONLY_FROM_NEW: current=" + prevStatus);
}
event.setStatus("ACK");
event.setAssigneeId(actorId);
event.setAssigneeName(actorName);
event.setAckedAt(OffsetDateTime.now());
PredictionEvent saved = eventRepository.save(event);
workflowRepository.save(EventWorkflow.builder()
.eventId(id)
.prevStatus(prevStatus)
.newStatus("ACK")
.actorId(actorId)
.actorName(actorName)
.build());
return saved;
}
/**
* 이벤트 상태 변경 (범용) + EventWorkflow INSERT.
*/
@Auditable(action = "UPDATE_EVENT_STATUS", resourceType = "PREDICTION_EVENT")
@Transactional
public PredictionEvent updateEventStatus(Long id, String newStatus, UUID actorId, String actorName, String comment) {
PredictionEvent event = getEventById(id);
String prevStatus = event.getStatus();
event.setStatus(newStatus);
// ACK 전환 acked_at 자동 설정
if ("ACK".equals(newStatus) && event.getAckedAt() == null) {
event.setAckedAt(OffsetDateTime.now());
event.setAssigneeId(actorId);
event.setAssigneeName(actorName);
}
// RESOLVED/FALSE_POSITIVE 전환 resolved_at 자동 설정
if (RESOLVED_STATUSES.contains(newStatus) && event.getResolvedAt() == null) {
event.setResolvedAt(OffsetDateTime.now());
}
if (comment != null && !comment.isBlank()) {
event.setResolutionNote(comment);
}
PredictionEvent saved = eventRepository.save(event);
workflowRepository.save(EventWorkflow.builder()
.eventId(id)
.prevStatus(prevStatus)
.newStatus(newStatus)
.actorId(actorId)
.actorName(actorName)
.comment(comment)
.build());
return saved;
}
/**
* 이벤트 처리 이력 조회.
*/
@Transactional(readOnly = true)
public List<EventWorkflow> getEventWorkflowHistory(Long eventId) {
return workflowRepository.findByEventIdOrderByCreatedAtDesc(eventId);
}
/**
* 상태별 이벤트 카운트.
*/
@Transactional(readOnly = true)
public Map<String, Long> getEventStats() {
Map<String, Long> stats = new LinkedHashMap<>();
for (String s : List.of("NEW", "ACK", "IN_PROGRESS", "RESOLVED", "FALSE_POSITIVE", "DISMISSED")) {
stats.put(s, eventRepository.countByStatus(s));
}
return stats;
}
// ========================================================================
// 헬퍼
// ========================================================================
AuthPrincipal currentPrincipal() {
var auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null && auth.getPrincipal() instanceof AuthPrincipal p) return p;
return null;
}
}

파일 보기

@ -1,50 +0,0 @@
package gc.mda.kcg.domain.event;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
import java.time.OffsetDateTime;
import java.util.UUID;
/**
* 이벤트 상태 변경 이력 (감사 추적).
* 이벤트의 상태가 변경될 때마다 기록.
*/
@Entity
@Table(name = "event_workflow", schema = "kcg")
@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder
public class EventWorkflow {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "event_id", nullable = false)
private Long eventId;
@Column(name = "prev_status", length = 20)
private String prevStatus;
@Column(name = "new_status", length = 20)
private String newStatus;
@JdbcTypeCode(SqlTypes.UUID)
@Column(name = "actor_id")
private UUID actorId;
@Column(name = "actor_name", length = 100)
private String actorName;
@Column(name = "comment", columnDefinition = "text")
private String comment;
@Column(name = "created_at", nullable = false)
private OffsetDateTime createdAt;
@PrePersist
void prePersist() {
if (createdAt == null) createdAt = OffsetDateTime.now();
}
}

파일 보기

@ -1,10 +0,0 @@
package gc.mda.kcg.domain.event;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface EventWorkflowRepository extends JpaRepository<EventWorkflow, Long> {
List<EventWorkflow> findByEventIdOrderByCreatedAtDesc(Long eventId);
}

파일 보기

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

파일 보기

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

파일 보기

@ -1,119 +0,0 @@
package gc.mda.kcg.domain.event;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
import java.util.Map;
import java.util.UUID;
/**
* AI 예측 이벤트.
* 불법어선 탐지, 이상행위 감지 시스템이 생성한 이벤트를 저장.
*/
@Entity
@Table(name = "prediction_events", schema = "kcg",
uniqueConstraints = @UniqueConstraint(columnNames = "event_uid"))
@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder
public class PredictionEvent {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "event_uid", nullable = false, length = 50, unique = true)
private String eventUid;
@Column(name = "occurred_at")
private OffsetDateTime occurredAt;
@Column(name = "level", length = 20)
private String level;
@Column(name = "category", length = 50)
private String category;
@Column(name = "title", length = 200)
private String title;
@Column(name = "detail", columnDefinition = "text")
private String detail;
@Column(name = "vessel_mmsi", length = 20)
private String vesselMmsi;
@Column(name = "vessel_name", length = 100)
private String vesselName;
@Column(name = "area_name", length = 100)
private String areaName;
@Column(name = "zone_code", length = 30)
private String zoneCode;
@Column(name = "lat")
private Double lat;
@Column(name = "lon")
private Double lon;
@Column(name = "speed_kn", precision = 5, scale = 2)
private BigDecimal speedKn;
@Column(name = "source_type", length = 50)
private String sourceType;
@Column(name = "source_ref_id")
private Long sourceRefId;
@Column(name = "ai_confidence", precision = 5, scale = 4)
private BigDecimal aiConfidence;
@Column(name = "status", nullable = false, length = 20)
private String status;
@JdbcTypeCode(SqlTypes.UUID)
@Column(name = "assignee_id")
private UUID assigneeId;
@Column(name = "assignee_name", length = 100)
private String assigneeName;
@Column(name = "acked_at")
private OffsetDateTime ackedAt;
@Column(name = "resolved_at")
private OffsetDateTime resolvedAt;
@Column(name = "resolution_note", columnDefinition = "text")
private String resolutionNote;
@Column(name = "dedup_key", length = 200)
private String dedupKey;
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "features", columnDefinition = "jsonb")
private Map<String, Object> features;
@Column(name = "created_at", nullable = false)
private OffsetDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private OffsetDateTime updatedAt;
@PrePersist
void prePersist() {
OffsetDateTime now = OffsetDateTime.now();
if (createdAt == null) createdAt = now;
if (updatedAt == null) updatedAt = now;
if (status == null) status = "NEW";
}
@PreUpdate
void preUpdate() {
updatedAt = OffsetDateTime.now();
}
}

파일 보기

@ -1,22 +0,0 @@
package gc.mda.kcg.domain.event;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import java.util.List;
public interface PredictionEventRepository
extends JpaRepository<PredictionEvent, Long>, JpaSpecificationExecutor<PredictionEvent> {
Page<PredictionEvent> findByStatusInOrderByOccurredAtDesc(List<String> statuses, Pageable pageable);
Page<PredictionEvent> findByLevelOrderByOccurredAtDesc(String level, Pageable pageable);
Page<PredictionEvent> findByCategoryOrderByOccurredAtDesc(String category, Pageable pageable);
Page<PredictionEvent> findByVesselMmsiOrderByOccurredAtDesc(String mmsi, Pageable pageable);
long countByStatus(String status);
}

파일 보기

@ -1,11 +0,0 @@
package gc.mda.kcg.domain.event.dto;
import jakarta.validation.constraints.NotBlank;
/**
* 이벤트 상태 변경 요청 DTO.
*/
public record EventStatusUpdateRequest(
@NotBlank String status,
String comment
) {}

파일 보기

@ -1,63 +0,0 @@
package gc.mda.kcg.domain.fleet;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
import java.time.OffsetDateTime;
import java.util.UUID;
/**
* 모선 후보 제외 (운영자 결정).
* scope_type: GROUP(그룹 한정) / GLOBAL(전역, 모든 그룹에 적용)
*/
@Entity
@Table(name = "gear_parent_candidate_exclusions", schema = "kcg")
@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder
public class CandidateExclusion {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "scope_type", nullable = false, length = 20)
private String scopeType; // GROUP, GLOBAL
@Column(name = "group_key", length = 255)
private String groupKey;
@Column(name = "sub_cluster_id")
private Integer subClusterId;
@Column(name = "excluded_mmsi", nullable = false, length = 20)
private String excludedMmsi;
@Column(name = "reason", columnDefinition = "text")
private String reason;
@JdbcTypeCode(SqlTypes.UUID)
@Column(name = "actor")
private UUID actor;
@Column(name = "actor_acnt", length = 50)
private String actorAcnt;
@Column(name = "created_at", nullable = false)
private OffsetDateTime createdAt;
@Column(name = "released_at")
private OffsetDateTime releasedAt;
@JdbcTypeCode(SqlTypes.UUID)
@Column(name = "released_by")
private UUID releasedBy;
@Column(name = "released_by_acnt", length = 50)
private String releasedByAcnt;
@PrePersist
void prePersist() {
if (createdAt == null) createdAt = OffsetDateTime.now();
}
}

파일 보기

@ -1,73 +0,0 @@
package gc.mda.kcg.domain.fleet;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
import java.time.OffsetDateTime;
import java.util.Map;
import java.util.UUID;
/**
* 모선 추론 학습 세션 (운영자가 정답 라벨링).
*/
@Entity
@Table(name = "gear_parent_label_sessions", schema = "kcg")
@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder
public class LabelSession {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "group_key", nullable = false, length = 255)
private String groupKey;
@Column(name = "sub_cluster_id", nullable = false)
private Integer subClusterId;
@Column(name = "label_parent_mmsi", nullable = false, length = 20)
private String labelParentMmsi;
@Column(name = "status", nullable = false, length = 20)
private String status; // ACTIVE, CANCELLED, COMPLETED
@Column(name = "active_from", nullable = false)
private OffsetDateTime activeFrom;
@Column(name = "active_until")
private OffsetDateTime activeUntil;
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "anchor_snapshot", columnDefinition = "jsonb")
private Map<String, Object> anchorSnapshot;
@JdbcTypeCode(SqlTypes.UUID)
@Column(name = "created_by")
private UUID createdBy;
@Column(name = "created_by_acnt", length = 50)
private String createdByAcnt;
@JdbcTypeCode(SqlTypes.UUID)
@Column(name = "cancelled_by")
private UUID cancelledBy;
@Column(name = "cancelled_at")
private OffsetDateTime cancelledAt;
@Column(name = "cancel_reason", columnDefinition = "text")
private String cancelReason;
@Column(name = "created_at", nullable = false)
private OffsetDateTime createdAt;
@PrePersist
void prePersist() {
OffsetDateTime now = OffsetDateTime.now();
if (createdAt == null) createdAt = now;
if (activeFrom == null) activeFrom = now;
if (status == null) status = "ACTIVE";
}
}

파일 보기

@ -1,131 +0,0 @@
package gc.mda.kcg.domain.fleet;
import gc.mda.kcg.domain.fleet.dto.*;
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.web.bind.annotation.*;
@RestController
@RequestMapping("/api/parent-inference")
@RequiredArgsConstructor
public class ParentInferenceWorkflowController {
private final ParentInferenceWorkflowService service;
// ========================================================================
// 검토 대기 / 결과 조회
// ========================================================================
@GetMapping("/review")
@RequirePermission(resource = "parent-inference-workflow:parent-review", operation = "READ")
public Page<ParentResolution> listReview(
@RequestParam(required = false) String status,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size
) {
return service.listReview(status, PageRequest.of(page, size));
}
// ========================================================================
// 모선 확정/거부/리셋
// ========================================================================
@PostMapping("/groups/{groupKey}/{subClusterId}/review")
@RequirePermission(resource = "parent-inference-workflow:parent-review", operation = "UPDATE")
public ParentResolution review(
@PathVariable String groupKey,
@PathVariable Integer subClusterId,
@Valid @RequestBody ReviewRequest req
) {
return service.review(groupKey, subClusterId, req);
}
// ========================================================================
// 후보 제외 (그룹 / 전역)
// ========================================================================
@PostMapping("/groups/{groupKey}/{subClusterId}/exclusions")
@RequirePermission(resource = "parent-inference-workflow:parent-exclusion", operation = "CREATE")
public CandidateExclusion excludeForGroup(
@PathVariable String groupKey,
@PathVariable Integer subClusterId,
@Valid @RequestBody ExclusionRequest req
) {
return service.excludeForGroup(groupKey, subClusterId, req);
}
@PostMapping("/exclusions/global")
@RequirePermission(resource = "parent-inference-workflow:exclusion-management", operation = "CREATE")
public CandidateExclusion excludeGlobal(@Valid @RequestBody GlobalExclusionRequest req) {
return service.excludeGlobal(req);
}
@PostMapping("/exclusions/{exclusionId}/release")
@RequirePermission(resource = "parent-inference-workflow:parent-exclusion", operation = "DELETE")
public CandidateExclusion releaseExclusion(
@PathVariable Long exclusionId,
@RequestBody(required = false) CancelRequest req
) {
return service.releaseExclusion(exclusionId, req);
}
@GetMapping("/exclusions")
@RequirePermission(resource = "parent-inference-workflow:parent-exclusion", operation = "READ")
public Page<CandidateExclusion> listExclusions(
@RequestParam(required = false) String scopeType,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size
) {
return service.listExclusions(scopeType, PageRequest.of(page, size));
}
// ========================================================================
// 학습 세션
// ========================================================================
@PostMapping("/groups/{groupKey}/{subClusterId}/label-sessions")
@RequirePermission(resource = "parent-inference-workflow:label-session", operation = "CREATE")
public LabelSession createLabelSession(
@PathVariable String groupKey,
@PathVariable Integer subClusterId,
@Valid @RequestBody LabelSessionRequest req
) {
return service.createLabelSession(groupKey, subClusterId, req);
}
@PostMapping("/label-sessions/{sessionId}/cancel")
@RequirePermission(resource = "parent-inference-workflow:label-session", operation = "UPDATE")
public LabelSession cancelLabelSession(
@PathVariable Long sessionId,
@RequestBody(required = false) CancelRequest req
) {
return service.cancelLabelSession(sessionId, req);
}
@GetMapping("/label-sessions")
@RequirePermission(resource = "parent-inference-workflow:label-session", operation = "READ")
public Page<LabelSession> listLabelSessions(
@RequestParam(required = false) String status,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size
) {
return service.listLabelSessions(status, PageRequest.of(page, size));
}
// ========================================================================
// 도메인 로그 (운영자 액션 이력)
// ========================================================================
@GetMapping("/review-logs")
@RequirePermission(resource = "parent-inference-workflow:parent-review", operation = "READ")
public Page<ParentReviewLog> listReviewLogs(
@RequestParam(required = false) String groupKey,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "50") int size
) {
return service.listReviewLogs(groupKey, PageRequest.of(page, size));
}
}

파일 보기

@ -1,273 +0,0 @@
package gc.mda.kcg.domain.fleet;
import gc.mda.kcg.audit.annotation.Auditable;
import gc.mda.kcg.auth.AuthPrincipal;
import gc.mda.kcg.domain.fleet.dto.*;
import gc.mda.kcg.domain.fleet.repository.*;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
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.List;
/**
* 모선 워크플로우 핵심 서비스.
* - 후보 데이터: prediction이 kcgaidb 저장한 분석 결과를 참조
* - 운영자 결정: 자체 DB (gear_group_parent_resolution )
*
* 모든 쓰기 액션은 @Auditable로 감사로그 자동 기록.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class ParentInferenceWorkflowService {
private final ParentResolutionRepository resolutionRepository;
private final ParentReviewLogRepository reviewLogRepository;
private final CandidateExclusionRepository exclusionRepository;
private final LabelSessionRepository labelSessionRepository;
// ========================================================================
// Resolution (모선 확정/거부/리셋)
// ========================================================================
@Transactional(readOnly = true)
public Page<ParentResolution> listReview(String status, Pageable pageable) {
if (status == null || status.isBlank()) {
return resolutionRepository.findAllByOrderByUpdatedAtDesc(pageable);
}
return resolutionRepository.findByStatusOrderByUpdatedAtDesc(status, pageable);
}
@Auditable(action = "REVIEW_PARENT", resourceType = "GEAR_GROUP")
@Transactional
public ParentResolution review(String groupKey, Integer subClusterId, ReviewRequest req) {
AuthPrincipal principal = currentPrincipal();
ParentResolution res = resolutionRepository
.findByGroupKeyAndSubClusterId(groupKey, subClusterId)
.orElseGet(() -> ParentResolution.builder()
.groupKey(groupKey)
.subClusterId(subClusterId)
.status("UNRESOLVED")
.build());
OffsetDateTime now = OffsetDateTime.now();
switch (req.action().toUpperCase()) {
case "CONFIRM" -> {
res.setStatus("MANUAL_CONFIRMED");
res.setSelectedParentMmsi(req.selectedParentMmsi());
res.setApprovedBy(principal != null ? principal.getUserId() : null);
res.setApprovedAt(now);
res.setManualComment(req.comment());
}
case "REJECT" -> {
res.setStatus("REVIEW_REQUIRED");
res.setRejectedCandidateMmsi(req.selectedParentMmsi());
res.setRejectedAt(now);
res.setManualComment(req.comment());
}
case "RESET" -> {
res.setStatus("UNRESOLVED");
res.setSelectedParentMmsi(null);
res.setRejectedCandidateMmsi(null);
res.setApprovedBy(null);
res.setApprovedAt(null);
res.setRejectedAt(null);
res.setManualComment(req.comment());
}
default -> throw new IllegalArgumentException("UNKNOWN_ACTION: " + req.action());
}
ParentResolution saved = resolutionRepository.save(res);
reviewLogRepository.save(ParentReviewLog.builder()
.groupKey(groupKey)
.subClusterId(subClusterId)
.action(req.action().toUpperCase())
.selectedParentMmsi(req.selectedParentMmsi())
.actor(principal != null ? principal.getUserId() : null)
.actorAcnt(principal != null ? principal.getUserAcnt() : null)
.comment(req.comment())
.build());
return saved;
}
// ========================================================================
// Exclusion (후보 제외)
// ========================================================================
@Auditable(action = "EXCLUDE_CANDIDATE_GROUP", resourceType = "GEAR_GROUP")
@Transactional
public CandidateExclusion excludeForGroup(String groupKey, Integer subClusterId, ExclusionRequest req) {
AuthPrincipal principal = currentPrincipal();
CandidateExclusion exc = CandidateExclusion.builder()
.scopeType("GROUP")
.groupKey(groupKey)
.subClusterId(subClusterId)
.excludedMmsi(req.excludedMmsi())
.reason(req.reason())
.actor(principal != null ? principal.getUserId() : null)
.actorAcnt(principal != null ? principal.getUserAcnt() : null)
.build();
CandidateExclusion saved = exclusionRepository.save(exc);
reviewLogRepository.save(ParentReviewLog.builder()
.groupKey(groupKey)
.subClusterId(subClusterId)
.action("EXCLUDE_GROUP")
.selectedParentMmsi(req.excludedMmsi())
.actor(principal != null ? principal.getUserId() : null)
.actorAcnt(principal != null ? principal.getUserAcnt() : null)
.comment(req.reason())
.build());
return saved;
}
@Auditable(action = "EXCLUDE_CANDIDATE_GLOBAL", resourceType = "GEAR_GROUP")
@Transactional
public CandidateExclusion excludeGlobal(GlobalExclusionRequest req) {
AuthPrincipal principal = currentPrincipal();
CandidateExclusion exc = CandidateExclusion.builder()
.scopeType("GLOBAL")
.excludedMmsi(req.excludedMmsi())
.reason(req.reason())
.actor(principal != null ? principal.getUserId() : null)
.actorAcnt(principal != null ? principal.getUserAcnt() : null)
.build();
CandidateExclusion saved = exclusionRepository.save(exc);
reviewLogRepository.save(ParentReviewLog.builder()
.groupKey("__GLOBAL__")
.action("EXCLUDE_GLOBAL")
.selectedParentMmsi(req.excludedMmsi())
.actor(principal != null ? principal.getUserId() : null)
.actorAcnt(principal != null ? principal.getUserAcnt() : null)
.comment(req.reason())
.build());
return saved;
}
@Auditable(action = "RELEASE_EXCLUSION", resourceType = "GEAR_GROUP")
@Transactional
public CandidateExclusion releaseExclusion(Long exclusionId, CancelRequest req) {
AuthPrincipal principal = currentPrincipal();
CandidateExclusion exc = exclusionRepository.findById(exclusionId)
.orElseThrow(() -> new IllegalArgumentException("EXCLUSION_NOT_FOUND: " + exclusionId));
exc.setReleasedAt(OffsetDateTime.now());
exc.setReleasedBy(principal != null ? principal.getUserId() : null);
exc.setReleasedByAcnt(principal != null ? principal.getUserAcnt() : null);
CandidateExclusion saved = exclusionRepository.save(exc);
reviewLogRepository.save(ParentReviewLog.builder()
.groupKey(exc.getGroupKey() != null ? exc.getGroupKey() : "__GLOBAL__")
.action("RELEASE_EXCLUSION")
.selectedParentMmsi(exc.getExcludedMmsi())
.actor(principal != null ? principal.getUserId() : null)
.actorAcnt(principal != null ? principal.getUserAcnt() : null)
.comment(req != null ? req.reason() : null)
.build());
return saved;
}
@Transactional(readOnly = true)
public Page<CandidateExclusion> listExclusions(String scopeType, Pageable pageable) {
if (scopeType == null || scopeType.isBlank()) {
return exclusionRepository.findActive(pageable);
}
return exclusionRepository.findActiveByScope(scopeType, pageable);
}
// ========================================================================
// Label Session (학습 세션)
// ========================================================================
@Auditable(action = "LABEL_PARENT_CREATE", resourceType = "GEAR_GROUP")
@Transactional
public LabelSession createLabelSession(String groupKey, Integer subClusterId, LabelSessionRequest req) {
AuthPrincipal principal = currentPrincipal();
LabelSession session = LabelSession.builder()
.groupKey(groupKey)
.subClusterId(subClusterId)
.labelParentMmsi(req.labelParentMmsi())
.anchorSnapshot(req.anchorSnapshot())
.createdBy(principal != null ? principal.getUserId() : null)
.createdByAcnt(principal != null ? principal.getUserAcnt() : null)
.build();
LabelSession saved = labelSessionRepository.save(session);
reviewLogRepository.save(ParentReviewLog.builder()
.groupKey(groupKey)
.subClusterId(subClusterId)
.action("LABEL_PARENT")
.selectedParentMmsi(req.labelParentMmsi())
.actor(principal != null ? principal.getUserId() : null)
.actorAcnt(principal != null ? principal.getUserAcnt() : null)
.build());
return saved;
}
@Auditable(action = "LABEL_PARENT_CANCEL", resourceType = "GEAR_GROUP")
@Transactional
public LabelSession cancelLabelSession(Long sessionId, CancelRequest req) {
AuthPrincipal principal = currentPrincipal();
LabelSession session = labelSessionRepository.findById(sessionId)
.orElseThrow(() -> new IllegalArgumentException("LABEL_SESSION_NOT_FOUND: " + sessionId));
session.setStatus("CANCELLED");
session.setCancelledAt(OffsetDateTime.now());
session.setCancelledBy(principal != null ? principal.getUserId() : null);
session.setCancelReason(req != null ? req.reason() : null);
LabelSession saved = labelSessionRepository.save(session);
reviewLogRepository.save(ParentReviewLog.builder()
.groupKey(session.getGroupKey())
.subClusterId(session.getSubClusterId())
.action("CANCEL_LABEL")
.selectedParentMmsi(session.getLabelParentMmsi())
.actor(principal != null ? principal.getUserId() : null)
.actorAcnt(principal != null ? principal.getUserAcnt() : null)
.comment(req != null ? req.reason() : null)
.build());
return saved;
}
@Transactional(readOnly = true)
public Page<LabelSession> listLabelSessions(String status, Pageable pageable) {
if (status == null || status.isBlank()) {
return labelSessionRepository.findAllByOrderByCreatedAtDesc(pageable);
}
return labelSessionRepository.findByStatusOrderByCreatedAtDesc(status, pageable);
}
// ========================================================================
// 도메인 로그 조회
// ========================================================================
@Transactional(readOnly = true)
public Page<ParentReviewLog> listReviewLogs(String groupKey, Pageable pageable) {
if (groupKey == null || groupKey.isBlank()) {
return reviewLogRepository.findAllByOrderByCreatedAtDesc(pageable);
}
return reviewLogRepository.findByGroupKeyOrderByCreatedAtDesc(groupKey, pageable);
}
// ========================================================================
// 헬퍼
// ========================================================================
private AuthPrincipal currentPrincipal() {
var auth = SecurityContextHolder.getContext().getAuthentication();
if (auth != null && auth.getPrincipal() instanceof AuthPrincipal p) return p;
return null;
}
}

파일 보기

@ -1,92 +0,0 @@
package gc.mda.kcg.domain.fleet;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
import java.time.OffsetDateTime;
import java.util.UUID;
/**
* 모선 확정 결과 (운영자 의사결정).
* prediction이 생성한 후보 데이터와 별도로 운영자 결정만 자체 DB에 저장.
*/
@Entity
@Table(name = "gear_group_parent_resolution", schema = "kcg",
uniqueConstraints = @UniqueConstraint(columnNames = {"group_key", "sub_cluster_id"}))
@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder
public class ParentResolution {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "group_key", nullable = false, length = 255)
private String groupKey;
@Column(name = "sub_cluster_id", nullable = false)
private Integer subClusterId;
@Column(name = "status", nullable = false, length = 30)
private String status; // UNRESOLVED, MANUAL_CONFIRMED, REVIEW_REQUIRED
@Column(name = "selected_parent_mmsi", length = 20)
private String selectedParentMmsi;
@Column(name = "selected_parent_name", length = 200)
private String selectedParentName;
@Column(name = "confidence", columnDefinition = "numeric(7,4)")
private java.math.BigDecimal confidence;
@Column(name = "top_score", columnDefinition = "numeric(7,4)")
private java.math.BigDecimal topScore;
@Column(name = "second_score", columnDefinition = "numeric(7,4)")
private java.math.BigDecimal secondScore;
@Column(name = "score_margin", columnDefinition = "numeric(7,4)")
private java.math.BigDecimal scoreMargin;
@Column(name = "decision_source", length = 30)
private String decisionSource;
@Column(name = "stable_cycles", columnDefinition = "integer default 0")
private Integer stableCycles;
@Column(name = "rejected_candidate_mmsi", length = 20)
private String rejectedCandidateMmsi;
@JdbcTypeCode(SqlTypes.UUID)
@Column(name = "approved_by")
private UUID approvedBy;
@Column(name = "approved_at")
private OffsetDateTime approvedAt;
@Column(name = "rejected_at")
private OffsetDateTime rejectedAt;
@Column(name = "manual_comment", columnDefinition = "text")
private String manualComment;
@Column(name = "created_at", nullable = false)
private OffsetDateTime createdAt;
@Column(name = "updated_at", nullable = false)
private OffsetDateTime updatedAt;
@PrePersist
void prePersist() {
OffsetDateTime now = OffsetDateTime.now();
if (createdAt == null) createdAt = now;
if (updatedAt == null) updatedAt = now;
if (status == null) status = "UNRESOLVED";
}
@PreUpdate
void preUpdate() {
updatedAt = OffsetDateTime.now();
}
}

파일 보기

@ -1,53 +0,0 @@
package gc.mda.kcg.domain.fleet;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
import java.time.OffsetDateTime;
import java.util.UUID;
/**
* 운영자 액션 로그 (도메인 컨텍스트 보존).
* audit_log와 별개로 group_key 도메인 정보를 직접 저장.
*/
@Entity
@Table(name = "gear_group_parent_review_log", schema = "kcg")
@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder
public class ParentReviewLog {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "group_key", nullable = false, length = 255)
private String groupKey;
@Column(name = "sub_cluster_id")
private Integer subClusterId;
@Column(name = "action", nullable = false, length = 30)
private String action; // CONFIRM, REJECT, RESET, EXCLUDE_GROUP, EXCLUDE_GLOBAL, LABEL_PARENT, CANCEL_LABEL, RELEASE_EXCLUSION
@Column(name = "selected_parent_mmsi", length = 20)
private String selectedParentMmsi;
@JdbcTypeCode(SqlTypes.UUID)
@Column(name = "actor")
private UUID actor;
@Column(name = "actor_acnt", length = 50)
private String actorAcnt;
@Column(name = "comment", columnDefinition = "text")
private String comment;
@Column(name = "created_at", nullable = false)
private OffsetDateTime createdAt;
@PrePersist
void prePersist() {
if (createdAt == null) createdAt = OffsetDateTime.now();
}
}

파일 보기

@ -1,3 +0,0 @@
package gc.mda.kcg.domain.fleet.dto;
public record CancelRequest(String reason) {}

파일 보기

@ -1,8 +0,0 @@
package gc.mda.kcg.domain.fleet.dto;
import jakarta.validation.constraints.NotBlank;
public record ExclusionRequest(
@NotBlank String excludedMmsi,
String reason
) {}

파일 보기

@ -1,8 +0,0 @@
package gc.mda.kcg.domain.fleet.dto;
import jakarta.validation.constraints.NotBlank;
public record GlobalExclusionRequest(
@NotBlank String excludedMmsi,
String reason
) {}

파일 보기

@ -1,10 +0,0 @@
package gc.mda.kcg.domain.fleet.dto;
import jakarta.validation.constraints.NotBlank;
import java.util.Map;
public record LabelSessionRequest(
@NotBlank String labelParentMmsi,
Map<String, Object> anchorSnapshot
) {}

파일 보기

@ -1,13 +0,0 @@
package gc.mda.kcg.domain.fleet.dto;
import jakarta.validation.constraints.NotBlank;
/**
* 모선 확정/거부/리셋 요청.
* action: CONFIRM, REJECT, RESET
*/
public record ReviewRequest(
@NotBlank String action,
String selectedParentMmsi,
String comment
) {}

파일 보기

@ -1,22 +0,0 @@
package gc.mda.kcg.domain.fleet.repository;
import gc.mda.kcg.domain.fleet.CandidateExclusion;
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.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
public interface CandidateExclusionRepository extends JpaRepository<CandidateExclusion, Long> {
@Query("SELECT e FROM CandidateExclusion e WHERE e.releasedAt IS NULL ORDER BY e.createdAt DESC")
Page<CandidateExclusion> findActive(Pageable pageable);
@Query("SELECT e FROM CandidateExclusion e WHERE e.scopeType = :scopeType AND e.releasedAt IS NULL ORDER BY e.createdAt DESC")
Page<CandidateExclusion> findActiveByScope(@Param("scopeType") String scopeType, Pageable pageable);
@Query("SELECT e FROM CandidateExclusion e WHERE e.groupKey = :groupKey AND e.releasedAt IS NULL ORDER BY e.createdAt DESC")
List<CandidateExclusion> findActiveByGroupKey(@Param("groupKey") String groupKey);
}

파일 보기

@ -1,14 +0,0 @@
package gc.mda.kcg.domain.fleet.repository;
import gc.mda.kcg.domain.fleet.LabelSession;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface LabelSessionRepository extends JpaRepository<LabelSession, Long> {
Page<LabelSession> findByStatusOrderByCreatedAtDesc(String status, Pageable pageable);
Page<LabelSession> findAllByOrderByCreatedAtDesc(Pageable pageable);
List<LabelSession> findByGroupKeyAndStatus(String groupKey, String status);
}

파일 보기

@ -1,16 +0,0 @@
package gc.mda.kcg.domain.fleet.repository;
import gc.mda.kcg.domain.fleet.ParentResolution;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
public interface ParentResolutionRepository extends JpaRepository<ParentResolution, Long> {
Optional<ParentResolution> findByGroupKeyAndSubClusterId(String groupKey, Integer subClusterId);
List<ParentResolution> findByGroupKey(String groupKey);
Page<ParentResolution> findByStatusOrderByUpdatedAtDesc(String status, Pageable pageable);
Page<ParentResolution> findAllByOrderByUpdatedAtDesc(Pageable pageable);
}

파일 보기

@ -1,11 +0,0 @@
package gc.mda.kcg.domain.fleet.repository;
import gc.mda.kcg.domain.fleet.ParentReviewLog;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
public interface ParentReviewLogRepository extends JpaRepository<ParentReviewLog, Long> {
Page<ParentReviewLog> findByGroupKeyOrderByCreatedAtDesc(String groupKey, Pageable pageable);
Page<ParentReviewLog> findAllByOrderByCreatedAtDesc(Pageable pageable);
}

파일 보기

@ -1,32 +0,0 @@
package gc.mda.kcg.domain.stats;
import jakarta.persistence.*;
import lombok.*;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
@Entity
@Table(name = "prediction_kpi_realtime", schema = "kcg")
@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder
public class PredictionKpi {
@Id
@Column(name = "kpi_key", length = 50)
private String kpiKey;
@Column(name = "kpi_label", length = 100)
private String kpiLabel;
@Column(name = "value")
private Integer value;
@Column(name = "trend", length = 10)
private String trend;
@Column(name = "delta_pct", precision = 12, scale = 2)
private BigDecimal deltaPct;
@Column(name = "updated_at")
private OffsetDateTime updatedAt;
}

파일 보기

@ -1,6 +0,0 @@
package gc.mda.kcg.domain.stats;
import org.springframework.data.jpa.repository.JpaRepository;
public interface PredictionKpiRepository extends JpaRepository<PredictionKpi, String> {
}

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