release: 2026-04-07 (26건 커밋) #5

병합
htlee develop 에서 main 로 26 commits 를 머지했습니다 2026-04-07 13:59:57 +09:00
377개의 변경된 파일25874개의 추가작업 그리고 1869개의 파일을 삭제

파일 보기

@ -46,5 +46,42 @@
"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,6 +1,7 @@
{
"applied_global_version": "1.6.1",
"applied_date": "2026-04-06",
"applied_date": "2026-04-07",
"project_type": "react-ts",
"gitea_url": "https://gitea.gc-si.dev"
"gitea_url": "https://gitea.gc-si.dev",
"custom_pre_commit": true
}

파일 보기

@ -1,9 +1,11 @@
name: Build and Deploy KCG AI Monitoring
name: Build and Deploy KCG AI Monitoring (Frontend)
on:
push:
branches:
- main
paths:
- 'frontend/**'
jobs:
build-and-deploy:
@ -18,20 +20,23 @@ 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 dist/* /deploy/kcg-ai-monitoring/
echo "Deployed at $(date '+%Y-%m-%d %H:%M:%S')"
cp -r frontend/dist/* /deploy/kcg-ai-monitoring/
echo "Frontend deployed at $(date '+%Y-%m-%d %H:%M:%S')"
ls -la /deploy/kcg-ai-monitoring/

62
.githooks/pre-commit Executable file
파일 보기

@ -0,0 +1,62 @@
#!/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

33
.gitignore vendored
파일 보기

@ -1,8 +1,18 @@
# === Build ===
dist/
build/
frontend/dist/
frontend/build/
backend/target/
backend/build/
# === Python (prediction) ===
prediction/.venv/
prediction/__pycache__/
prediction/**/__pycache__/
prediction/*.pyc
prediction/.env
# === Dependencies ===
frontend/node_modules/
node_modules/
# === IDE ===
@ -19,6 +29,8 @@ Thumbs.db
.env
.env.*
!.env.example
# 프론트엔드 환경 예시 (.env.example만 커밋)
!frontend/.env.example
secrets/
# === Debug ===
@ -27,12 +39,15 @@ yarn-debug.log*
yarn-error.log*
# === Test ===
coverage/
frontend/coverage/
backend/coverage/
# === Cache ===
.eslintcache
.prettiercache
*.tsbuildinfo
frontend/.eslintcache
frontend/.prettiercache
frontend/*.tsbuildinfo
frontend/.vite/
.vite/
# === Code Review Graph (로컬 전용) ===
.code-review-graph/
@ -55,3 +70,9 @@ coverage/
.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

102
CLAUDE.md Normal file
파일 보기

@ -0,0 +1,102 @@
# KCG AI Monitoring (모노레포)
해양경찰청 AI 기반 불법어선 탐지 및 단속 지원 플랫폼
## 모노레포 구조
```
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~V013)
│ └── 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 :8001] ──────┘ (5분 주기 분석 결과 저장)
↑ read ↑ read
[SNPDB PostgreSQL] (AIS 원본) [Iran Backend] (레거시 프록시, 선택)
```
- **자체 백엔드**: 인증/권한/감사로그/관리자 + 운영자 의사결정 (확정/제외/학습)
- **iran 백엔드 프록시**: 분석 결과 read-only 참조 (vessel_analysis, group_polygons, correlations)
- **신규 DB (kcgaidb)**: 자체 생산 데이터만 저장, prediction 분석 테이블은 미복사
## 명령어
```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 검증

56
Makefile Normal file
파일 보기

@ -0,0 +1,56 @@
.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

파일 보기

@ -0,0 +1,3 @@
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
backend/.sdkmanrc Normal file
파일 보기

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

18
backend/README.md Normal file
파일 보기

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

295
backend/mvnw vendored Executable file
파일 보기

@ -0,0 +1,295 @@
#!/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 Normal file
파일 보기

@ -0,0 +1,189 @@
<# : 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"

177
backend/pom.xml Normal file
파일 보기

@ -0,0 +1,177 @@
<?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>
<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>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</path>
</annotationProcessorPaths>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>

파일 보기

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

파일 보기

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

파일 보기

@ -0,0 +1,154 @@
package gc.mda.kcg.admin;
import gc.mda.kcg.audit.AccessLogRepository;
import gc.mda.kcg.audit.AuditLogRepository;
import gc.mda.kcg.auth.LoginHistoryRepository;
import gc.mda.kcg.permission.annotation.RequirePermission;
import lombok.RequiredArgsConstructor;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
/**
* 시스템 관리 대시보드 메트릭 API.
*
* - 감사 로그 / 접근 로그 / 로그인 이력 통계
* - 24시간 / 7일 추세
* - 액션별 / 상태별 분포
*
* 권한: admin:audit-logs, admin:access-logs, admin:login-history (READ)
*/
@RestController
@RequestMapping("/api/admin/stats")
@RequiredArgsConstructor
public class AdminStatsController {
private final AuditLogRepository auditLogRepository;
private final AccessLogRepository accessLogRepository;
private final LoginHistoryRepository loginHistoryRepository;
private final JdbcTemplate jdbc;
/**
* 감사 로그 통계.
* - total: 전체 건수
* - last24h: 24시간 건수
* - failed24h: 24시간 FAILED 건수
* - byAction: 액션별 카운트 (top 10)
* - hourly24: 시간별 24시간 추세
*/
@GetMapping("/audit")
@RequirePermission(resource = "admin:audit-logs", operation = "READ")
public Map<String, Object> auditStats() {
Map<String, Object> result = new LinkedHashMap<>();
result.put("total", auditLogRepository.count());
result.put("last24h", jdbc.queryForObject(
"SELECT COUNT(*) FROM kcg.auth_audit_log WHERE created_at > now() - interval '24 hours'", Long.class));
result.put("failed24h", jdbc.queryForObject(
"SELECT COUNT(*) FROM kcg.auth_audit_log WHERE created_at > now() - interval '24 hours' AND result = 'FAILED'", Long.class));
List<Map<String, Object>> byAction = jdbc.queryForList(
"SELECT action_cd AS action, COUNT(*) AS count FROM kcg.auth_audit_log " +
"WHERE created_at > now() - interval '7 days' " +
"GROUP BY action_cd ORDER BY count DESC LIMIT 10");
result.put("byAction", byAction);
List<Map<String, Object>> hourly = jdbc.queryForList(
"SELECT date_trunc('hour', created_at) AS hour, COUNT(*) AS count " +
"FROM kcg.auth_audit_log " +
"WHERE created_at > now() - interval '24 hours' " +
"GROUP BY hour ORDER BY hour");
result.put("hourly24", hourly);
return result;
}
/**
* 접근 로그 통계.
* - total: 전체 건수
* - last24h: 24시간
* - error4xx, error5xx: 24시간 에러
* - avgDurationMs: 24시간 평균 응답 시간
* - topPaths: 24시간 호출 많은 경로
*/
@GetMapping("/access")
@RequirePermission(resource = "admin:access-logs", operation = "READ")
public Map<String, Object> accessStats() {
Map<String, Object> result = new LinkedHashMap<>();
result.put("total", accessLogRepository.count());
result.put("last24h", jdbc.queryForObject(
"SELECT COUNT(*) FROM kcg.auth_access_log WHERE created_at > now() - interval '24 hours'", Long.class));
result.put("error4xx", jdbc.queryForObject(
"SELECT COUNT(*) FROM kcg.auth_access_log WHERE created_at > now() - interval '24 hours' AND status_code >= 400 AND status_code < 500", Long.class));
result.put("error5xx", jdbc.queryForObject(
"SELECT COUNT(*) FROM kcg.auth_access_log WHERE created_at > now() - interval '24 hours' AND status_code >= 500", Long.class));
Double avg = jdbc.queryForObject(
"SELECT AVG(duration_ms)::float FROM kcg.auth_access_log WHERE created_at > now() - interval '24 hours'",
Double.class);
result.put("avgDurationMs", avg != null ? Math.round(avg * 10) / 10.0 : 0);
List<Map<String, Object>> topPaths = jdbc.queryForList(
"SELECT request_path AS path, COUNT(*) AS count, AVG(duration_ms)::int AS avg_ms " +
"FROM kcg.auth_access_log " +
"WHERE created_at > now() - interval '24 hours' AND request_path NOT LIKE '/actuator%' " +
"GROUP BY request_path ORDER BY count DESC LIMIT 10");
result.put("topPaths", topPaths);
return result;
}
/**
* 로그인 통계.
* - total: 전체 건수
* - success24h: 24시간 성공
* - failed24h: 24시간 실패
* - locked24h: 24시간 잠금
* - successRate: 성공률 (24시간 , %)
* - byUser: 사용자별 성공 카운트 (top 10)
* - daily7d: 7일 일별 추세
*/
@GetMapping("/login")
@RequirePermission(resource = "admin:login-history", operation = "READ")
public Map<String, Object> loginStats() {
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;
}
}

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

