feat: Phase 2 - Spring Boot 백엔드 + DB 마이그레이션 초기화

Phase 2-1: PostgreSQL DB 생성
- 211.208.115.83:5432에 kcgaidb 데이터베이스 생성
- kcg-app 사용자 + kcg 스키마 생성

Phase 2-2: Spring Boot 3.5.7 + Java 21 프로젝트
- gc.mda.kcg.KcgAiApplication 메인 클래스
- 의존성: web, security, data-jpa, validation, postgresql,
  flyway, actuator, cache, lombok, caffeine, jjwt(0.12.6)
- Maven Wrapper 포함, .sdkmanrc로 Java 21 고정

Phase 2-3: application.yml
- DataSource: 211.208.115.83/kcgaidb (kcg-app)
- JPA: ddl-auto=validate, default_schema=kcg
- Flyway: classpath:db/migration, schema=kcg
- Caffeine 캐시 (permissions, users)
- prediction/iran-backend/cors/jwt 커스텀 설정
- application-local.yml (로컬 디버깅용)

Phase 2-4: Flyway 마이그레이션 V001~V005
- V001 auth_init: auth_org, auth_user, auth_role,
  auth_user_role, auth_login_hist (pgcrypto 확장 포함)
- V002 perm_tree: auth_perm_tree, auth_perm, auth_setting
  (wing 패턴의 트리 기반 RBAC)
- V003 perm_seed: 5개 역할(ADMIN/OPERATOR/ANALYST/VIEWER/FIELD)
  + 13개 Level 0 탭 + 36개 Level 1 패널 (총 49개 리소스)
  + 역할별 권한 매트릭스 일괄 INSERT
- V004 access_logs: auth_audit_log, auth_access_log
- V005 parent_workflow: gear_group_parent_resolution,
  review_log, candidate_exclusions, label_sessions
  (iran 012/014의 백엔드 쓰기 부분만 이관)

Phase 2-5: 빌드 + 기동 검증 완료
- ./mvnw clean compile 성공
- spring-boot:run으로 기동 → Flyway가 V001~V005 자동 적용
- /actuator/health UP 확인
- 14개 테이블 + flyway_schema_history 생성 확인
- ADMIN 245건, OPERATOR 22건, 다른 역할 13건 권한 시드 확인

Phase 2 임시 SecurityConfig: 모든 요청 permitAll
(Phase 3에서 JWT 필터 + 트리 기반 권한 체크로 전환 예정)

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

파일 보기

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

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"

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

@ -0,0 +1,168 @@
<?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.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>
</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,26 @@
package gc.mda.kcg.config;
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.web.SecurityFilterChain;
/**
* Phase 2 임시 SecurityConfig.
* Phase 3에서 JWT 필터 + 권한 체계 본격 도입 확장.
*/
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(auth -> auth
.requestMatchers("/actuator/**").permitAll()
.anyRequest().permitAll() // Phase 2: 모두 허용. Phase 3에서 인증 필수로 전환
);
return http.build();
}
}

파일 보기

@ -0,0 +1,10 @@
spring:
jpa:
properties:
hibernate:
show_sql: true
logging:
level:
org.springframework.security: DEBUG
org.springframework.web: DEBUG

파일 보기

@ -0,0 +1,68 @@
spring:
application:
name: kcg-ai-backend
datasource:
url: jdbc:postgresql://211.208.115.83:5432/kcgaidb
username: kcg-app
password: Kcg2026ai
driver-class-name: org.postgresql.Driver
hikari:
maximum-pool-size: 10
minimum-idle: 2
connection-timeout: 30000
jpa:
hibernate:
ddl-auto: validate
properties:
hibernate:
default_schema: kcg
format_sql: true
jdbc:
time_zone: Asia/Seoul
open-in-view: false
flyway:
enabled: true
schemas: kcg
default-schema: kcg
locations: classpath:db/migration
baseline-on-migrate: true
cache:
type: caffeine
cache-names: permissions,users
caffeine:
spec: maximumSize=1000,expireAfterWrite=10m
server:
port: 8080
forward-headers-strategy: framework
management:
endpoints:
web:
exposure:
include: health,info,flyway
endpoint:
health:
show-details: when-authorized
logging:
level:
root: INFO
gc.mda.kcg: DEBUG
org.flywaydb: INFO
# === 애플리케이션 커스텀 설정 ===
app:
prediction:
base-url: ${PREDICTION_BASE_URL:http://localhost:8001}
iran-backend:
base-url: ${IRAN_BACKEND_BASE_URL:http://localhost:18080}
cors:
allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:5173,http://localhost:5174}
jwt:
secret: ${JWT_SECRET:change-me-in-production-this-must-be-at-least-256-bits-long-secret-key}
expiration-ms: ${JWT_EXPIRATION_MS:86400000}

파일 보기

@ -0,0 +1,101 @@
-- ============================================================================
-- V001: 인증/조직/역할/로그인 이력 초기 스키마
-- ============================================================================
-- UUID 생성용 확장 (kcgaidb는 신규 DB이므로 한 번만 활성화)
CREATE EXTENSION IF NOT EXISTS pgcrypto;
-- ----------------------------------------------------------------------------
-- 조직 (계층 구조)
-- ----------------------------------------------------------------------------
CREATE TABLE kcg.auth_org (
org_sn BIGSERIAL PRIMARY KEY,
org_nm VARCHAR(100) NOT NULL,
org_abbr_nm VARCHAR(50),
org_tp_cd VARCHAR(20), -- HQ, REGIONAL, STATION, AGENCY
upper_org_sn BIGINT REFERENCES kcg.auth_org(org_sn),
sort_ord INT DEFAULT 0,
use_yn CHAR(1) NOT NULL DEFAULT 'Y',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_auth_org_upper ON kcg.auth_org(upper_org_sn);
COMMENT ON TABLE kcg.auth_org IS '조직 (해양경찰청 본청/지방청/서/파출소 등)';
-- ----------------------------------------------------------------------------
-- 사용자
-- ----------------------------------------------------------------------------
CREATE TABLE kcg.auth_user (
user_id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_acnt VARCHAR(50) UNIQUE NOT NULL, -- 로그인 ID
pswd_hash VARCHAR(255), -- BCrypt 해시 (PASSWORD provider 전용)
user_nm VARCHAR(100) NOT NULL, -- 이름
rnkp_nm VARCHAR(50), -- 직급/계급
email VARCHAR(255),
org_sn BIGINT REFERENCES kcg.auth_org(org_sn),
user_stts_cd VARCHAR(20) NOT NULL DEFAULT 'PENDING', -- PENDING/ACTIVE/LOCKED/INACTIVE/REJECTED
fail_cnt INT NOT NULL DEFAULT 0,
last_login_dtm TIMESTAMPTZ,
-- 인증 방식 확장 (Phase 9: GPKI/SSO)
auth_provider VARCHAR(20) NOT NULL DEFAULT 'PASSWORD', -- PASSWORD/GPKI/SSO
provider_sub VARCHAR(255), -- GPKI DN 또는 SSO subject
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_auth_user_org ON kcg.auth_user(org_sn);
CREATE INDEX idx_auth_user_status ON kcg.auth_user(user_stts_cd);
CREATE INDEX idx_auth_user_provider ON kcg.auth_user(auth_provider, provider_sub);
COMMENT ON TABLE kcg.auth_user IS '사용자 계정';
COMMENT ON COLUMN kcg.auth_user.auth_provider IS '인증 방식: PASSWORD(자체) / GPKI(공무원 인증서) / SSO';
-- ----------------------------------------------------------------------------
-- 역할
-- ----------------------------------------------------------------------------
CREATE TABLE kcg.auth_role (
role_sn BIGSERIAL PRIMARY KEY,
role_cd VARCHAR(50) UNIQUE NOT NULL, -- ADMIN, OPERATOR, ANALYST, VIEWER, FIELD
role_nm VARCHAR(100) NOT NULL,
role_dc TEXT,
dflt_yn CHAR(1) NOT NULL DEFAULT 'N', -- 신규 사용자 자동 배정 여부
builtin_yn CHAR(1) NOT NULL DEFAULT 'N', -- 내장 역할 (삭제 불가)
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
COMMENT ON TABLE kcg.auth_role IS '사용자 역할';
-- ----------------------------------------------------------------------------
-- 사용자-역할 매핑 (다대다)
-- ----------------------------------------------------------------------------
CREATE TABLE kcg.auth_user_role (
user_id UUID NOT NULL REFERENCES kcg.auth_user(user_id) ON DELETE CASCADE,
role_sn BIGINT NOT NULL REFERENCES kcg.auth_role(role_sn) ON DELETE CASCADE,
granted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
granted_by UUID,
PRIMARY KEY (user_id, role_sn)
);
CREATE INDEX idx_auth_user_role_role ON kcg.auth_user_role(role_sn);
-- ----------------------------------------------------------------------------
-- 로그인 이력
-- ----------------------------------------------------------------------------
CREATE TABLE kcg.auth_login_hist (
hist_sn BIGSERIAL PRIMARY KEY,
user_id UUID,
user_acnt VARCHAR(50),
login_dtm TIMESTAMPTZ NOT NULL DEFAULT now(),
login_ip VARCHAR(45),
user_agent TEXT,
result VARCHAR(20) NOT NULL, -- SUCCESS, FAILED, LOCKED
fail_reason VARCHAR(255),
auth_provider VARCHAR(20)
);
CREATE INDEX idx_login_hist_user ON kcg.auth_login_hist(user_id, login_dtm DESC);
CREATE INDEX idx_login_hist_acnt ON kcg.auth_login_hist(user_acnt, login_dtm DESC);
CREATE INDEX idx_login_hist_dtm ON kcg.auth_login_hist(login_dtm DESC);

파일 보기

@ -0,0 +1,57 @@
-- ============================================================================
-- V002: 권한 트리 + 권한 매트릭스 (wing 패턴)
-- ============================================================================
-- ----------------------------------------------------------------------------
-- 리소스 트리 (메뉴/탭/패널 계층 구조)
-- ----------------------------------------------------------------------------
CREATE TABLE kcg.auth_perm_tree (
rsrc_cd VARCHAR(100) PRIMARY KEY,
parent_cd VARCHAR(100) REFERENCES kcg.auth_perm_tree(rsrc_cd) ON DELETE CASCADE,
rsrc_nm VARCHAR(100) NOT NULL,
rsrc_desc TEXT,
icon VARCHAR(50),
rsrc_level INT NOT NULL DEFAULT 0, -- 0=tab(권한그룹), 1=subtab/패널, 2+=중첩
sort_ord INT NOT NULL DEFAULT 0,
use_yn CHAR(1) NOT NULL DEFAULT 'Y',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_perm_tree_parent ON kcg.auth_perm_tree(parent_cd);
CREATE INDEX idx_perm_tree_level ON kcg.auth_perm_tree(rsrc_level, sort_ord);
COMMENT ON TABLE kcg.auth_perm_tree IS '리소스 트리 (좌측 탭=권한그룹, 자식=패널/액션)';
COMMENT ON COLUMN kcg.auth_perm_tree.rsrc_cd IS '리소스 코드 (예: detection, detection:gear-detection)';
-- ----------------------------------------------------------------------------
-- 권한 매트릭스 (역할 × 리소스 × 오퍼레이션)
-- ----------------------------------------------------------------------------
CREATE TABLE kcg.auth_perm (
perm_sn BIGSERIAL PRIMARY KEY,
role_sn BIGINT NOT NULL REFERENCES kcg.auth_role(role_sn) ON DELETE CASCADE,
rsrc_cd VARCHAR(100) NOT NULL REFERENCES kcg.auth_perm_tree(rsrc_cd) ON DELETE CASCADE,
oper_cd VARCHAR(20) NOT NULL, -- READ, CREATE, UPDATE, DELETE, EXPORT, MANAGE
grant_yn CHAR(1) NOT NULL, -- Y(허용), N(명시적 거부)
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_by UUID,
UNIQUE(role_sn, rsrc_cd, oper_cd)
);
CREATE INDEX idx_perm_role ON kcg.auth_perm(role_sn);
CREATE INDEX idx_perm_rsrc ON kcg.auth_perm(rsrc_cd);
COMMENT ON TABLE kcg.auth_perm IS '권한 매트릭스 (명시적 권한만 저장, 미저장 시 트리 상속)';
-- ----------------------------------------------------------------------------
-- 시스템 설정 (메뉴 구성, 자동 승인 등 JSON)
-- ----------------------------------------------------------------------------
CREATE TABLE kcg.auth_setting (
setting_key VARCHAR(50) PRIMARY KEY,
setting_val JSONB NOT NULL,
description TEXT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_by UUID
);
COMMENT ON TABLE kcg.auth_setting IS '시스템 설정 (메뉴 구성, 자동승인, 정책 등)';

파일 보기

@ -0,0 +1,180 @@
-- ============================================================================
-- V003: 초기 역할 + 리소스 트리 시드 + 역할별 권한 매트릭스
-- ============================================================================
-- ----------------------------------------------------------------------------
-- 역할 시드 (5종)
-- ----------------------------------------------------------------------------
INSERT INTO kcg.auth_role(role_cd, role_nm, role_dc, dflt_yn, builtin_yn) VALUES
('ADMIN', '시스템 관리자', '모든 권한 + 사용자/역할/권한 관리', 'N', 'Y'),
('OPERATOR', '운영자', '분석 + 모선 확정/제외/학습 의사결정', 'N', 'Y'),
('ANALYST', '분석가', '조회 + 분석 (확정 권한 없음)', 'N', 'Y'),
('VIEWER', '조회자', '읽기 전용', 'Y', 'Y'),
('FIELD', '현장요원', '현장 작전 + 알림', 'N', 'Y');
-- ----------------------------------------------------------------------------
-- Level 0: 좌측 탭 (권한 그룹)
-- ----------------------------------------------------------------------------
INSERT INTO kcg.auth_perm_tree(rsrc_cd, parent_cd, rsrc_nm, rsrc_level, sort_ord, icon) VALUES
('dashboard', NULL, '대시보드', 0, 10, 'LayoutDashboard'),
('monitoring', NULL, '실시간 모니터링', 0, 20, 'Activity'),
('surveillance', NULL, '감시', 0, 30, 'Eye'),
('detection', NULL, '탐지', 0, 40, 'Radar'),
('vessel', NULL, '선박', 0, 50, 'Ship'),
('risk-assessment', NULL, '위험평가', 0, 60, 'AlertTriangle'),
('patrol', NULL, '순찰', 0, 70, 'Navigation'),
('enforcement', NULL, '단속', 0, 80, 'Shield'),
('field-ops', NULL, '현장작전', 0, 90, 'MapPin'),
('ai-operations', NULL, 'AI 운영', 0, 100, 'Bot'),
('statistics', NULL, '통계', 0, 110, 'BarChart3'),
('parent-inference-workflow', NULL, '모선 워크플로우', 0, 120, 'GitBranch'),
('admin', NULL, '관리', 0, 999, 'Settings');
-- ----------------------------------------------------------------------------
-- Level 1: 서브탭/패널
-- ----------------------------------------------------------------------------
INSERT INTO kcg.auth_perm_tree(rsrc_cd, parent_cd, rsrc_nm, rsrc_level, sort_ord) VALUES
-- monitoring
('monitoring:alert-list', 'monitoring', '알림 목록', 1, 10),
('monitoring:kpi-panel', 'monitoring', 'KPI 패널', 1, 20),
-- surveillance
('surveillance:live-map', 'surveillance', '실시간 맵', 1, 10),
('surveillance:map-control', 'surveillance', '해역 관리', 1, 20),
-- detection
('detection:gear-detection', 'detection', '어구탐지', 1, 10),
('detection:dark-vessel', 'detection', 'Dark Vessel', 1, 20),
('detection:china-fishing', 'detection', '중국어선', 1, 30),
('detection:gear-identification', 'detection', '어구식별', 1, 40),
-- vessel
('vessel:vessel-detail', 'vessel', '선박상세', 1, 10),
('vessel:transfer-detection', 'vessel', '전재탐지', 1, 20),
-- risk-assessment
('risk-assessment:risk-map', 'risk-assessment', '위험지도', 1, 10),
('risk-assessment:enforcement-plan', 'risk-assessment', '단속계획', 1, 20),
-- patrol
('patrol:patrol-route', 'patrol', '순찰경로', 1, 10),
('patrol:fleet-optimization', 'patrol', '선단최적화', 1, 20),
-- enforcement
('enforcement:enforcement-history', 'enforcement', '단속이력', 1, 10),
('enforcement:event-list', 'enforcement', '이벤트 목록', 1, 20),
-- field-ops
('field-ops:mobile-service', 'field-ops', '모바일 서비스', 1, 10),
('field-ops:ship-agent', 'field-ops', '함정 에이전트', 1, 20),
('field-ops:ai-alert', 'field-ops', 'AI 경보', 1, 30),
-- ai-operations
('ai-operations:ai-assistant', 'ai-operations', 'AI 어시스턴트', 1, 10),
('ai-operations:ai-model', 'ai-operations', 'AI 모델', 1, 20),
('ai-operations:mlops', 'ai-operations', 'MLOps', 1, 30),
-- statistics
('statistics:statistics', 'statistics', '통계', 1, 10),
('statistics:external-service', 'statistics', '외부 서비스', 1, 20),
-- parent-inference-workflow ★
('parent-inference-workflow:parent-review', 'parent-inference-workflow', '확정/거부', 1, 10),
('parent-inference-workflow:parent-exclusion', 'parent-inference-workflow', '후보 제외', 1, 20),
('parent-inference-workflow:label-session', 'parent-inference-workflow', '학습 세션', 1, 30),
('parent-inference-workflow:exclusion-management','parent-inference-workflow','전역 제외 관리', 1, 40),
-- admin ★
('admin:user-management', 'admin', '사용자 관리', 1, 10),
('admin:role-management', 'admin', '역할 관리', 1, 20),
('admin:permission-management', 'admin', '권한 관리', 1, 30),
('admin:menu-management', 'admin', '메뉴 설정', 1, 40),
('admin:audit-logs', 'admin', '감사로그', 1, 50),
('admin:access-logs', 'admin', '접근 이력', 1, 60),
('admin:login-history', 'admin', '로그인 이력', 1, 70),
('admin:system-config', 'admin', '시스템 설정', 1, 80);
-- ----------------------------------------------------------------------------
-- 권한 시드: 헬퍼 - 역할별로 일괄 INSERT
-- ----------------------------------------------------------------------------
-- ADMIN: 모든 리소스에 대해 R/C/U/D/EXPORT 부여
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
SELECT r.role_sn, t.rsrc_cd, op.oper_cd, 'Y'
FROM kcg.auth_role r
CROSS JOIN kcg.auth_perm_tree t
CROSS JOIN (VALUES ('READ'), ('CREATE'), ('UPDATE'), ('DELETE'), ('EXPORT')) AS op(oper_cd)
WHERE r.role_cd = 'ADMIN';
-- VIEWER: 모든 view 탭(READ만), admin/parent-inference-workflow는 deny
-- (1) view 가능 Level 0 탭에만 READ 부여
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
SELECT r.role_sn, t.rsrc_cd, 'READ', 'Y'
FROM kcg.auth_role r
CROSS JOIN kcg.auth_perm_tree t
WHERE r.role_cd = 'VIEWER'
AND t.rsrc_level = 0
AND t.rsrc_cd NOT IN ('admin', 'parent-inference-workflow');
-- (2) admin / parent-inference-workflow는 명시적 deny
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
SELECT r.role_sn, t.rsrc_cd, 'READ', 'N'
FROM kcg.auth_role r
CROSS JOIN kcg.auth_perm_tree t
WHERE r.role_cd = 'VIEWER'
AND t.rsrc_level = 0
AND t.rsrc_cd IN ('admin', 'parent-inference-workflow');
-- ANALYST: 모든 view + parent-inference-workflow READ만 (확정 권한 없음)
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
SELECT r.role_sn, t.rsrc_cd, 'READ', 'Y'
FROM kcg.auth_role r
CROSS JOIN kcg.auth_perm_tree t
WHERE r.role_cd = 'ANALYST'
AND t.rsrc_level = 0
AND t.rsrc_cd != 'admin';
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
SELECT r.role_sn, 'admin', 'READ', 'N'
FROM kcg.auth_role r WHERE r.role_cd = 'ANALYST';
-- OPERATOR: 모든 view + parent-inference-workflow R/C/U + admin은 거부
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
SELECT r.role_sn, t.rsrc_cd, 'READ', 'Y'
FROM kcg.auth_role r
CROSS JOIN kcg.auth_perm_tree t
WHERE r.role_cd = 'OPERATOR'
AND t.rsrc_level = 0
AND t.rsrc_cd != 'admin';
-- OPERATOR에 parent-inference-workflow의 자식 리소스에 R/C/U 부여
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
SELECT r.role_sn, t.rsrc_cd, op.oper_cd, 'Y'
FROM kcg.auth_role r
CROSS JOIN kcg.auth_perm_tree t
CROSS JOIN (VALUES ('READ'), ('CREATE'), ('UPDATE')) AS op(oper_cd)
WHERE r.role_cd = 'OPERATOR'
AND t.parent_cd = 'parent-inference-workflow'
AND t.rsrc_cd != 'parent-inference-workflow:exclusion-management'; -- 전역 제외는 admin만
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
SELECT r.role_sn, 'admin', 'READ', 'N'
FROM kcg.auth_role r WHERE r.role_cd = 'OPERATOR';
-- FIELD: field-ops, vessel, monitoring, dashboard READ만
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
SELECT r.role_sn, t.rsrc_cd, 'READ', 'Y'
FROM kcg.auth_role r
CROSS JOIN kcg.auth_perm_tree t
WHERE r.role_cd = 'FIELD'
AND t.rsrc_level = 0
AND t.rsrc_cd IN ('dashboard', 'monitoring', 'vessel', 'field-ops');
-- 다른 모든 탭 명시적 deny
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
SELECT r.role_sn, t.rsrc_cd, 'READ', 'N'
FROM kcg.auth_role r
CROSS JOIN kcg.auth_perm_tree t
WHERE r.role_cd = 'FIELD'
AND t.rsrc_level = 0
AND t.rsrc_cd NOT IN ('dashboard', 'monitoring', 'vessel', 'field-ops');
-- ----------------------------------------------------------------------------
-- 초기 admin 계정 시드 (Phase 3에서 BCrypt 해시로 갱신)
-- ----------------------------------------------------------------------------
INSERT INTO kcg.auth_user(user_acnt, user_nm, user_stts_cd, auth_provider, pswd_hash)
VALUES ('admin', '시스템 관리자', 'ACTIVE', 'PASSWORD', '$2a$10$placeholder.will.be.set.in.phase3');
INSERT INTO kcg.auth_user_role(user_id, role_sn)
SELECT u.user_id, r.role_sn
FROM kcg.auth_user u, kcg.auth_role r
WHERE u.user_acnt = 'admin' AND r.role_cd = 'ADMIN';

파일 보기

@ -0,0 +1,50 @@
-- ============================================================================
-- V004: 감사로그 + 접근 이력
-- ============================================================================
-- ----------------------------------------------------------------------------
-- 감사로그 (의사결정 액션 - @Auditable AOP가 기록)
-- ----------------------------------------------------------------------------
CREATE TABLE kcg.auth_audit_log (
audit_sn BIGSERIAL PRIMARY KEY,
user_id UUID,
user_acnt VARCHAR(50),
action_cd VARCHAR(50) NOT NULL, -- LOGIN, LOGOUT, CONFIRM_PARENT, REJECT_PARENT, EXCLUDE_CANDIDATE, LABEL_CREATE, ROLE_GRANT, PERM_UPDATE, USER_CREATE, ...
resource_type VARCHAR(50), -- VESSEL, GROUP, PARENT_INFERENCE, USER, ROLE, SYSTEM
resource_id VARCHAR(100),
detail JSONB,
ip_address VARCHAR(45),
result VARCHAR(20), -- SUCCESS, FAILED
fail_reason TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_audit_user ON kcg.auth_audit_log(user_id, created_at DESC);
CREATE INDEX idx_audit_action ON kcg.auth_audit_log(action_cd, created_at DESC);
CREATE INDEX idx_audit_resource ON kcg.auth_audit_log(resource_type, resource_id);
CREATE INDEX idx_audit_created ON kcg.auth_audit_log(created_at DESC);
COMMENT ON TABLE kcg.auth_audit_log IS '감사로그 (운영자 의사결정 + 시스템 액션)';
-- ----------------------------------------------------------------------------
-- 접근 이력 (모든 HTTP 요청 - AccessLogFilter가 기록)
-- ----------------------------------------------------------------------------
CREATE TABLE kcg.auth_access_log (
access_sn BIGSERIAL PRIMARY KEY,
user_id UUID,
user_acnt VARCHAR(50),
http_method VARCHAR(10),
request_path VARCHAR(500),
query_string TEXT,
status_code INT,
duration_ms INT,
ip_address VARCHAR(45),
user_agent TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_access_user ON kcg.auth_access_log(user_id, created_at DESC);
CREATE INDEX idx_access_path ON kcg.auth_access_log(request_path, created_at DESC);
CREATE INDEX idx_access_created ON kcg.auth_access_log(created_at DESC);
COMMENT ON TABLE kcg.auth_access_log IS '접근 이력 (모든 HTTP 요청)';

파일 보기

@ -0,0 +1,97 @@
-- ============================================================================
-- V005: 모선 워크플로우 (운영자 의사결정 - HYBRID)
-- iran 백엔드 마이그레이션 012/014의 백엔드 쓰기 부분만 이식
-- ============================================================================
-- ----------------------------------------------------------------------------
-- 모선 확정 결과 (운영자 액션 결과)
-- ----------------------------------------------------------------------------
CREATE TABLE kcg.gear_group_parent_resolution (
id BIGSERIAL PRIMARY KEY,
group_key VARCHAR(255) NOT NULL,
sub_cluster_id INT NOT NULL,
status VARCHAR(30) NOT NULL, -- UNRESOLVED, MANUAL_CONFIRMED, REVIEW_REQUIRED
selected_parent_mmsi VARCHAR(20),
rejected_candidate_mmsi VARCHAR(20),
approved_by UUID,
approved_at TIMESTAMPTZ,
rejected_at TIMESTAMPTZ,
manual_comment TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE(group_key, sub_cluster_id)
);
CREATE INDEX idx_parent_resolution_status ON kcg.gear_group_parent_resolution(status);
CREATE INDEX idx_parent_resolution_group ON kcg.gear_group_parent_resolution(group_key);
COMMENT ON TABLE kcg.gear_group_parent_resolution IS '모선 확정 결과 (HYBRID: prediction 후보 + 운영자 결정)';
-- ----------------------------------------------------------------------------
-- 운영자 액션 로그 (도메인 컨텍스트 보존, audit_log와 별개)
-- ----------------------------------------------------------------------------
CREATE TABLE kcg.gear_group_parent_review_log (
id BIGSERIAL PRIMARY KEY,
group_key VARCHAR(255) NOT NULL,
sub_cluster_id INT,
action VARCHAR(30) NOT NULL, -- CONFIRM, REJECT, RESET, EXCLUDE_GROUP, EXCLUDE_GLOBAL, LABEL_PARENT, CANCEL_LABEL, RELEASE_EXCLUSION
selected_parent_mmsi VARCHAR(20),
actor UUID,
actor_acnt VARCHAR(50),
comment TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_review_log_group ON kcg.gear_group_parent_review_log(group_key, created_at DESC);
CREATE INDEX idx_review_log_actor ON kcg.gear_group_parent_review_log(actor, created_at DESC);
CREATE INDEX idx_review_log_action ON kcg.gear_group_parent_review_log(action, created_at DESC);
COMMENT ON TABLE kcg.gear_group_parent_review_log IS '모선 워크플로우 운영자 액션 로그';
-- ----------------------------------------------------------------------------
-- 후보 제외 (운영자 또는 관리자가 잘못된 후보 차단)
-- ----------------------------------------------------------------------------
CREATE TABLE kcg.gear_parent_candidate_exclusions (
id BIGSERIAL PRIMARY KEY,
scope_type VARCHAR(20) NOT NULL, -- GROUP, GLOBAL
group_key VARCHAR(255), -- GLOBAL일 때는 NULL
sub_cluster_id INT,
excluded_mmsi VARCHAR(20) NOT NULL,
reason TEXT,
actor UUID,
actor_acnt VARCHAR(50),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
released_at TIMESTAMPTZ,
released_by UUID,
released_by_acnt VARCHAR(50)
);
CREATE INDEX idx_exclusion_scope ON kcg.gear_parent_candidate_exclusions(scope_type, group_key, excluded_mmsi);
CREATE INDEX idx_exclusion_active ON kcg.gear_parent_candidate_exclusions(scope_type, released_at) WHERE released_at IS NULL;
COMMENT ON TABLE kcg.gear_parent_candidate_exclusions IS '모선 후보 제외 (그룹/전역 스코프)';
-- ----------------------------------------------------------------------------
-- 학습 세션 (운영자가 정답 라벨링)
-- ----------------------------------------------------------------------------
CREATE TABLE kcg.gear_parent_label_sessions (
id BIGSERIAL PRIMARY KEY,
group_key VARCHAR(255) NOT NULL,
sub_cluster_id INT NOT NULL,
label_parent_mmsi VARCHAR(20) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'ACTIVE', -- ACTIVE, CANCELLED, COMPLETED
active_from TIMESTAMPTZ NOT NULL DEFAULT now(),
active_until TIMESTAMPTZ,
anchor_snapshot JSONB,
created_by UUID,
created_by_acnt VARCHAR(50),
cancelled_by UUID,
cancelled_at TIMESTAMPTZ,
cancel_reason TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_label_session_group ON kcg.gear_parent_label_sessions(group_key, status);
CREATE INDEX idx_label_session_status ON kcg.gear_parent_label_sessions(status);
COMMENT ON TABLE kcg.gear_parent_label_sessions IS '모선 추론 학습 세션 (운영자 정답 라벨링)';

파일 보기

@ -0,0 +1,13 @@
package gc.mda.kcg;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;
@SpringBootTest
class KcgAiApplicationTests {
@Test
void contextLoads() {
}
}