@ -0,0 +1,104 @@
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;
}
}
}

파일 보기

@ -0,0 +1,23 @@
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";
}

파일 보기

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

파일 보기

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

파일 보기

@ -0,0 +1,19 @@
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;
}

파일 보기

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

파일 보기

@ -0,0 +1,82 @@
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;
}
}

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

@ -0,0 +1,16 @@
package gc.mda.kcg.auth.dto;
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
) {}

파일 보기

@ -0,0 +1,39 @@
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; }
}
}

파일 보기

@ -0,0 +1,71 @@
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;
}
}

파일 보기

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

파일 보기

@ -0,0 +1,39 @@
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 IranBackend iranBackend = new IranBackend();
private Cors cors = new Cors();
private Jwt jwt = new Jwt();
@Getter @Setter
public static class Prediction {
private String baseUrl;
}
@Getter @Setter
public static class IranBackend {
private String baseUrl;
}
@Getter @Setter
public static class Cors {
private String allowedOrigins;
}
@Getter @Setter
public static class Jwt {
private String secret;
private long expirationMs;
}
}

파일 보기

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

파일 보기

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

파일 보기

@ -0,0 +1,67 @@
package gc.mda.kcg.domain.analysis;
import gc.mda.kcg.permission.annotation.RequirePermission;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
/**
* Prediction (Python FastAPI) 서비스 프록시.
* 현재는 stub - Phase 5에서 연결.
*/
@RestController
@RequestMapping("/api/prediction")
@RequiredArgsConstructor
public class PredictionProxyController {
private final IranBackendClient iranClient;
@GetMapping("/health")
public ResponseEntity<?> health() {
Map<String, Object> data = iranClient.getJson("/api/prediction/health");
if (data == null) {
return ResponseEntity.ok(Map.of(
"status", "DISCONNECTED",
"message", "Prediction 서비스 미연결 (Phase 5에서 연결 예정)"
));
}
return ResponseEntity.ok(data);
}
@GetMapping("/status")
@RequirePermission(resource = "monitoring", operation = "READ")
public ResponseEntity<?> status() {
Map<String, Object> data = iranClient.getJson("/api/prediction/status");
if (data == null) {
return ResponseEntity.ok(Map.of("status", "DISCONNECTED"));
}
return ResponseEntity.ok(data);
}
@PostMapping("/trigger")
@RequirePermission(resource = "ai-operations:mlops", operation = "UPDATE")
public ResponseEntity<?> trigger() {
return ResponseEntity.ok(Map.of("ok", false, "message", "Prediction 서비스 미연결"));
}
/**
* AI 채팅 프록시 (POST).
* 향후 prediction 인증 통과 SSE 스트리밍으로 전환.
*/
@PostMapping("/chat")
@RequirePermission(resource = "ai-operations:ai-assistant", operation = "READ")
public ResponseEntity<?> chat(@org.springframework.web.bind.annotation.RequestBody Map<String, Object> body) {
// iran 백엔드에 인증 토큰이 필요하므로 현재 stub 응답
// 향후: iranClient에 Bearer 토큰 전달 + SSE 스트리밍
return ResponseEntity.ok(Map.of(
"ok", false,
"serviceAvailable", false,
"message", "Prediction 채팅 인증 연동 대기 중 (Phase 9에서 활성화 예정). 입력: " + body.getOrDefault("message", "")
));
}
}

파일 보기

@ -0,0 +1,123 @@
package gc.mda.kcg.domain.analysis;
import gc.mda.kcg.domain.fleet.ParentResolution;
import gc.mda.kcg.domain.fleet.repository.ParentResolutionRepository;
import gc.mda.kcg.permission.annotation.RequirePermission;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import java.util.*;
/**
* iran 백엔드 분석 데이터 프록시 + 자체 DB 운영자 결정 합성 (HYBRID).
*
* 라우팅:
* GET /api/vessel-analysis 전체 분석결과 + 통계 (단순 프록시)
* GET /api/vessel-analysis/groups 어구/선단 그룹 + parentResolution 합성
* GET /api/vessel-analysis/groups/{key}/detail 단일 그룹 상세
* GET /api/vessel-analysis/groups/{key}/correlations 상관관계 점수
*
* 권한: detection / detection:gear-detection (READ)
*/
@RestController
@RequestMapping("/api/vessel-analysis")
@RequiredArgsConstructor
public class VesselAnalysisProxyController {
private final IranBackendClient iranClient;
private final ParentResolutionRepository resolutionRepository;
@GetMapping
@RequirePermission(resource = "detection", operation = "READ")
public ResponseEntity<?> getVesselAnalysis() {
Map<String, Object> data = iranClient.getJson("/api/vessel-analysis");
if (data == null) {
return ResponseEntity.ok(Map.of(
"serviceAvailable", false,
"message", "iran 백엔드 미연결",
"items", List.of(),
"stats", Map.of(),
"count", 0
));
}
// 통과 + 메타데이터 추가
Map<String, Object> enriched = new LinkedHashMap<>(data);
enriched.put("serviceAvailable", true);
return ResponseEntity.ok(enriched);
}
/**
* 그룹 목록 + 자체 DB의 parentResolution 합성.
* 그룹에 resolution 필드 추가.
*/
@GetMapping("/groups")
@RequirePermission(resource = "detection:gear-detection", operation = "READ")
public ResponseEntity<?> getGroups() {
Map<String, Object> data = iranClient.getJson("/api/vessel-analysis/groups");
if (data == null) {
return ResponseEntity.ok(Map.of(
"serviceAvailable", false,
"items", List.of(),
"count", 0
));
}
@SuppressWarnings("unchecked")
List<Map<String, Object>> items = (List<Map<String, Object>>) data.getOrDefault("items", List.of());
// 자체 DB의 모든 resolution을 group_key로 인덱싱
Map<String, ParentResolution> resolutionByKey = new HashMap<>();
for (ParentResolution r : resolutionRepository.findAll()) {
resolutionByKey.put(r.getGroupKey() + "::" + r.getSubClusterId(), r);
}
// 그룹에 합성
for (Map<String, Object> item : items) {
String groupKey = String.valueOf(item.get("groupKey"));
Object subRaw = item.get("subClusterId");
Integer sub = subRaw == null ? null : Integer.valueOf(subRaw.toString());
ParentResolution res = resolutionByKey.get(groupKey + "::" + sub);
if (res != null) {
Map<String, Object> resolution = new LinkedHashMap<>();
resolution.put("status", res.getStatus());
resolution.put("selectedParentMmsi", res.getSelectedParentMmsi());
resolution.put("approvedAt", res.getApprovedAt());
resolution.put("manualComment", res.getManualComment());
item.put("resolution", resolution);
} else {
item.put("resolution", null);
}
}
Map<String, Object> result = new LinkedHashMap<>(data);
result.put("items", items);
result.put("serviceAvailable", true);
return ResponseEntity.ok(result);
}
@GetMapping("/groups/{groupKey}/detail")
@RequirePermission(resource = "detection:gear-detection", operation = "READ")
public ResponseEntity<?> getGroupDetail(@PathVariable String groupKey) {
Map<String, Object> data = iranClient.getJson("/api/vessel-analysis/groups/" + groupKey + "/detail");
if (data == null) {
return ResponseEntity.ok(Map.of("serviceAvailable", false, "groupKey", groupKey));
}
return ResponseEntity.ok(data);
}
@GetMapping("/groups/{groupKey}/correlations")
@RequirePermission(resource = "detection:gear-detection", operation = "READ")
public ResponseEntity<?> getGroupCorrelations(
@PathVariable String groupKey,
@RequestParam(required = false) Double minScore
) {
String path = "/api/vessel-analysis/groups/" + groupKey + "/correlations";
if (minScore != null) path += "?minScore=" + minScore;
Map<String, Object> data = iranClient.getJson(path);
if (data == null) {
return ResponseEntity.ok(Map.of("serviceAvailable", false, "groupKey", groupKey));
}
return ResponseEntity.ok(data);
}
}

파일 보기

@ -0,0 +1,97 @@
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,
Pageable pageable
) {
return service.listRecords(violationType, 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);
}
}

파일 보기

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

파일 보기

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

파일 보기

@ -0,0 +1,146 @@
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.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, Pageable 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
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
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
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);
}
}

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

@ -0,0 +1,112 @@
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;
}
}

파일 보기

@ -0,0 +1,165 @@
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;
}
}

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

@ -0,0 +1,73 @@
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";
}
}

파일 보기

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

파일 보기

@ -0,0 +1,273 @@
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;
/**
* 모선 워크플로우 핵심 서비스 (HYBRID).
* - 후보 데이터: iran 백엔드 API 호출 (현재 stub)
* - 운영자 결정: 자체 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;
}
}

파일 보기

@ -0,0 +1,71 @@
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;
/**
* 모선 확정 결과 (운영자 의사결정).
* iran 백엔드의 후보 데이터(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 = "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();
}
}

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

@ -0,0 +1,32 @@
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 = 5, scale = 2)
private BigDecimal deltaPct;
@Column(name = "updated_at")
private OffsetDateTime updatedAt;
}

파일 보기

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

파일 보기

@ -0,0 +1,65 @@
package gc.mda.kcg.domain.stats;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.util.Map;
@Entity
@Table(name = "prediction_stats_daily", schema = "kcg")
@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder
public class PredictionStatsDaily {
@Id
@Column(name = "stat_date")
private LocalDate statDate;
@Column(name = "total_detections")
private Integer totalDetections;
@Column(name = "enforcement_count")
private Integer enforcementCount;
@Column(name = "manual_confirmed_parents")
private Integer manualConfirmedParents;
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "by_category", columnDefinition = "jsonb")
private Map<String, Object> byCategory;
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "by_zone", columnDefinition = "jsonb")
private Map<String, Object> byZone;
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "by_risk_level", columnDefinition = "jsonb")
private Map<String, Object> byRiskLevel;
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "by_gear_type", columnDefinition = "jsonb")
private Map<String, Object> byGearType;
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "by_violation_type", columnDefinition = "jsonb")
private Map<String, Object> byViolationType;
@Column(name = "event_count")
private Integer eventCount;
@Column(name = "critical_event_count")
private Integer criticalEventCount;
@Column(name = "false_positive_count")
private Integer falsePositiveCount;
@Column(name = "ai_accuracy_pct", precision = 5, scale = 2)
private BigDecimal aiAccuracyPct;
@Column(name = "updated_at")
private OffsetDateTime updatedAt;
}

파일 보기

@ -0,0 +1,10 @@
package gc.mda.kcg.domain.stats;
import org.springframework.data.jpa.repository.JpaRepository;
import java.time.LocalDate;
import java.util.List;
public interface PredictionStatsDailyRepository extends JpaRepository<PredictionStatsDaily, LocalDate> {
List<PredictionStatsDaily> findByStatDateBetweenOrderByStatDateAsc(LocalDate from, LocalDate to);
}

파일 보기

@ -0,0 +1,62 @@
package gc.mda.kcg.domain.stats;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.OffsetDateTime;
import java.util.Map;
@Entity
@Table(name = "prediction_stats_monthly", schema = "kcg")
@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder
public class PredictionStatsMonthly {
@Id
@Column(name = "stat_month")
private LocalDate statMonth;
@Column(name = "total_detections")
private Integer totalDetections;
@Column(name = "total_enforcements")
private Integer totalEnforcements;
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "by_category", columnDefinition = "jsonb")
private Map<String, Object> byCategory;
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "by_zone", columnDefinition = "jsonb")
private Map<String, Object> byZone;
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "by_risk_level", columnDefinition = "jsonb")
private Map<String, Object> byRiskLevel;
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "by_gear_type", columnDefinition = "jsonb")
private Map<String, Object> byGearType;
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "by_violation_type", columnDefinition = "jsonb")
private Map<String, Object> byViolationType;
@Column(name = "event_count")
private Integer eventCount;
@Column(name = "critical_event_count")
private Integer criticalEventCount;
@Column(name = "false_positive_count")
private Integer falsePositiveCount;
@Column(name = "ai_accuracy_pct", precision = 5, scale = 2)
private BigDecimal aiAccuracyPct;
@Column(name = "updated_at")
private OffsetDateTime updatedAt;
}

파일 보기

@ -0,0 +1,10 @@
package gc.mda.kcg.domain.stats;
import org.springframework.data.jpa.repository.JpaRepository;
import java.time.LocalDate;
import java.util.List;
public interface PredictionStatsMonthlyRepository extends JpaRepository<PredictionStatsMonthly, LocalDate> {
List<PredictionStatsMonthly> findByStatMonthBetweenOrderByStatMonthAsc(LocalDate from, LocalDate to);
}

파일 보기

@ -0,0 +1,60 @@
package gc.mda.kcg.domain.stats;
import gc.mda.kcg.permission.annotation.RequirePermission;
import lombok.RequiredArgsConstructor;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.web.bind.annotation.*;
import java.time.LocalDate;
import java.util.List;
/**
* 통계/KPI 조회 API.
* prediction_kpi_realtime, prediction_stats_monthly, prediction_stats_daily 테이블 기반.
*/
@RestController
@RequestMapping("/api/stats")
@RequiredArgsConstructor
public class StatsController {
private final PredictionKpiRepository kpiRepository;
private final PredictionStatsMonthlyRepository monthlyRepository;
private final PredictionStatsDailyRepository dailyRepository;
/**
* 실시간 KPI 전체 목록 조회
*/
@GetMapping("/kpi")
@RequirePermission(resource = "statistics", operation = "READ")
public List<PredictionKpi> getKpi() {
return kpiRepository.findAll();
}
/**
* 월별 통계 조회
* @param from 시작 (: 2025-10)
* @param to 종료 (: 2026-04)
*/
@GetMapping("/monthly")
@RequirePermission(resource = "statistics", operation = "READ")
public List<PredictionStatsMonthly> getMonthly(
@RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate from,
@RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate to
) {
return monthlyRepository.findByStatMonthBetweenOrderByStatMonthAsc(from, to);
}
/**
* 일별 통계 조회
* @param from 시작 날짜 (: 2026-04-01)
* @param to 종료 날짜 (: 2026-04-07)
*/
@GetMapping("/daily")
@RequirePermission(resource = "statistics", operation = "READ")
public List<PredictionStatsDaily> getDaily(
@RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate from,
@RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate to
) {
return dailyRepository.findByStatDateBetweenOrderByStatDateAsc(from, to);
}
}

파일 보기

@ -0,0 +1,66 @@
package gc.mda.kcg.master;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
import java.time.OffsetDateTime;
import java.util.Map;
/**
* 계층형 코드 마스터.
* 시스템 전반에서 사용하는 분류 코드를 트리 구조로 관리.
*/
@Entity
@Table(name = "code_master", schema = "kcg")
@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder
public class CodeMaster {
@Id
@Column(name = "code_id", length = 100)
private String codeId;
@Column(name = "parent_id", length = 100)
private String parentId;
@Column(name = "group_code", length = 50)
private String groupCode;
@Column(name = "code", length = 50)
private String code;
@Column(name = "depth")
private Integer depth;
@Column(name = "name_ko", length = 100)
private String nameKo;
@Column(name = "name_en", length = 100)
private String nameEn;
@Column(name = "sort_order")
private Integer sortOrder;
@Column(name = "color_hex", length = 10)
private String colorHex;
@Column(name = "icon", length = 30)
private String icon;
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "metadata", columnDefinition = "jsonb")
private Map<String, Object> metadata;
@Column(name = "is_active")
private Boolean isActive;
@Column(name = "created_at")
private OffsetDateTime createdAt;
@PrePersist
void prePersist() {
if (createdAt == null) createdAt = OffsetDateTime.now();
if (isActive == null) isActive = true;
}
}

파일 보기

@ -0,0 +1,14 @@
package gc.mda.kcg.master;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface CodeMasterRepository extends JpaRepository<CodeMaster, String> {
List<CodeMaster> findByGroupCodeAndIsActiveTrueOrderBySortOrder(String groupCode);
List<CodeMaster> findByGroupCodeAndDepthOrderBySortOrder(String groupCode, int depth);
List<CodeMaster> findByParentIdOrderBySortOrder(String parentId);
}

파일 보기

@ -0,0 +1,94 @@
package gc.mda.kcg.master;
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;
/**
* 어구 유형 마스터.
* 어구별 속도/패턴/법적 허용 구역 분석에 필요한 메타데이터 관리.
*/
@Entity
@Table(name = "gear_type_master", schema = "kcg")
@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder
public class GearType {
@Id
@Column(name = "gear_code", length = 20)
private String gearCode;
@Column(name = "gear_name_ko", length = 50)
private String gearNameKo;
@Column(name = "gear_name_en", length = 50)
private String gearNameEn;
@Column(name = "category", length = 20)
private String category;
@Column(name = "speed_min_kn")
private BigDecimal speedMinKn;
@Column(name = "speed_max_kn")
private BigDecimal speedMaxKn;
@Column(name = "duration_min_minutes")
private Integer durationMinMinutes;
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "pattern_signature", columnDefinition = "jsonb")
private Map<String, Object> patternSignature;
@Column(name = "polygon_shape_hint", length = 20)
private String polygonShapeHint;
@JdbcTypeCode(SqlTypes.ARRAY)
@Column(name = "legal_zones", columnDefinition = "text[]")
private String[] legalZones;
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "legal_seasons", columnDefinition = "jsonb")
private Map<String, Object> legalSeasons;
@Column(name = "permit_required")
private Boolean permitRequired;
@Column(name = "display_color", length = 7)
private String displayColor;
@Column(name = "display_icon", length = 30)
private String displayIcon;
@Column(name = "display_order")
private Integer displayOrder;
@Column(name = "description", columnDefinition = "text")
private String description;
@Column(name = "is_active")
private Boolean isActive;
@JdbcTypeCode(SqlTypes.UUID)
@Column(name = "created_by")
private UUID createdBy;
@Column(name = "updated_at")
private OffsetDateTime updatedAt;
@PrePersist
void prePersist() {
if (isActive == null) isActive = true;
if (updatedAt == null) updatedAt = OffsetDateTime.now();
}
@PreUpdate
void preUpdate() {
updatedAt = OffsetDateTime.now();
}
}

파일 보기

@ -0,0 +1,10 @@
package gc.mda.kcg.master;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface GearTypeRepository extends JpaRepository<GearType, String> {
List<GearType> findByIsActiveTrueOrderByDisplayOrder();
}

파일 보기

@ -0,0 +1,147 @@
package gc.mda.kcg.master;
import gc.mda.kcg.permission.annotation.RequirePermission;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
/**
* 마스터 데이터 통합 컨트롤러.
* 코드 마스터, 어구 유형, 함정, 선박 허가 조회/관리 API 제공.
*/
@RestController
@RequiredArgsConstructor
public class MasterDataController {
private final CodeMasterRepository codeMasterRepository;
private final GearTypeRepository gearTypeRepository;
private final PatrolShipRepository patrolShipRepository;
private final VesselPermitRepository vesselPermitRepository;
// ========================================================================
// 코드 마스터 (인증만, 권한 불필요)
// ========================================================================
@GetMapping("/api/codes")
public List<CodeMaster> listCodes(@RequestParam String group) {
return codeMasterRepository.findByGroupCodeAndIsActiveTrueOrderBySortOrder(group);
}
@GetMapping("/api/codes/{codeId}/children")
public List<CodeMaster> listChildren(@PathVariable String codeId) {
return codeMasterRepository.findByParentIdOrderBySortOrder(codeId);
}
// ========================================================================
// 어구 유형 (조회: 인증만 / 생성·수정: admin:system-config)
// ========================================================================
@GetMapping("/api/gear-types")
public List<GearType> listGearTypes() {
return gearTypeRepository.findByIsActiveTrueOrderByDisplayOrder();
}
@GetMapping("/api/gear-types/{gearCode}")
public GearType getGearType(@PathVariable String gearCode) {
return gearTypeRepository.findById(gearCode)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND,
"어구 유형을 찾을 수 없습니다: " + gearCode));
}
@PostMapping("/api/gear-types")
@RequirePermission(resource = "admin:system-config", operation = "CREATE")
public GearType createGearType(@RequestBody GearType gearType) {
if (gearTypeRepository.existsById(gearType.getGearCode())) {
throw new ResponseStatusException(HttpStatus.CONFLICT,
"이미 존재하는 어구 코드입니다: " + gearType.getGearCode());
}
return gearTypeRepository.save(gearType);
}
@PutMapping("/api/gear-types/{gearCode}")
@RequirePermission(resource = "admin:system-config", operation = "UPDATE")
public GearType updateGearType(@PathVariable String gearCode, @RequestBody GearType gearType) {
if (!gearTypeRepository.existsById(gearCode)) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND,
"어구 유형을 찾을 수 없습니다: " + gearCode);
}
gearType.setGearCode(gearCode);
return gearTypeRepository.save(gearType);
}
// ========================================================================
// 함정 (patrol 권한)
// ========================================================================
@GetMapping("/api/patrol-ships")
@RequirePermission(resource = "patrol", operation = "READ")
public List<PatrolShip> listPatrolShips() {
return patrolShipRepository.findByIsActiveTrueOrderByShipCode();
}
@PatchMapping("/api/patrol-ships/{id}/status")
@RequirePermission(resource = "patrol", operation = "UPDATE")
public PatrolShip updatePatrolShipStatus(
@PathVariable Long id,
@RequestBody PatrolShipStatusRequest request
) {
PatrolShip ship = patrolShipRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND,
"함정을 찾을 수 없습니다: " + id));
if (request.status() != null) ship.setCurrentStatus(request.status());
if (request.lat() != null) ship.setCurrentLat(request.lat());
if (request.lon() != null) ship.setCurrentLon(request.lon());
if (request.zoneCode() != null) ship.setCurrentZoneCode(request.zoneCode());
if (request.fuelPct() != null) ship.setFuelPct(request.fuelPct());
return patrolShipRepository.save(ship);
}
// ========================================================================
// 선박 허가 (vessel 권한)
// ========================================================================
@GetMapping("/api/vessel-permits")
@RequirePermission(resource = "vessel", operation = "READ")
public Page<VesselPermit> listVesselPermits(
@RequestParam(required = false) String flag,
@RequestParam(required = false) String permitStatus,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size
) {
PageRequest pageable = PageRequest.of(page, size);
if (flag != null) {
return vesselPermitRepository.findByFlagCountry(flag, pageable);
}
if (permitStatus != null) {
return vesselPermitRepository.findByPermitStatus(permitStatus, pageable);
}
return vesselPermitRepository.findAll(pageable);
}
@GetMapping("/api/vessel-permits/{mmsi}")
@RequirePermission(resource = "vessel", operation = "READ")
public VesselPermit getVesselPermit(@PathVariable String mmsi) {
return vesselPermitRepository.findByMmsi(mmsi)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND,
"선박 허가 정보를 찾을 수 없습니다: " + mmsi));
}
// ========================================================================
// 내부 DTO
// ========================================================================
record PatrolShipStatusRequest(
String status,
Double lat,
Double lon,
String zoneCode,
Integer fuelPct
) {}
}

파일 보기

@ -0,0 +1,78 @@
package gc.mda.kcg.master;
import jakarta.persistence.*;
import lombok.*;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
/**
* 함정(경비함) 마스터.
* 해양경찰 소속 함정의 제원 현재 상태 관리.
*/
@Entity
@Table(name = "patrol_ship_master", schema = "kcg")
@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder
public class PatrolShip {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "ship_id")
private Long shipId;
@Column(name = "ship_code", length = 20, unique = true)
private String shipCode;
@Column(name = "ship_name", length = 100)
private String shipName;
@Column(name = "ship_class", length = 50)
private String shipClass;
@Column(name = "tonnage")
private BigDecimal tonnage;
@Column(name = "max_speed_kn")
private BigDecimal maxSpeedKn;
@Column(name = "fuel_capacity_l")
private BigDecimal fuelCapacityL;
@Column(name = "base_port", length = 50)
private String basePort;
@Column(name = "current_status", length = 20)
private String currentStatus;
@Column(name = "current_lat")
private Double currentLat;
@Column(name = "current_lon")
private Double currentLon;
@Column(name = "current_zone_code", length = 30)
private String currentZoneCode;
@Column(name = "fuel_pct")
private Integer fuelPct;
@Column(name = "crew_count")
private Integer crewCount;
@Column(name = "is_active")
private Boolean isActive;
@Column(name = "updated_at")
private OffsetDateTime updatedAt;
@PrePersist
void prePersist() {
if (isActive == null) isActive = true;
if (updatedAt == null) updatedAt = OffsetDateTime.now();
}
@PreUpdate
void preUpdate() {
updatedAt = OffsetDateTime.now();
}
}

파일 보기

@ -0,0 +1,12 @@
package gc.mda.kcg.master;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface PatrolShipRepository extends JpaRepository<PatrolShip, Long> {
List<PatrolShip> findByIsActiveTrueOrderByShipCode();
List<PatrolShip> findByCurrentStatus(String status);
}

파일 보기

@ -0,0 +1,87 @@
package gc.mda.kcg.master;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.OffsetDateTime;
/**
* 선박 허가 마스터.
* 어선 허가 정보, 허용 어구/구역, 유효기간 관리.
*/
@Entity
@Table(name = "vessel_permit_master", schema = "kcg")
@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder
public class VesselPermit {
@Id
@Column(name = "mmsi", length = 20)
private String mmsi;
@Column(name = "vessel_name", length = 100)
private String vesselName;
@Column(name = "vessel_name_cn", length = 100)
private String vesselNameCn;
@Column(name = "flag_country", length = 10)
private String flagCountry;
@Column(name = "vessel_type", length = 30)
private String vesselType;
@Column(name = "tonnage")
private BigDecimal tonnage;
@Column(name = "length_m")
private BigDecimal lengthM;
@Column(name = "build_year")
private Integer buildYear;
@Column(name = "permit_status", length = 20)
private String permitStatus;
@Column(name = "permit_no", length = 50)
private String permitNo;
@JdbcTypeCode(SqlTypes.ARRAY)
@Column(name = "permitted_gear_codes", columnDefinition = "text[]")
private String[] permittedGearCodes;
@JdbcTypeCode(SqlTypes.ARRAY)
@Column(name = "permitted_zones", columnDefinition = "text[]")
private String[] permittedZones;
@Column(name = "permit_valid_from")
private LocalDate permitValidFrom;
@Column(name = "permit_valid_to")
private LocalDate permitValidTo;
@Column(name = "company_id")
private Long companyId;
@Column(name = "data_source", length = 50)
private String dataSource;
@Column(name = "last_synced_at")
private OffsetDateTime lastSyncedAt;
@Column(name = "updated_at")
private OffsetDateTime updatedAt;
@PrePersist
void prePersist() {
if (updatedAt == null) updatedAt = OffsetDateTime.now();
}
@PreUpdate
void preUpdate() {
updatedAt = OffsetDateTime.now();
}
}

파일 보기

@ -0,0 +1,16 @@
package gc.mda.kcg.master;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface VesselPermitRepository extends JpaRepository<VesselPermit, String> {
Page<VesselPermit> findByFlagCountry(String flagCountry, Pageable pageable);
Page<VesselPermit> findByPermitStatus(String permitStatus, Pageable pageable);
Optional<VesselPermit> findByMmsi(String mmsi);
}

파일 보기

@ -0,0 +1,50 @@
package gc.mda.kcg.permission;
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_perm", schema = "kcg",
uniqueConstraints = @UniqueConstraint(columnNames = {"role_sn", "rsrc_cd", "oper_cd"}))
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class Perm {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
@Column(name = "perm_sn")
private Long permSn;
@Column(name = "role_sn", nullable = false)
private Long roleSn;
@Column(name = "rsrc_cd", nullable = false, length = 100)
private String rsrcCd;
@Column(name = "oper_cd", nullable = false, length = 20)
private String operCd; // READ/CREATE/UPDATE/DELETE/EXPORT/MANAGE
@Column(name = "grant_yn", nullable = false, length = 1)
private String grantYn; // Y / N
@Column(name = "updated_at", nullable = false)
private OffsetDateTime updatedAt;
@JdbcTypeCode(SqlTypes.UUID)
@Column(name = "updated_by")
private UUID updatedBy;
@PrePersist
@PreUpdate
void preUpdate() {
updatedAt = OffsetDateTime.now();
}
}

파일 보기

@ -0,0 +1,22 @@
package gc.mda.kcg.permission;
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;
import java.util.Optional;
public interface PermRepository extends JpaRepository<Perm, Long> {
List<Perm> findByRoleSn(Long roleSn);
Optional<Perm> findByRoleSnAndRsrcCdAndOperCd(Long roleSn, String rsrcCd, String operCd);
@Query("SELECT p FROM Perm p WHERE p.roleSn IN :roleSns")
List<Perm> findByRoleSnIn(@Param("roleSns") List<Long> roleSns);
void deleteByRoleSn(Long roleSn);
void deleteByRoleSnAndRsrcCdAndOperCd(Long roleSn, String rsrcCd, String operCd);
}

파일 보기

@ -0,0 +1,179 @@
package gc.mda.kcg.permission;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import java.util.*;
/**
* 트리 기반 RBAC 권한 해석기 (wing 프로젝트의 permResolver.ts Java 이식).
*
* 핵심 규칙:
* 1. READ가 게이팅 오퍼레이션: 부모의 READ가 N(deny)이면 자식의 모든 작업도 강제 deny
* 2. 명시 권한 우선: AUTH_PERM에 grant_yn 명시값이 있으면 그것 사용
* 3. 상속: 명시값 없으면 부모의 동일 작업 권한을 상속
* 4. 미정 = 거부 (기본값)
* 5. 다중 역할: 역할의 결과를 OR(합집합)
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class PermResolver {
public static final List<String> OPERATIONS = List.of("READ", "CREATE", "UPDATE", "DELETE", "EXPORT");
/**
* 권한 생성 헬퍼.
*/
public static String makePermKey(String rsrcCd, String operCd) {
return rsrcCd + "::" + operCd;
}
/**
* 단일 역할의 명시 권한 + 트리 해석된 (rsrcCd operCd[]) .
*
* @param treeNodes 전체 트리 노드 (use_yn=Y)
* @param explicitPerms 해당 역할의 명시 권한 grantYn ('Y'/'N')
* @return 트리 순회 결과 (모든 사용 가능한 노드에 대한 R/C/U/D/E 결정)
*/
public Map<String, Set<String>> resolveSingleRole(
List<PermTree> treeNodes,
Map<String, String> explicitPerms
) {
// 트리 인덱싱 (parent children)
Map<String, List<PermTree>> childrenMap = new HashMap<>();
Map<String, PermTree> nodeMap = new HashMap<>();
for (PermTree node : treeNodes) {
if (!"Y".equals(node.getUseYn())) continue;
nodeMap.put(node.getRsrcCd(), node);
String parent = node.getParentCd();
childrenMap.computeIfAbsent(parent, k -> new ArrayList<>()).add(node);
}
// 결과 : rsrcCd granted operations Set
Map<String, Set<String>> resolved = new HashMap<>();
// 루트 노드부터 BFS (parentCd가 null인 노드)
List<PermTree> roots = childrenMap.getOrDefault(null, Collections.emptyList());
for (PermTree root : roots) {
walkTree(root, null, childrenMap, explicitPerms, resolved);
}
return resolved;
}
/**
* 트리 순회: 부모의 효과적 권한을 컨텍스트로 받아 자식에 전파.
*/
private void walkTree(
PermTree node,
Set<String> parentEffective,
Map<String, List<PermTree>> childrenMap,
Map<String, String> explicitPerms,
Map<String, Set<String>> resolved
) {
Set<String> nodeEffective = new HashSet<>();
// 1. 오퍼레이션에 대해 (READ 먼저, 다른 작업은 다음)
// READ 결정
boolean readGranted = resolveOperation(node.getRsrcCd(), "READ",
parentEffective != null && parentEffective.contains("READ"),
explicitPerms);
// 부모 READ가 deny면 모든 작업 강제 deny
boolean parentReadDenied = parentEffective != null && !parentEffective.contains("READ") && parentEffective.contains("__defined__");
if (readGranted && !parentReadDenied) {
nodeEffective.add("READ");
}
// 다른 작업: READ가 부여된 경우에만 평가
if (nodeEffective.contains("READ")) {
for (String op : List.of("CREATE", "UPDATE", "DELETE", "EXPORT")) {
boolean parentHasOp = parentEffective != null && parentEffective.contains(op);
boolean granted = resolveOperation(node.getRsrcCd(), op, parentHasOp, explicitPerms);
if (granted) {
nodeEffective.add(op);
}
}
}
// 마커: 노드가 평가되었음을 표시 (자식에서 parent_read_denied 판단용)
nodeEffective.add("__defined__");
// 결과 저장 (마커 제외)
Set<String> publicOps = new HashSet<>(nodeEffective);
publicOps.remove("__defined__");
if (!publicOps.isEmpty()) {
resolved.put(node.getRsrcCd(), publicOps);
}
// 자식 재귀
List<PermTree> children = childrenMap.getOrDefault(node.getRsrcCd(), Collections.emptyList());
for (PermTree child : children) {
walkTree(child, nodeEffective, childrenMap, explicitPerms, resolved);
}
}
/**
* 단일 (rsrc, oper) 권한 해석:
* - 명시값이 있으면 그것 우선
* - 없으면 부모 권한 상속
*/
private boolean resolveOperation(String rsrcCd, String operCd, boolean parentGranted, Map<String, String> explicitPerms) {
String key = makePermKey(rsrcCd, operCd);
String explicit = explicitPerms.get(key);
if ("Y".equals(explicit)) return true;
if ("N".equals(explicit)) return false;
return parentGranted;
}
/**
* 다중 역할 해석: 역할 결과를 OR 합집합.
*
* @param treeNodes 전체 트리
* @param permsByRole 역할 sn 명시 권한 grantYn
* @return 최종 (rsrcCd operCd[])
*/
public Map<String, List<String>> resolveMultiRole(
List<PermTree> treeNodes,
Map<Long, Map<String, String>> permsByRole
) {
Map<String, Set<String>> merged = new HashMap<>();
for (Map.Entry<Long, Map<String, String>> entry : permsByRole.entrySet()) {
Map<String, Set<String>> single = resolveSingleRole(treeNodes, entry.getValue());
for (Map.Entry<String, Set<String>> e : single.entrySet()) {
merged.computeIfAbsent(e.getKey(), k -> new HashSet<>()).addAll(e.getValue());
}
}
// Set List 변환 + 안정적 정렬
Map<String, List<String>> result = new HashMap<>();
for (Map.Entry<String, Set<String>> e : merged.entrySet()) {
List<String> sorted = new ArrayList<>(e.getValue());
sorted.sort(Comparator.comparingInt(OPERATIONS::indexOf));
result.put(e.getKey(), sorted);
}
return result;
}
/**
* 단일 권한 체크 헬퍼: hasPermission(resolved, "detection:gear-detection", "READ")
* 부모 fallback 지원: "detection:gear-detection" 미존재 "detection" 검사
*/
public boolean hasPermission(Map<String, List<String>> resolved, String rsrcCd, String operCd) {
List<String> ops = resolved.get(rsrcCd);
if (ops != null && ops.contains(operCd)) return true;
// 부모 fallback
int colonIdx = rsrcCd.indexOf(':');
if (colonIdx > 0) {
String parent = rsrcCd.substring(0, colonIdx);
List<String> parentOps = resolved.get(parent);
return parentOps != null && parentOps.contains(operCd);
}
return false;
}
}

파일 보기

@ -0,0 +1,62 @@
package gc.mda.kcg.permission;
import jakarta.persistence.*;
import lombok.*;
import java.time.OffsetDateTime;
@Entity
@Table(name = "auth_perm_tree", schema = "kcg")
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class PermTree {
@Id
@Column(name = "rsrc_cd", length = 100)
private String rsrcCd;
@Column(name = "parent_cd", length = 100)
private String parentCd;
@Column(name = "rsrc_nm", nullable = false, length = 100)
private String rsrcNm;
@Column(name = "rsrc_desc", columnDefinition = "text")
private String rsrcDesc;
@Column(name = "icon", length = 50)
private String icon;
@Column(name = "rsrc_level", nullable = false)
private Integer rsrcLevel;
@Column(name = "sort_ord", nullable = false)
private Integer sortOrd;
@Column(name = "use_yn", nullable = false, length = 1)
private String useYn;
@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 (useYn == null) useYn = "Y";
if (sortOrd == null) sortOrd = 0;
if (rsrcLevel == null) rsrcLevel = 0;
}
@PreUpdate
void preUpdate() {
updatedAt = OffsetDateTime.now();
}
}

파일 보기

@ -0,0 +1,114 @@
package gc.mda.kcg.permission;
import gc.mda.kcg.permission.dto.PermissionUpdateRequest;
import gc.mda.kcg.permission.dto.RoleCreateRequest;
import gc.mda.kcg.permission.dto.RoleUpdateRequest;
import gc.mda.kcg.permission.dto.UserRoleAssignRequest;
import gc.mda.kcg.permission.annotation.RequirePermission;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* 권한 트리 + 역할 + 사용자 역할 배정 API.
* - 트리 조회: 모든 사용자
* - 역할/권한 CRUD: admin:role-management 또는 admin:permission-management
* - 사용자 역할 배정: admin:user-management (UPDATE)
*/
@RestController
@RequiredArgsConstructor
public class PermTreeController {
private final PermTreeRepository permTreeRepository;
private final RoleRepository roleRepository;
private final PermRepository permRepository;
private final RoleManagementService roleManagementService;
// ========================================================================
// 권한 트리 (모든 사용자)
// ========================================================================
@GetMapping("/api/perm-tree")
public List<PermTree> getPermTree() {
return permTreeRepository.findAllByOrderByRsrcLevelAscSortOrdAsc();
}
// ========================================================================
// 역할 조회 + 권한 매트릭스
// ========================================================================
@GetMapping("/api/roles")
@RequirePermission(resource = "admin:role-management", operation = "READ")
public List<Map<String, Object>> getRolesWithPermissions() {
List<Role> roles = roleRepository.findAllByOrderByRoleSnAsc();
return roles.stream().<Map<String, Object>>map(r -> {
List<Perm> perms = permRepository.findByRoleSn(r.getRoleSn());
return Map.of(
"roleSn", r.getRoleSn(),
"roleCd", r.getRoleCd(),
"roleNm", r.getRoleNm(),
"roleDc", r.getRoleDc() == null ? "" : r.getRoleDc(),
"dfltYn", r.getDfltYn(),
"builtinYn", r.getBuiltinYn(),
"permissions", perms
);
}).toList();
}
// ========================================================================
// 역할 CRUD
// ========================================================================
@PostMapping("/api/roles")
@RequirePermission(resource = "admin:role-management", operation = "CREATE")
public Role createRole(@Valid @RequestBody RoleCreateRequest req) {
return roleManagementService.createRole(req);
}
@PutMapping("/api/roles/{roleSn}")
@RequirePermission(resource = "admin:role-management", operation = "UPDATE")
public Role updateRole(@PathVariable Long roleSn, @RequestBody RoleUpdateRequest req) {
return roleManagementService.updateRole(roleSn, req);
}
@DeleteMapping("/api/roles/{roleSn}")
@RequirePermission(resource = "admin:role-management", operation = "DELETE")
public Map<String, Object> deleteRole(@PathVariable Long roleSn) {
roleManagementService.deleteRole(roleSn);
return Map.of("ok", true);
}
// ========================================================================
// 권한 매트릭스 일괄 갱신
// ========================================================================
@PutMapping("/api/roles/{roleSn}/permissions")
@RequirePermission(resource = "admin:permission-management", operation = "UPDATE")
public Map<String, Object> updatePermissions(
@PathVariable Long roleSn,
@Valid @RequestBody PermissionUpdateRequest req
) {
int changed = roleManagementService.updatePermissions(roleSn, req);
return Map.of("ok", true, "changed", changed);
}
// ========================================================================
// 사용자 역할 배정
// ========================================================================
@PutMapping("/api/admin/users/{userId}/roles")
@RequirePermission(resource = "admin:user-management", operation = "UPDATE")
public Map<String, Object> assignUserRoles(
@PathVariable String userId,
@RequestBody UserRoleAssignRequest req
) {
UUID uid = UUID.fromString(userId);
List<Long> roleSns = req.roleSns() == null ? List.of() : req.roleSns();
List<String> assigned = roleManagementService.assignUserRoles(uid, roleSns);
return Map.of("userId", userId, "roles", assigned);
}
}

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