Compare commits

...

213 커밋

작성자 SHA1 메시지 날짜
594741906b Merge pull request 'release: 2026-04-17.4 (14건 커밋)' (#81) from develop into main 2026-04-17 07:43:28 +09:00
ddcb493160 Merge pull request 'docs: 릴리즈 노트 정리 (2026-04-17.4)' (#80) from release/2026-04-17.4 into develop 2026-04-17 07:42:57 +09:00
b0d9630dde docs: 릴리즈 노트 정리 (2026-04-17.4) 2026-04-17 07:42:44 +09:00
b1bd6e507a Merge pull request 'fix(backend): Spring 6.1 RestClient bean 모호성 기동 실패 해소' (#79) from hotfix/backend-parameters-flag into develop 2026-04-17 07:42:10 +09:00
f07d68b43f fix(backend): Spring 6.1 RestClient bean 모호성 기동 실패 해소
증상: rocky-211 의 kcg-ai-backend 가 `No qualifying bean of type RestClient,
but 2 were found: predictionRestClient, signalBatchRestClient` 로 기동 실패 반복.
PR #A 의 RestClientConfig 도입 이후 잠복해 있던 문제로, PredictionProxyController /
VesselAnalysisProxyController 의 필드 @Qualifier 가 Lombok `@RequiredArgsConstructor`
가 만든 constructor parameter 로 복사되지 않아 Spring 6.1 의 bean 이름 fallback 이
실패한 것.

- backend/pom.xml — default-compile / default-testCompile 의 configuration 에
  `<parameters>true</parameters>` 추가. spring-boot-starter-parent 기본값을 executions
  override 과정에서 덮어쓰지 않도록 명시.
- backend/src/main/java/lombok.config — `lombok.copyableAnnotations += org.springframework.beans.factory.annotation.Qualifier`.
  Lombok 이 필드의 @Qualifier 를 생성된 constructor parameter 로 복사해야 Spring 이
  파라미터 레벨 annotation 으로 해당 bean 을 식별할 수 있음.

검증: javap 로 PredictionProxyController 생성자의 RuntimeVisibleParameterAnnotations 에
@Qualifier("predictionRestClient") 가 실제 복사되었는지 확인, 재빌드/재배포 후 rocky-211
기동 성공("Started KcgAiApplication in 7.333 seconds") + Tomcat 18080 정상 리스닝.
2026-04-17 07:40:57 +09:00
5be83d2d9a Merge pull request 'release: 2026-04-17.3 (19건 커밋)' (#77) from develop into main 2026-04-17 07:36:56 +09:00
28be92047b Merge pull request 'docs: 릴리즈 노트 정리 (2026-04-17.3)' (#78) from release/2026-04-17.3 into develop 2026-04-17 07:35:23 +09:00
f92810b1b4 docs: 릴리즈 노트 정리 (2026-04-17.3) 2026-04-17 07:34:27 +09:00
b106113e47 Merge pull request 'docs: 절대 지침 섹션 추가 (develop 동기화 + design-system 준수)' (#76) from docs/claude-md-absolute-rules into develop 2026-04-17 07:29:28 +09:00
9a9388c37a docs: 릴리즈 노트 업데이트 2026-04-17 07:28:14 +09:00
48794e6962 docs: 절대 지침 섹션 추가 (develop 동기화 + design-system 준수) 2026-04-17 07:23:58 +09:00
fafed8ccdf Merge pull request 'release: 2026-04-17.2 (5건 커밋)' (#75) from develop into main
All checks were successful
Build and Deploy KCG AI Monitoring (Frontend) / build-and-deploy (push) Successful in 15s
2026-04-17 07:19:51 +09:00
485743c0e1 Merge pull request 'docs: 릴리즈 노트 정리 (2026-04-17.2)' (#74) from release/2026-04-17.2 into develop 2026-04-17 07:18:29 +09:00
ed48735310 docs: 릴리즈 노트 정리 (2026-04-17.2) 2026-04-17 07:18:19 +09:00
e0af0e089d Merge pull request 'feat(detection): GEAR_IDENTITY_COLLISION 탐지 패턴 추가 + docs 정비' (#73) from feature/gear-identity-collision into develop 2026-04-17 07:14:08 +09:00
a4e29629fc feat(detection): GEAR_IDENTITY_COLLISION 탐지 패턴 추가
동일 어구 이름이 서로 다른 MMSI 로 같은 5분 사이클에 동시 AIS 송출되는
공존 케이스를 신규 탐지 패턴으로 분리해 기록·분류한다. 부수 효과로
fleet_tracker.track_gear_identity 의 PK 충돌로 인한 사이클 실패도 해소.

Prediction
- algorithms/gear_identity.py: detect_gear_name_collisions + classify_severity
- fleet_tracker.py: 공존/교체 분기 분리, UPSERT helper, savepoint 점수 이전
- output/event_generator.py: run_gear_identity_collision_events 추가
- scheduler.py: track_gear_identity 직후 이벤트 승격 호출

Backend (domain/analysis)
- GearIdentityCollision 엔티티 + Repository(Specification+stats)
- GearIdentityCollisionService (@Transactional readOnly / @Auditable resolve)
- GearCollisionController /api/analysis/gear-collisions (list/stats/detail/resolve)
- GearCollisionResponse / StatsResponse / ResolveRequest (record)

DB
- V030__gear_identity_collision.sql: gear_identity_collisions 테이블
  + auth_perm_tree 엔트리(detection:gear-collision nav_sort=950) + 역할별 권한

Frontend
- shared/constants/gearCollisionStatuses.ts + catalogRegistry 등록
- services/gearCollisionApi.ts (list/stats/get/resolve)
- features/detection/GearCollisionDetection.tsx (PageContainer+Section+DataTable
  + 분류 액션 폼, design system SSOT 준수)
- componentRegistry + feature index + i18n detection.json / common.json(ko/en)
2026-04-17 06:53:12 +09:00
831045ace9 docs: 프로젝트 산출문서 2026-04-17 기준 정비
- docs/architecture.md: shared/components/ui 9개·i18n 네임스페이스 갱신
- docs/sfr-traceability.md: v3.0 전면 재작성 (운영 상태 기반 531 라인)
- docs/sfr-user-guide.md: 헤더 + SFR-01/02/09/10/11/12/13/17 구현 현황 갱신
- docs/data-sharing-analysis.md / next-refactoring.md / page-workflow.md: stale 3건 제거
2026-04-17 06:52:51 +09:00
62d14fc519 Merge pull request 'release: 2026-04-17 (11건 커밋)' (#72) from develop into main
All checks were successful
Build and Deploy KCG AI Monitoring (Frontend) / build-and-deploy (push) Successful in 16s
2026-04-17 05:39:15 +09:00
760bceed32 Merge pull request 'docs: 릴리즈 노트 정리 (2026-04-17)' (#71) from release/2026-04-17 into develop 2026-04-17 05:38:19 +09:00
fe43f6b022 docs: 릴리즈 노트 정리 (2026-04-17) 2026-04-17 05:37:39 +09:00
38c97686fc Merge pull request 'refactor(design-system): 하드코딩 색상 라이트/다크 대응 + raw HTML → 공통 컴포넌트 치환' (#70) from refactor/design-system-ssot into develop 2026-04-16 17:10:10 +09:00
5731fa30a1 docs: 릴리즈 노트 업데이트 (PR #C 디자인시스템 정비) 2026-04-16 17:09:32 +09:00
c1cc36b134 refactor(design-system): 하드코딩 색상 라이트/다크 대응 + raw button/input 공통 컴포넌트 치환
30개 파일 전 영역에 동일한 패턴으로 SSOT 준수:

**StatBox 재설계 (2파일)**:
- RealGearGroups, RealVesselAnalysis 의 `color: string` prop 제거
- `intent: BadgeIntent` prop + `INTENT_TEXT_CLASS` 매핑 도입

**raw `<button>` → Button 컴포넌트 (다수)**:
- `bg-blue-600 hover:bg-blue-500 text-on-vivid ...` → `<Button variant="primary">`
- `bg-orange-600 ...` / `bg-green-600 ...` → `<Button variant="primary">`
- `bg-red-600 ...` → `<Button variant="destructive">`
- 아이콘 전용 → `<Button variant="ghost" aria-label=".." icon={...} />`
- detection/enforcement/admin/parent-inference/statistics/ai-operations/auth 전영역

**raw `<input>` → Input 컴포넌트**:
- parent-inference (ParentReview, ParentExclusion, LabelSession)
- admin (PermissionsPanel, UserRoleAssignDialog)
- ai-operations (AIAssistant)
- auth (LoginPage)

**raw `<select>` → Select 컴포넌트**:
- detection (RealGearGroups, RealVesselAnalysis, ChinaFishing)

**커스텀 탭 → TabBar/TabButton (segmented/underline)**:
- ChinaFishing: 모드 탭 + 선박 탭 + 통계 탭

**raw `<input type="checkbox">` → Checkbox**:
- GearDetection FilterCheckGroup

**하드코딩 Tailwind 색상 라이트/다크 쌍 변환 (전영역)**:
- `text-red-400` → `text-red-600 dark:text-red-400`
- `text-green-400` → `text-green-600 dark:text-green-400`
- blue/cyan/orange/yellow/purple/amber 동일 패턴
- `text-*-500` 아이콘도 `text-*-600 dark:text-*-500` 로 라이트 모드 대응
- 상태 dot (bg-red-500 animate-pulse 등)은 의도적 시각 구분이므로 유지

**에러 메시지 한글 → t('error.errorPrefix') 통일**:
- detection/parent-inference/admin 에서 `에러: {error}` 패턴 → `t('error.errorPrefix', { msg: error })`

**결과**: tsc 0 errors / eslint 0 errors (84 warnings 기존)
2026-04-16 17:09:14 +09:00
2c23049c8e Merge pull request 'refactor(i18n): alert/confirm/aria-label 하드코딩 한글 제거' (#69) from refactor/i18n-alert-aria-confirm into develop 2026-04-16 16:33:22 +09:00
03f2ea08db docs: 릴리즈 노트 업데이트 (PR #B i18n 정비) 2026-04-16 16:32:53 +09:00
8af693a2df refactor(i18n): alert/confirm/aria-label 하드코딩 한글 제거
공통 번역 리소스 확장:
- common.json 에 aria / error / dialog / success / message 네임스페이스 추가
- ko/en 양쪽 동일 구조 유지 (aria 36 키 + error 7 키 + dialog 4 키 + message 5 키)

alert/confirm 11건 → t() 치환:
- parent-inference: ParentReview / LabelSession / ParentExclusion
- admin: PermissionsPanel / UserRoleAssignDialog / AccessControl

aria-label 한글 40+건 → t() 치환:
- parent-inference (group_key/sub_cluster/정답 parent MMSI/스코프 필터 등)
- admin (역할 코드/이름, 알림 제목/내용, 시작일/종료일, 코드 검색, 대분류 필터, 수신 현황 기준일)
- detection (그룹 유형/해역 필터, 관심영역, 필터 설정/초기화, 멤버 수, 미니맵/재생 닫기)
- enforcement (확인/선박 상세/단속 등록/오탐 처리)
- vessel/statistics/ai-operations (조회 시작/종료 시각, 업로드 패널 닫기, 전송, 예시 URL 복사)
- 공통 컴포넌트 (SearchInput, NotificationBanner)

MainLayout 언어 토글:
- title 삼항분기 → t('message.switchToEnglish'/'switchToKorean')
- aria-label="페이지 내 검색" → t('aria.searchInPage')
- 토글 버튼 자체에 aria-label={t('aria.languageToggle')} 추가
2026-04-16 16:32:37 +09:00
5a57959bd5 Merge pull request 'refactor: 프로젝트 뼈대 정리 — iran 잔재 제거 + 백엔드 계층 + 카탈로그' (#68) from refactor/cleanup-iran-backend-catalog into develop 2026-04-16 16:20:05 +09:00
bb40958858 docs: 릴리즈 노트 업데이트 (PR #A 구조 정비) 2026-04-16 16:19:02 +09:00
9251d7593c refactor: 프로젝트 뼈대 정리 — iran 잔재 제거 + 백엔드 계층 분리 + 카탈로그 등록
iran 백엔드 프록시 잔재 제거:
- IranBackendClient dead class 삭제, AppProperties/application.yml iran-backend 블록 제거
- Frontend UI 라벨/주석/system-flow manifest deprecated 마킹
- CLAUDE.md 시스템 구성 다이어그램 최신화

백엔드 계층 분리:
- AlertController/MasterDataController/AdminStatsController 에서 repository/JdbcTemplate 직접 주입 제거
- AlertService/MasterDataService/AdminStatsService 신규 계층 도입 + @Transactional(readOnly=true)
- Proxy controller 의 @PostConstruct RestClient 생성 → RestClientConfig @Bean 으로 통합

감사 로그 보강:
- EnforcementService createRecord/updateRecord/createPlan 에 @Auditable 추가
- VesselAnalysisGroupService.resolveParent 에 PARENT_RESOLVE 액션 기록

카탈로그 정합성:
- performanceStatus 를 catalogRegistry 에 등록 (쇼케이스 자동 노출)
- alertLevels 확장: isValidAlertLevel / isHighSeverity / getAlertLevelOrder
- LiveMapView/DarkVesselDetection 시각 매핑(opacity/radius/tier score) 상수로 추출
- GearIdentification/vesselAnomaly 직접 분기를 타입 가드/헬퍼로 치환
2026-04-16 16:18:18 +09:00
c8673246f3 Merge pull request 'release: 2026-04-16.7 (4건 커밋)' (#67) from develop into main
All checks were successful
Build and Deploy KCG AI Monitoring (Frontend) / build-and-deploy (push) Successful in 15s
2026-04-16 15:26:34 +09:00
312dde7b86 Merge pull request 'docs: 릴리즈 노트 정리 (2026-04-16.7)' (#66) from release/2026-04-16.7 into develop 2026-04-16 15:25:50 +09:00
9063095a9b docs: 릴리즈 노트 정리 (2026-04-16.7) 2026-04-16 15:25:36 +09:00
65b98c53be Merge pull request 'feat: 경량 분석 riskScore 해상도 개선 + vessel_type 매핑 + 중국 그리드 정합성' (#65) from feature/risk-scoring-vessel-type into develop 2026-04-16 15:23:37 +09:00
3372d06545 docs: 릴리즈 노트 업데이트 2026-04-16 15:22:51 +09:00
524df19f20 feat(frontend): 선박 유형 한글 카탈로그 + 중국 선박 분석 그리드 정합성
vessel_type 카탈로그
- shared/constants/vesselTypes.ts 신규 — TRAWL/PURSE/GILLNET/LONGLINE/
  TRAP/CARGO/UNKNOWN 7종 + getVesselTypeLabel / getVesselTypeIntent
  헬퍼. 기존 alertLevels 카탈로그 패턴 답습
- catalogRegistry 에 VESSEL_TYPES 등록 — design-system 쇼케이스에 자동
  노출

RealVesselAnalysis 필터 props 확장
- Props 에 mmsiPrefix / minRiskScore / size 추가 (all·spoofing mode)
- 선박 유형 컬럼을 한글 라벨로 렌더
- RealAllVessels 편의 export 를 mmsiPrefix='412' 로 고정 + 제목을
  '중국 선박 전체 분석 결과 (실시간)' 로 변경

효과
- Tab 1 상단 그리드가 중국 선박만 표시해 페이지 성격과 일치
- 선박 유형 '저인망/선망/유자망/연승/통발/운반선/미분류' 한글 표시
- 55점 HIGH 같은 중국 선박이 상단/하단 양쪽에 일관되게 노출
2026-04-16 15:20:08 +09:00
6fb0b04992 feat(prediction): 경량 분석 riskScore 해상도 개선 + vessel_type 매핑
경량 경로 선박 60.8%가 45점 고정으로 수렴하고 98.6%가 vessel_type
UNKNOWN 으로만 기록되던 문제를 해결한다.

riskScore (compute_lightweight_risk_score)
- dark_suspicion_score(0~100) 직접 반영: min(30, score*0.3)
- EEZ_OR_BEYOND 기선 근접도 가산 (12NM 내 +15, 24NM 내 +8)
- dark_history_24h 가산 (dark_suspicion_score 미반영 케이스만)
- 허가 이력 +20 → +8/+15 차등 (dark_suspicion_score 있을 때 이중계산 방지)
- gap_duration_min 4구간 차등 (fallback: 720m/180m/60m/30m)

vessel_type (신규 vessel_type_mapping.py)
- fleet_vessels fishery_code → VesselType 매핑:
  PT/PT-S/OT → TRAWL, GN → GILLNET, PS → PURSE, FC → CARGO
- GILLNET / CARGO 2개 값 신규 추가 (기존 TRAWL/PURSE/LONGLINE/TRAP/UNKNOWN)
- scheduler.py 경량 경로에서 등록선은 매핑, 미등록선은 UNKNOWN 유지

배포 후 검증 (redis-211 15:15 사이클)
- risk_score 분포: 45점 60.8% → 0% (11~40 범위 고르게 분산)
- vessel_type: UNKNOWN 98.6% → 89.1% (886척이 구체 유형으로 전환,
  TRAWL 518 / LONGLINE 171 / TRAP 78 / PURSE 73 / GILLNET 38 / CARGO 8)
- 412354335 샘플: 45 MEDIUM 고정 → 20 LOW (dss=40 × 0.3 + 축소 허가)
2026-04-16 15:19:55 +09:00
2f94c2a0a4 Merge pull request 'release: 2026-04-16.6 (5건 커밋)' (#64) from develop into main
All checks were successful
Build and Deploy KCG AI Monitoring (Frontend) / build-and-deploy (push) Successful in 15s
2026-04-16 14:40:48 +09:00
1def64dd1d Merge pull request 'docs: 릴리즈 노트 정리 (2026-04-16.6)' (#63) from release/2026-04-16.6 into develop 2026-04-16 14:39:52 +09:00
49c11e7b4a docs: 릴리즈 노트 정리 (2026-04-16.6) 2026-04-16 14:38:59 +09:00
9607f798dd Merge pull request 'feat: 중국어선 감시 실데이터 연동 + 특이운항 미니맵/판별 패널' (#62) from feature/china-fishing-backend-api into develop 2026-04-16 14:36:31 +09:00
a9f81c6c7e docs: 릴리즈 노트 업데이트 2026-04-16 14:35:43 +09:00
d82eaf7e79 feat(frontend): 중국어선 감시 실데이터 연동 + 특이운항 미니맵/판별 패널
Tab 1 AI 감시 대시보드 / Tab 2 환적탐지 / Tab 3 어구판별 3개 탭을
deprecated iran proxy 에서 자체 /api/analysis/* 로 전환하고, 특이운항
선박의 24h 항적과 판별 구간 상세를 지도와 패널로 제공한다.

서비스 계층
- analysisApi.ts 확장: getAnalysisStats / getAnalysisVessels(필터 3종) /
  getGearDetections 추가. VesselAnalysis 에 violationCategories /
  bd09OffsetM / ucafScore / ucftScore / clusterId 필드 노출
- analysisAdapter.ts: flat VesselAnalysis → nested VesselAnalysisItem
  변환으로 기존 컴포넌트 재사용
- vesselAnalysisApi.ts fetchVesselAnalysis @deprecated 마킹

Tab 1 (ChinaFishing)
- 서버 집계(stats) 기준 카운터 재구성. 중국어선 / Dark / 환적 / 고위험
  모두 mmsiPrefix=412 로 서버 필터
- 선박 리스트 vessel_type UNKNOWN 인 경우 "중국어선" + "미분류" 로 표시
- 특이운항 row 클릭 → 아래 행에 미니맵 + 판별 패널 배치
- 관심영역 / VIIRS / 기상 / VTS 카드에 "데모 데이터" 뱃지. 비허가 /
  제재 / 관심 탭 disabled + "준비중" 뱃지

Tab 2 (RealVesselAnalysis)
- /analysis/dark / /analysis/transship / /analysis/vessels mode별 분기
- 상단 통계 카드를 items 클라이언트 집계로 전환해 하단 테이블과 정합

Tab 3 (GearIdentification)
- 최하단 "최근 자동탐지 결과" 섹션 추가. row 클릭 시 상단 입력 폼
  프리필 + 결과 패널에 자동탐지 근거 프리셋

특이운항 판별 시각화 (VesselMiniMap / VesselAnomalyPanel /
vesselAnomaly 유틸)
- 24h getAnalysisHistory 로드 → classifyAnomaly 로 DARK/SPOOFING/
  TRANSSHIP/GEAR_VIOLATION/HIGH_RISK 5개 카테고리 판별. 좌표는
  top-level lat/lon 우선, features.gap_start_* fallback
- groupAnomaliesToSegments: 5분 주기 반복되는 동일 신호를 시작~종료
  구간으로 병합
- 미니맵: 전체 궤적은 연한 파랑, segment 시간범위와 매칭되는 AIS
  궤적 서브구간을 severity 색(CRITICAL 빨강 / WARNING 주황 / INFO
  파랑) 으로 하이라이트. 이벤트 기준 좌표는 작은 흰 점
- 판별 패널: 시작→종료 · 지속 · N회 연속 감지 · 카테고리 뱃지 · 설명
2026-04-16 14:31:26 +09:00
820ed75585 feat(backend): /api/analysis stats + gear-detections 엔드포인트 추가
중국어선 감시 화면의 실데이터 연동을 위해 기존 /api/analysis 에 집계/
필터 기능을 보강한다.

- VesselAnalysisResult 엔티티에 violation_categories TEXT[] 매핑 추가
- VesselAnalysisResponse 에 violationCategories / bd09OffsetM /
  ucafScore / ucftScore / clusterId 5개 필드 노출
- /api/analysis/vessels 에 mmsiPrefix / minRiskScore / minFishingPct
  필터 파라미터 추가
- /api/analysis/stats: MMSI별 최신 row 기준 단일 쿼리 COUNT FILTER
  집계 (total/dark/spoofing/transship/risk별/zone별/fishing/avgRisk)
- /api/analysis/gear-detections: gear_code/judgment NOT NULL 인 row
  MMSI 중복 제거 목록. 어구/어망 판별 탭 '자동탐지 결과' 섹션 연동용
- deprecated 스텁 /api/vessel-analysis 는 프론트 호출 제거 후 다음
  릴리즈에서 삭제 예정 (이번 PR 에서는 유지)
2026-04-16 14:31:02 +09:00
14eb4c7ea3 feat(prediction): vessel_analysis_results 에 분석 시점 lat/lon 저장
AnalysisResult 에 lat/lon 필드 + to_db_tuple 반영 + upsert_results SQL
컬럼 추가. 분류 파이프라인(last_row) / 경량 분석(all_positions) 두 경로
모두 분석 시점의 선박 위치를 함께 기록해 프론트 미니맵에서 특이운항
판별 위치를 실제 항적 위에 표시할 수 있게 한다.

배포 후 첫 사이클 8173/8173 lat/lon non-null 확인.
2026-04-16 14:30:49 +09:00
d0c8a88f21 Merge pull request 'release: 2026-04-16.5 (Phase 1-B admin DS)' (#61) from develop into main
All checks were successful
Build and Deploy KCG AI Monitoring (Frontend) / build-and-deploy (push) Successful in 16s
2026-04-16 11:37:36 +09:00
da4557a5df Merge pull request 'docs: 릴리즈 노트 정리 (2026-04-16.5)' (#60) from release/2026-04-16.5 into develop 2026-04-16 11:37:20 +09:00
3248ec581b docs: 릴리즈 노트 정리 (2026-04-16) 2026-04-16 11:37:15 +09:00
369aaec06f Merge pull request 'refactor(frontend): admin Phase 1-B 디자인 시스템 하드코딩 색상 제거' (#59) from feature/admin-ds-phase1b into develop 2026-04-16 11:36:45 +09:00
f1dc9f7a5a docs: 릴리즈 노트 업데이트 2026-04-16 11:36:22 +09:00
234169d540 refactor(frontend): admin 11개 페이지 디자인 시스템 하드코딩 색상 제거 (Phase 1-B)
129건 하드코딩 Tailwind 색상 → 시맨틱 토큰 치환:
- text-cyan-400 (45건) → text-label
- text-green-400/500 (51건) → text-label + Badge intent="success"
- text-red-400/500 (31건) → text-heading + Badge intent="critical"
- text-blue-400 (33건) → text-label + Badge intent="info"
- text-purple-400 (20건) → text-heading
- text-yellow/orange/amber (32건) → text-heading + Badge intent="warning"

raw <button> → <Button> 컴포넌트 교체 (DataHub/NoticeManagement/SystemConfig 등)
미사용 import 정리 (SaveButton/DataTable/lucide 아이콘)

대상: AIAgentSecurityPage, AISecurityPage, AccessControl, AccessLogs,
AdminPanel, AuditLogs, DataHub, LoginHistoryView, NoticeManagement,
PermissionsPanel, SystemConfig

검증: tsc 0 errors, eslint 0 errors, 하드코딩 색상 잔여 0건
2026-04-16 11:25:51 +09:00
7d101604cc Merge pull request 'release: 2026-04-16.4 (50건 커밋)' (#58) from develop into main 2026-04-16 11:10:45 +09:00
6f997ad796 Merge pull request 'docs: 릴리즈 노트 정리 (2026-04-16.4)' (#57) from release/2026-04-16.4 into develop 2026-04-16 11:09:42 +09:00
15e17759f8 docs: 릴리즈 노트 정리 (2026-04-16) 2026-04-16 11:09:17 +09:00
25de59be12 Merge pull request 'feat(prediction): DAR-03 탐지 튜닝 v2 (pair/매칭/G-02/G-03)' (#56) from feature/dar03-tuning-v2 into develop 2026-04-16 11:07:47 +09:00
20e2b029c5 docs: 릴리즈 노트 업데이트 2026-04-16 11:07:06 +09:00
7314c7f65f fix(prediction): match_ais_to_registry 대상을 전체 중국 선박으로 확장 (A-2 핵심 버그)
버그: all_ais 를 vessel_dfs(classification 통과 500척)만 대상으로 구성 →
허가선 906척 중 실제 AIS 존재 866척(upper bound 95.8%) 임에도 매칭률
9.4% 에 머물렀던 진짜 원인.

수정: vessel_store._tracks 전체에서 중국 MID(412/413/414) 활성 선박을
대상으로 match_ais_to_registry 호출. 매칭률 upper bound 95.8% 까지 회복 기대.

검증: 이번 AIS 실제 샘플 조사로 판명:
- AIS 고유 정규화 이름 411,908 개
- 허가선 정규화 이름 904 개
- 교집합 866 개 (95.8%)
→ 정규화 로직은 정상 작동. 문제는 호출 범위였음.
2026-04-16 10:32:54 +09:00
535704707b fix(prediction): FUZZY 정규화를 공백/대소문자만으로 축소 + name_en 전용 (A-2 후속)
버그 원인: 초기 정규화가 선박번호(suffix)까지 제거 → '浙岭渔20865' → '浙岭渔' 로
축약 → 동명이 수십 개 발생 → len(unassigned)>1 조건에 전부 탈락 → FUZZY=0건.

중국/한국 어선명은 업체명+선박번호가 고유 식별자이므로 숫자 자체는 보존해야 함.
정규화는 공백/구두점/대소문자/'NO.' 마커만 통일:
  'ZHE LING YU 20865' ↔ 'zhelingyu20865' ↔ 'ZHE-LING-YU-20865' 모두 일치

FUZZY 매칭 key 는 name_en 만 등록 (AIS 보고 이름이 영문이 주류).
2026-04-16 10:13:35 +09:00
37ae1bfa48 feat(prediction): 서버 스크립트에 tier/match_method/G-02/G-03 추적 추가
scheduler.py features write 확장:
- pair_tier (STRONG/PROBABLE/SUSPECT)
- pair_type, pair_reject_reason
- similarity, confidence
- registered_fishery_code
→ SUSPECT tier 까지 raw_pair 로 보존하여 통계 집계 가능

diagnostic-snapshot.sh (5분 주기):
- 4-4.1 pair_trawl tier 분포 + avg_sync_min
- 4-4.2 reject 사유 journal 로그 tail
- 4-4.3 G-02 금어기 상세 (observed_at, fishery_code)
- 4-4.4 G-03 미등록 어구 상세 (detected/registered/allowed)
- 7.5-2b match_method 분포 (EXACT vs FUZZY)
- 7.5-2c fishery_code × match_method 교차

hourly-analysis-snapshot.sh (1시간 주기):
- P3.5 match_method 분포 + avg_confidence
- P3.6 fishery_code × match_method 교차
- D3.6 pair_tier 분포 + avg_sync_min + avg_sep_nm
- D3.7 G-02/G-03 건수 + gear_judgment 분포
- D3.8 reject 사유 1시간 journal 집계
2026-04-16 09:50:42 +09:00
68e690d791 refactor(prediction): pair_trawl tier 분류 + join_key time_bucket 전환
두 가지 근본 버그를 동시에 해결:

1. Join key 버그 — raw AIS timestamp(ms 단위) inner join 은 두 선박 간 우연히
   일치하는 확률이 거의 0. vessel_store._tracks 의 time_bucket(5분 리샘플)
   컬럼을 우선 사용. _pair_join_key() 헬퍼로 fallback 지원.

2. AND 게이트 0건 문제 — 스펙 100%(2h 연속 + 500m + SOG 2-4 + sog_delta 0.5 +
   cog 10°)를 전부 요구하면 실제 공조 페어를 놓침. Tier 분류로 재설계:
   - STRONG  : 스펙 100% (24 cycles, 기존 조건)
   - PROBABLE: 800m / SOG 1.5-5 / sog_delta 1.0 / cog 20° / 12 cycles + 0.6 ratio
   - SUSPECT : 동일 완화 조건 / 6 cycles + 0.3 ratio (플래그만)
   G-06 판정은 STRONG/PROBABLE 만. SUSPECT 는 약한 신호로 노출.

거부 사유 카운터(REJECT_COUNTERS) + tier 카운트를 사이클별 로그 출력.
'조건이 엄격한건지 실제 페어가 없는건지' 원인 구분 가능.

피드백 메모리: feedback_detection_tier.md
2026-04-16 09:47:08 +09:00
f5374a5316 refactor(prediction): pair_trawl sog/cog on-demand 계산 + 중국 MID 413/414 추가
- pair_trawl._ensure_sog_cog(): _trajectory_similarity 진입 시 sog/cog 없으면
  vessel_store._compute_sog_cog() 로 haversine 계산 (tracks + timestamp 만 있으면 OK)
- pool 을 vessel_store._tracks 전체(55k)로 원복: 한국 440xxx/러시아 273xxx 페어 탐색 가능
- base 필터 중국 MID 확장: 412 → 412/413/414 (본토/홍콩/마카오)
- df_targets groupby 우회 제거 (불필요한 결합)
2026-04-16 09:29:38 +09:00
6c08d831d0 fix(prediction): pair_trawl pool 에 sog/cog 계산된 궤적 사용 (A-1 후속)
버그: vessel_store._tracks 는 raw_sog 만 보유 → _trajectory_similarity 가
sog/cog 컬럼 부재로 항상 0 반환 → candidates=0.

df_targets(select_analysis_targets 결과, 412* 전체 8k+ 에 sog/cog 계산)
를 mmsi 별로 groupby 하여 pool_tracks dict 생성. base 확장 필터의
sog 컬럼도 동일하게 적용.

첫 사이클 로그: base=512, pool=54825 → candidates=0 (2026-04-16 09:25).
수정 후 재검증 예정.
2026-04-16 09:27:42 +09:00
1a065840bd feat(prediction): G-02 금어기 + G-03 미등록 어구 탐지 추가 (A-4)
V029 fishery_permit_cn 스키마를 입력으로 하여 보류 중이던 G-02/G-03
판정 함수를 신설. classify_gear_violations() 시그니처에 permit_periods,
registered_fishery_code, observation_ts 매개변수 추가.

- G-02 (CLOSED_SEASON_FISHING, score 18): 관측 시각이 fishing_period_1/2
  허가 기간 밖이면 금어기 조업
- G-03 (UNREGISTERED_GEAR, score 12): 감지 어구가 fishery_code 허용 어구
  집합(PT→TRAWL/PT-S, GN→GILLNET, PS→PURSE, OT→TRAWL, FC→금지)에 없음
- fleet_tracker: _parse_period_range() 'YYYY/MM/DD - YYYY/MM/DD' 파서 +
  get_permit_periods() + get_registered_fishery_code()
- violation_classifier: CLOSED_SEASON_FISHING / UNREGISTERED_GEAR judgment →
  ILLEGAL_GEAR 카테고리 매핑

데이터 부재(permit_periods 빈 값, fishery_code 미등록) 시 판정 보류 → False.
검증 목표: 1시간 내 G-02/G-03 각 ≥ 1건
2026-04-16 09:12:07 +09:00
64df7b180c feat(prediction): fleet_tracker NAME_FUZZY 매칭 추가 (A-2)
_normalize_vessel_name()로 선박번호 suffix(호/號/号/NoN/#N) +
공백/구두점 제거 후 upper() 통일. EXACT 실패 시 FUZZY 단계로
매칭 시도. 동명이 후보 2개 이상이거나 이미 다른 MMSI 할당된 vid는
제외하여 중복 방지.

- match_ais_to_registry: NAME_EXACT → NAME_FUZZY (confidence 0.80)
- track_gear_identity: parent_name 매칭에도 FUZZY 적용
- _name_fuzzy_map 캐시로 1회 lookup

검증 목표: fleet_vessels.mmsi 매칭률 8.7% → 30%+
2026-04-16 09:08:35 +09:00
d817e4cbbf perf(prediction): pair_trawl base 확장 + 임계 완화 (A-1)
- SIMILARITY_OBSERVE 0.50 → 0.45 (pool 확대 후 recall 확보)
- min_common_samples 6 → 4 (비분류 선박 샘플 부족 보정)
- base_mmsis에 조업 속력대(1.5~5.0kn) 중국 412* 전체 추가
  (classifications 500척만으로 bbox 기점 부족 → 실제 공조 페어 누락)

검증 목표: 10분 사이클에서 pair_detected ≥ 1
2026-04-16 09:06:50 +09:00
020b3b7643 Merge pull request 'release: 2026-04-16.3 (6건 커밋) — Admin 디자인 시스템 Phase 1-A' (#55) from develop into main
All checks were successful
Build and Deploy KCG AI Monitoring (Frontend) / build-and-deploy (push) Successful in 15s
2026-04-16 08:45:02 +09:00
ac67b9b7af Merge pull request 'docs: 릴리즈 노트 정리 (2026-04-16.3)' (#54) from release/2026-04-16.3 into develop 2026-04-16 08:44:29 +09:00
9c74459acc docs: 릴리즈 노트 정리 (2026-04-16.3) 2026-04-16 08:44:17 +09:00
03d3d428e5 Merge pull request 'refactor(admin): 3개 신규 페이지 디자인 시스템 준수 + RBAC skeleton (Phase 1-A)' (#53) from feature/admin-ds-rbac-refactor into develop 2026-04-16 08:43:52 +09:00
e6b053dfa2 Merge remote-tracking branch 'origin/develop' into feature/admin-ds-rbac-refactor
# Conflicts:
#	docs/RELEASE-NOTES.md
2026-04-16 08:43:33 +09:00
a68945bd07 docs: 릴리즈 노트 업데이트 2026-04-16 08:40:07 +09:00
69b97d33f6 refactor(admin): 3개 신규 페이지 디자인 시스템 준수 + RBAC skeleton (Phase 1-A)
- performanceStatus.ts 카탈로그 신설 (status→intent/hex/label)
- 자체 탭 네비 3건 → TabBar/TabButton (underline variant)
- raw <button> 3건 → TabButton
- PerformanceMonitoring hex 9건 → getPerformanceStatusHex 카탈로그
- statusIntent/barColor 로컬 함수 → 카탈로그 getPerformanceStatusIntent + utilizationStatus
- 3개 페이지 useAuth().hasPermission() skeleton 배치 (Phase 3 action guard 대비)
- 남은 hex 10건(DAR-10/11 KPI)은 Phase 1-B 전역 admin 시맨틱 색상 통일에서 처리

검증: tsc + ESLint + vite build 모두 통과
2026-04-16 08:39:34 +09:00
21b5048a9c Merge pull request 'release: 2026-04-16.2 — 성능 모니터링 + DAR-10/11 + DAR-03 어구 비교' (#52) from release/2026-04-16.2-main into main
All checks were successful
Build and Deploy KCG AI Monitoring (Frontend) / build-and-deploy (push) Successful in 15s
2026-04-16 08:21:43 +09:00
74bdfa3f04 Merge branch 'main' into release/2026-04-16.2-main 2026-04-16 08:21:09 +09:00
bf473c12bf Merge pull request 'docs: 릴리즈 노트 정리 (2026-04-16.2)' (#49) from release/2026-04-16.2 into develop 2026-04-16 08:03:44 +09:00
77b6fc9b14 docs: 릴리즈 노트 정리 (2026-04-16.2) 2026-04-16 08:03:32 +09:00
0f29172a5d Merge pull request 'feat: 시스템관리 > 감사·보안에 성능 모니터링(PER-01~06) 메뉴 추가' (#48) from merge/performance-monitoring-into-develop into develop 2026-04-16 08:01:53 +09:00
d12c81f233 Merge remote-tracking branch 'origin/feature/performance-monitoring-menu' into merge-check/performance-monitoring-menu-into-develop 2026-04-16 07:59:56 +09:00
6c7c0f4ca6 Merge pull request 'release: 2026-04-16 (20건 커밋) — DAR-03 탐지 보강 + 한중어업협정 906척 레지스트리' (#47) from develop into main 2026-04-16 07:49:39 +09:00
0a5d8fe213 Merge pull request 'docs: 릴리즈 노트 정리 (2026-04-16)' (#46) from release/2026-04-16 into develop 2026-04-16 07:48:41 +09:00
dd0a934203 docs: 릴리즈 노트 정리 (2026-04-16) 2026-04-16 07:48:23 +09:00
47c553d993 Merge pull request 'feat(prediction): DAR-03 탐지 로직 보강 + 한중어업협정 906척 레지스트리 적재' (#45) from feature/dar03-detection-hardening into develop 2026-04-16 07:46:30 +09:00
9d538cffd8 docs: 릴리즈 노트 업데이트 2026-04-16 07:45:14 +09:00
8ff04a8cca feat(prediction): DAR-03 탐지 로직 보강 + 한중어업협정 906척 레지스트리 적재
- V029: kcg.fishery_permit_cn 신규 테이블(연단위, permit_year+permit_no 복합 유니크) + fleet_vessels permit_year/fishery_code 컬럼
- load_fishery_permit_cn.py: xls → DB 적재 스크립트, 906척 + 497 신청인사 upsert
- G-04/G-05/G-06 Dead code 해결: classify_gear_violations 호출 연결, dir() 버그 제거
- find_pair_candidates: bbox 1차 + 궤적 유사도(location/sog_corr/cog_alignment) 2차, role 가점
- spoofing 산식 교체: 1시간 윈도우 + teleport 절대 가점 + extreme 50kn 단독 0.6 확정
- transshipment 선종 완화: shipTy 부분일치 + 412* FISHING 간주
- gear_code DB write 경로 신설 + fleet_tracker API 3개 추가
- cron 스크립트: fishery_permit/pair_type/fleet_role 신규 섹션
2026-04-16 07:43:24 +09:00
Nan Kyung Lee
755f3919ba feat: 시스템관리 > 감사·보안에 성능 모니터링(PER-01~06) 메뉴 추가
- V028 Flyway 마이그레이션: admin:performance-monitoring 권한 트리 + RBAC
- PerformanceMonitoring.tsx: 5 탭 구조(성능 현황·응답성·처리용량·AI 모델·가용성/확장성)
- PER-01~06 전체 커버: 3,000명 규모·상황실 100명 24/7 SLO·S&P 글로벌 AIS 영향 최소화 8대 전략
- 6개 AI 모델 성능 지표(정확도·정밀도·재현율·F1·ROC-AUC) 표시
- 디자인 시스템 준수: PageContainer/PageHeader/Card/Badge intent 기반

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 17:12:53 +09:00
35cc889d23 Merge pull request 'release: 2026-04-15 (5건 커밋)' (#43) from develop into main
All checks were successful
Build and Deploy KCG AI Monitoring (Frontend) / build-and-deploy (push) Successful in 18s
2026-04-15 13:43:29 +09:00
68940e73b0 Merge pull request 'docs: 릴리즈 노트 정리 (2026-04-15)' (#42) from release/2026-04-15 into develop 2026-04-15 13:42:34 +09:00
1cc4c9dfd7 docs: 릴리즈 노트 정리 (2026-04-15) 2026-04-15 13:42:06 +09:00
ba6908a0d4 Merge pull request 'feat(detection): DAR-03 어구 탐지 워크플로우 + 모선 검토 UI + 24h 리플레이 통합' (#41) from feature/dar03-fishing-pattern-enhancement into develop 2026-04-15 13:30:01 +09:00
52ac478069 docs: 릴리즈 노트 [Unreleased] DAR-03 + 모선 검토 + 리플레이 항목 추가 2026-04-15 13:29:01 +09:00
2ee8a0e7ff feat(detection): DAR-03 어구 탐지 워크플로우 + 모선 검토 UI + 24h 리플레이 통합
- prediction: G-01/G-04/G-05/G-06 위반 분류 + 쌍끌이 공조 탐지 추가
- backend: 모선 확정/제외 API + signal-batch 항적 프록시 + ParentResolution 점수 근거 필드 확장
- frontend: 어구 탐지 그리드 다중필터/지도 flyTo, 후보 검토 패널(점수 근거+확정/제외), 24h convex hull 리플레이 + TripsLayer 애니메이션
- gitignore: 루트 .venv/ 추가
2026-04-15 13:26:15 +09:00
908b2cdafa Merge pull request 'release: 단속 계획 순찰 작전 + 감사·보안 DAR-10/DAR-11' (#40) from release/2026-04-14-enforcement-dar into main
All checks were successful
Build and Deploy KCG AI Monitoring (Frontend) / build-and-deploy (push) Successful in 15s
2026-04-14 16:55:56 +09:00
Nan Kyung Lee
256152f7fc feat: 시스템관리 > 감사·보안에 데이터 모델 검증(DAR-11) 메뉴 추가
- 5탭 구성: 검증 현황 / 논리 모델 검증 / 물리 모델 검증 / 중복·정합성 점검 / 검증 결과 이력
- 4단계 검증 절차 (계획 수립→논리 검증→물리 검증→결과 보고)
- 논리 모델 8항목 (완전성·정합성·정규화·표준), 물리 모델 10항목 (구조·타입·인덱스·제약·성능)
- 중복·정합성 점검 6항목 + 8개 주제영역 48테이블 매핑
- V027 마이그레이션: admin:data-model-verification 권한 트리 + ADMIN 역할 권한

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 16:55:05 +09:00
Nan Kyung Lee
77f39497e5 feat: 시스템관리 > 감사·보안에 데이터 보관·파기 정책(DAR-10) 메뉴 추가
- 5탭 구성: 보관 현황 / 유형별 보관기간 / 파기 절차 / 예외·연장 / 파기 감사 대장
- 6종 데이터 유형별 보관기간 기준표 (법적 근거 포함)
- 4단계 파기 승인 절차 워크플로우 (선별→신청→승인→기록)
- 보존 연장 예외 관리 (수사·소송·감사·재난 4가지 사유)
- 파기 감사 대장 (대상·일시·담당자·방식·용량 기록)
- V026 마이그레이션: admin:data-retention 권한 트리 + ADMIN 역할 권한

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 16:55:05 +09:00
Nan Kyung Lee
a7aaa7fc13 feat: 단속 계획에 단일 함정 순찰 작전·다함정 순찰 작전 탭 추가
- 단일 함정 순찰 작전: 4가지 작전 유형(정찰 순찰/긴급 출동/감시 초계/근접 차단), 가용 함정 현황, SOP 절차
- 다함정 순찰 작전: 4가지 작전 유형(포위 차단/광역 초계/합동 단속/호위), 역할 분담, 통신 프로토콜, SOP 절차

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 16:55:05 +09:00
Nan Kyung Lee
0d35807765 feat: AI 모델관리 어구 탐지 탭에 DAR-03 5종 어구 구조 비교 추가
- FAO ISSCFG 기준 5종 어구(저층트롤/쌍끌이/스토우넷/자망/통발) 특성 비교 표
- 어구별 구조 도식 5개 (이미지 + 사양 + G코드 연계)
- AIS 신호 특성 및 이상 판정 기준 비교 표
- 근거: FAO 분류 + Wang et al.(2022) 논문
- 이미지 5장 /public/dar03/ 배포
- 디자인 시스템 준수 (Card/Badge intent/시맨틱 토큰)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 16:54:20 +09:00
0bc8883bb8 Merge pull request 'release: 2026-04-14 (5건 커밋)' (#38) from develop into main
All checks were successful
Build and Deploy KCG AI Monitoring (Frontend) / build-and-deploy (push) Successful in 15s
2026-04-14 08:21:25 +09:00
359eebe200 Merge pull request 'docs: 릴리즈 노트 정리 (2026-04-14)' (#37) from release/2026-04-14 into develop 2026-04-14 08:20:48 +09:00
9076797699 docs: 릴리즈 노트 정리 (2026-04-14)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 08:20:30 +09:00
3d7896b4f2 Merge pull request 'feat(frontend): 탐지 결과 운영 워크플로우 UI 구축' (#36) from feature/detection-workflow-ui into develop 2026-04-14 08:15:52 +09:00
56af7690fb docs: 릴리즈 노트 [Unreleased] 탐지 워크플로우 UI 항목 추가
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 08:09:34 +09:00
d354c1ebc7 feat(frontend): 탐지 결과 운영 워크플로우 UI 구축
- DarkVesselDetection: 판정 상세 사이드 패널(점수 산출 내역 P1~P11,
  GAP 상세, 7일 이력 차트), 선박 위치 gap_start_lat/lon fallback,
  클릭 시 지도 하이라이트
- TransferDetection: 5단계 필터 기반 환적 운영 화면 재구성
  (KPI, 쌍 목록, 쌍 상세, 감시영역 지도, 탐지 조건 시각화)
- GearDetection: 모선 추론 상태(DIRECT_MATCH/AUTO_PROMOTED/REVIEW_REQUIRED),
  추정 모선 MMSI, 후보 수 3개 컬럼 추가
- EnforcementPlan: CRITICAL 이벤트를 카테고리별(다크베셀/환적/EEZ침범/고위험)
  아이콘+라벨로 "탐지 기반 단속 대상" 통합 표시
- darkVesselPatterns: prediction P1~P11 전 패턴 한국어 카탈로그 +
  buildScoreBreakdown() 점수 산출 유틸
- ScoreBreakdown: 가점/감점 분리 점수 내역 시각화 공통 컴포넌트

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-14 07:56:52 +09:00
4a32cfc72e Merge pull request 'release: 2026-04-13.2 (5건 커밋)' (#35) from develop into main
All checks were successful
Build and Deploy KCG AI Monitoring (Frontend) / build-and-deploy (push) Successful in 14s
2026-04-13 11:51:21 +09:00
767ec4a84c Merge pull request 'docs: 릴리즈 노트 정리 (2026-04-13.2)' (#34) from release/2026-04-13.2 into develop 2026-04-13 11:50:44 +09:00
6f68dce380 docs: 릴리즈 노트 정리 (2026-04-13.2)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 11:50:31 +09:00
ce01c2134e Merge pull request 'refactor(frontend): LGCNS 3개 페이지 디자인 시스템 공통 구조 전환' (#33) from feature/lgcns-design-system-align into develop 2026-04-13 11:49:11 +09:00
feb28dbb85 docs: 릴리즈 노트 [Unreleased] LGCNS 디자인 시스템 전환 항목 추가
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 11:45:42 +09:00
99d72e3622 refactor(frontend): LGCNS 3개 페이지 디자인 시스템 공통 구조 전환
- 커스텀 탭 → TabBar/TabButton 공통 컴포넌트 교체 (3개 파일)
- hex 색상 맵 → Tailwind 클래스 토큰 전환, style={{ }} 인라인 제거
- 인라인 Badge intent 삼항 → 카탈로그 함수 교체 (getAgentPermTypeIntent 등)
- 신규 카탈로그: mlopsJobStatuses (4종), aiSecurityStatuses (위협3+권한5+결과3)
- catalogRegistry에 4건 등록 → design-system.html 쇼케이스 자동 노출
- statusIntent.ts에 '허용', '위험', '관리자', '중지', '실행' 매핑 추가

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 11:41:49 +09:00
f304f778ca Merge pull request 'release: 2026-04-13 (21건 커밋)' (#32) from develop into main
All checks were successful
Build and Deploy KCG AI Monitoring (Frontend) / build-and-deploy (push) Successful in 17s
2026-04-13 11:15:46 +09:00
a88e3c5076 Merge pull request 'docs: 릴리즈 노트 정리 (2026-04-13)' (#31) from release/2026-04-13 into develop 2026-04-13 11:15:07 +09:00
2eddd01d17 docs: 릴리즈 노트 정리 (2026-04-13)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 11:14:44 +09:00
90c270ac53 Merge pull request 'feat: LGCNS MLOps + AI 보안(SER-10) + AI Agent 보안(SER-11) 메뉴 추가' (#30) from feature/lgcns-mlops-ai-security-menu into develop 2026-04-13 11:13:02 +09:00
df75e085a7 docs: 릴리즈 노트 [Unreleased] LGCNS MLOps + AI 보안 메뉴 항목 추가
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 11:12:19 +09:00
7e37b5b680 Merge remote-tracking branch 'origin/develop' into feature/lgcns-mlops-ai-security-menu 2026-04-13 11:10:53 +09:00
45371315ba feat: prediction 알고리즘 재설계 + 프론트 CRUD 권한 가드 보완 (#29) 2026-04-13 11:08:11 +09:00
Nan Kyung Lee
1244f07de6 feat: LGCNS MLOps + AI 보안(SER-10) + AI Agent 보안(SER-11) 메뉴 추가
- V025 마이그레이션: admin 그룹 하위 3개 메뉴 등록
  - LGCNS MLOps (AI 플랫폼, nav_sort=350)
  - AI 보안 (감사·보안, nav_sort=1800)
  - AI Agent 보안 (감사·보안, nav_sort=1900)
- 페이지 컴포넌트 3개 신규 생성
- componentRegistry, i18n(ko/en) 반영

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 10:51:05 +09:00
019598ff55 Merge pull request 'release: 2026-04-09.2 워크플로우 연결 + 메뉴 DB SSOT' (#28) from develop into main
All checks were successful
Build and Deploy KCG AI Monitoring (Frontend) / build-and-deploy (push) Successful in 14s
2026-04-09 16:05:19 +09:00
73b55c2bde Merge pull request 'docs: 릴리즈 노트 정리 (2026-04-09.2)' (#27) from release/2026-04-09.2 into develop 2026-04-09 16:04:31 +09:00
a08071edce docs: 릴리즈 노트 정리 (2026-04-09.2)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 16:04:10 +09:00
93429f012f Merge pull request 'feat: 워크플로우 연결 + 메뉴 DB SSOT 구조화' (#26) from feature/workflow-connection-step1 into develop 2026-04-09 16:03:25 +09:00
a498cfdbe7 docs: 릴리즈 노트 [Unreleased] 메뉴 DB SSOT 항목 추가
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 16:02:55 +09:00
6fe7a7daf4 feat: 메뉴 DB SSOT 구조화 — auth_perm_tree 기반 메뉴·권한·i18n 통합
## 핵심 변경
- auth_perm_tree를 메뉴 SSOT로 확장 (V020~V024)
  - url_path, label_key, component_key, nav_group, nav_sub_group, nav_sort 컬럼
  - labels JSONB (다국어: {"ko":"...", "en":"..."})
- 보이지 않는 도메인 그룹 8개 삭제 (surveillance, detection, risk-assessment 등)
  - 권한 트리 = 메뉴 트리 완전 동기화
  - 그룹 레벨 권한 → 개별 자식 권한으로 확장 후 그룹 삭제
- 패널 노드 parent_cd를 실제 소속 페이지로 수정
  (어구식별→어구탐지, 전역제외→후보제외, 역할관리→권한관리)
- vessel:vessel-detail 권한 노드 제거 (드릴다운 전용, 인증만 체크)

## 백엔드
- MenuConfigService: auth_perm_tree에서 menuConfig DTO 생성
- /api/auth/me 응답에 menuConfig 포함 (로그인 시 프리로드)
- @RequirePermission 12곳 수정 (삭제된 그룹명 → 구체적 자식 리소스)
- Caffeine 캐시 menuConfig 추가

## 프론트엔드
- NAV_ENTRIES 하드코딩 제거 → menuStore(Zustand) 동적 렌더링
- PATH_TO_RESOURCE 하드코딩 제거 → DB 기반 longest-match
- App.tsx 36개 정적 import/33개 Route → DynamicRoutes + componentRegistry
- PermissionsPanel: DB labels JSONB 기반 표시명 + 페이지/패널 아이콘 구분
- DB migration README.md 전면 재작성 (V001~V024, 49테이블, 149인덱스)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 15:54:04 +09:00
1147b96b00 docs: 워크플로우 연결 릴리즈 노트 [Unreleased] 갱신
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 12:17:17 +09:00
d08d614b5f fix(frontend): 메뉴 중복 해소 + system-flow 노드 동기화 + V019 권한 트리
- /map-control labelKey nav.riskMap → nav.mapControl (위험도 지도 중복 해소)
- i18n nav.mapControl 키 추가 (ko: 해역 관리, en: Map Control)
- V019 마이그레이션: ai-operations:llm-ops 권한 트리 항목 추가 (PR #22 누락분)
- system-flow 08-frontend.json: 누락 노드 14개 추가
  - ui.map_control, ui.risk_map, ui.patrol_route, ui.fleet_optimization
  - ui.report_management, ui.external_service
  - ui.ai_model, ui.mlops, ui.llm_ops
  - ui.mobile_service, ui.ship_agent
  - ui.admin_panel, ui.permissions

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 12:05:35 +09:00
e401c07dd3 feat(frontend): 워크플로우 연결 Step 5 — 자동갱신 + 모선추론 연결 + i18n
자동 갱신 (30초, 깜박임 없음):
- eventStore: silentRefresh() 메서드 추가 (loading 상태 미변경, 데이터만 교체)
- EventList: 30초 인터벌로 silentRefresh + loadStats 호출
- DarkVesselDetection: 30초 인터벌로 getDarkVessels silent 갱신

모선추론 자동 연결:
- ParentReview CONFIRM → createLabelSession 자동 호출 (학습 데이터 수집 시작)
- ParentReview REJECT → excludeForGroup 자동 호출 (잘못된 후보 재추론 방지)
- 자동 연결 실패 시 리뷰 자체는 유지 (catch 무시)

i18n (ko/en):
- darkTier: CRITICAL/HIGH/WATCH/NONE 라벨
- transshipTier: CRITICAL/HIGH/WATCH 라벨
- adminSubGroup: AI 플랫폼/시스템 운영/사용자 관리/감사·보안

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 11:43:18 +09:00
6887a2b4fc feat(frontend): 워크플로우 연결 Step 4 — Enforcement 연계 + admin 서브그룹
EnforcementHistory:
- eventId 역추적 컬럼 추가 (#{eventId} 클릭 → EventList 이동)
- Record 인터페이스에 eventId 필드 추가

EnforcementPlan:
- 미배정 CRITICAL 이벤트 패널 신설 (NEW 상태 CRITICAL 이벤트 표시)
- getEvents(level=CRITICAL, status=NEW) 연동

MainLayout:
- admin 메뉴 4개 서브그룹 분리 (AI 플랫폼/시스템 운영/사용자 관리/감사·보안)
- NavDivider 타입 도입으로 그룹 내 소제목 라벨 렌더링
- 기존 RBAC 필터링 + collapsed 모드 호환 유지

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 11:32:03 +09:00
1940caf73b feat(frontend): 워크플로우 연결 Step 3 — VesselDetail 강화 + DarkVessel prediction 전환
VesselDetail:
- iran proxy → prediction 직접 API 전환 (getAnalysisLatest/getAnalysisHistory)
- dark 패턴 시각화: dark_tier Badge, 의심점수 바, dark_patterns 태그, 7일 반복 횟수
- 환적 의심 분석 섹션 추가 (transship_tier, transship_score)
- 24h AIS 수신 이력 타임라인 그래프 (시간대별 수신/소실 막대)
- 단속 이력 탭 신설 (GET /api/enforcement/records?vesselMmsi)
- 지도 중심좌표를 분석 결과의 lat/lon으로 자동 설정
- 위험도 점수 표시 0~100 직접 사용 (iran proxy의 0~1 변환 제거)

DarkVesselDetection:
- iran proxy → getDarkVessels() 직접 API 전환
- derivePattern() 제거 → features.dark_tier/dark_suspicion_score/dark_patterns 직접 표시
- tier 기반 KPI 카드 (CRITICAL/HIGH/WATCH) + 클릭 필터
- 의심 점수 내림차순 정렬 (가장 의심스러운 순)
- tier별 필터 셀렉트 추가
- 지도 범례: tier 기반 색상

enforcement.ts: getEnforcementRecords에 vesselMmsi 파라미터 추가

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 11:02:46 +09:00
0679c04bfe feat(frontend): 워크플로우 연결 Step 2 — EventList 워크플로우 + MMSI 링크
- EventList 인라인 액션 버튼 4종 추가 (확인/선박상세/단속등록/오탐)
  - 확인(ACK): NEW 상태 이벤트만 활성, ackEvent API 연동
  - 선박 상세: /vessel/{mmsi} 네비게이션
  - 단속 등록: createEnforcementRecord API → 이벤트 RESOLVED 자동 전환
  - 오탐 처리: updateEventStatus(FALSE_POSITIVE) 연동
- MMSI → VesselDetail 링크 3개 화면 적용
  - EventList: MMSI 컬럼 클릭 → /vessel/{mmsi}
  - DarkVesselDetection: MMSI 컬럼 클릭 → /vessel/{mmsi}
  - EnforcementHistory: 대상 선박 컬럼 클릭 → /vessel/{mmsi}
- PredictionEvent 타입에 features 필드 추가 (dark_tier, transship_score 등)
- analysisApi.ts 서비스 신규 생성 (직접 조회 API 5개 연동)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 10:50:31 +09:00
5c804aa38f feat(backend): 워크플로우 연결 Step 1 — 백엔드 기반 확장
- V018 마이그레이션: prediction_events.features JSONB 컬럼 추가
- VesselAnalysis 직접 조회 API 5개 신설 (/api/analysis/*)
  - vessels 목록 (필터: mmsi, zone, riskLevel, isDark)
  - vessels/{mmsi} 최신 분석 (features 포함)
  - vessels/{mmsi}/history 분석 이력
  - dark 베셀 목록 (MMSI 중복 제거)
  - transship 의심 목록
- PredictionEvent entity에 features JSONB 필드 추가
- EnforcementController vesselMmsi 필터 파라미터 추가
- event_generator.py INSERT에 features 컬럼 추가

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 10:43:53 +09:00
13ea475bd7 Merge pull request 'chore: main → develop back-merge (2026-04-09 릴리즈 + PR #21/#22 동기화)' (#25) from main into develop 2026-04-09 09:59:43 +09:00
6c7a2a9a9d Merge pull request 'feat: SFR-20 LLM 운영 관리 페이지 신규 추가' (#22) from feat/llm-ops-page into main
All checks were successful
Build and Deploy KCG AI Monitoring (Frontend) / build-and-deploy (push) Successful in 15s
2026-04-09 09:59:18 +09:00
ab64c97200 Merge pull request 'fix: PredictionAlert 직렬화 500 에러 + 로컬 프록시 Origin 보정' (#21) from fix/alert-serialization-and-proxy-origin into main
Some checks failed
Build and Deploy KCG AI Monitoring (Frontend) / build-and-deploy (push) Has been cancelled
2026-04-09 09:59:10 +09:00
d66d1623a2 Merge pull request 'release: 2026-04-09 (50건 커밋)' (#24) from release/2026-04-09 into main 2026-04-09 09:58:20 +09:00
037ae363a1 docs: 릴리즈 노트 정리 (2026-04-09) 2026-04-09 09:57:52 +09:00
0b2dbb523a Merge pull request 'feat(prediction): output 정상화 + dark 점수화 + transship 재설계' (#23) from fix/prediction-output-anomalies into develop 2026-04-09 09:56:47 +09:00
b15a94066a docs: prediction 2차 개편 릴리즈 노트 + hourly snapshot 스크립트
- RELEASE-NOTES [Unreleased] 섹션에 dark 의심 점수화 + transship 재설계 변경사항 추가
- prediction/scripts/hourly-analysis-snapshot.sh: 시간별 상태 스냅샷 수집 (25개 섹션)
2026-04-09 09:55:46 +09:00
Nan Kyung Lee
072d10bacf feat(frontend): SFR-20 LLM 운영 관리 페이지 신규 추가
Qwen3-8B 기반 통합 LLM 운영 플랫폼 웹 콘솔 (10탭):
- 모델 관리: 레지스트리, Blue-Green 배포, 파인튜닝 이력
- 프롬프트 관리: 4종 템플릿, A/B 테스트, Git 버전관리
- 추론 서비스: vLLM 엔드포인트, 오토스케일링, KPI
- 로그/추적: ELK + OpenTelemetry, 마스킹 처리
- 평가/품질: RAGAS 자동 평가, 할루시네이션율, F1
- 데이터: 문서 등록(법령/지침/매뉴얼/단속·수사 사례), 지식 소스
- RAG 파이프라인: 6단계 + Airflow 재색인 + MRR/NDCG
- 품질개선: 불용어 관리, 욕설 필터링, 분류체계, 규칙 보완
- 보안: 3단계 파이프라인 + 망분리 + TLS 1.3
- 운영 모니터링: GPU 노드 + 서비스 상태 + Alertmanager

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 08:38:52 +09:00
dac4a3bda2 fix(prediction): features JSONB 중첩 구조 sanitize
AnalysisResult.to_db_tuple이 기존에 features dict 값을 모두 float로
변환했는데, dark_suspicion 구조를 넣으면서 dark_patterns(list) 등
비스칼라 타입이 포함되어 upsert 실패 (float argument not a list).

_sanitize 재귀 함수로 JSON 호환 타입(str/int/float/bool/list/dict/None)을
그대로 보존하도록 변경.
2026-04-09 07:56:04 +09:00
e5d123e4c5 feat(prediction): dark 의심 점수화 + transship 베테랑 관점 재설계
12h 누적 분석 결과 dark/transship이 운영 불가 수준으로 판정되어
탐지 철학을 근본부터 전환.

## dark 재설계: 넓은 탐지 + 의도적 OFF 의심 점수화

기존 "필터 제외" 방식에서 "넓게 기록 + 점수 산출 + 등급별 알람"으로 전환.
해경 베테랑 관점의 8가지 패턴을 가점 합산하여 0~100점 산출.

- P1 이동 중 OFF (gap 직전 SOG > 2kn)
- P2 민감 수역 경계 근처 OFF (영해/접속수역/특정조업수역)
- P3 반복 이력 (7일 내 재발) — 가장 강력
- P4 gap 후 이동거리 비정상 (은폐 이동)
- P5 주간 조업 시간 OFF
- P6 gap 직전 이상 행동 (teleport/급변)
- P7 무허가 선박 가점
- P8 장기 gap (3h/6h 구간별)
- 감점: gap 시작 위치가 한국 AIS 수신 커버리지 밖

완전 제외:
- 어구 AIS (GEAR_PATTERN 매칭, fleet_tracker SSOT)
- 한국 선박 (MMSI 440*, 441*) — 해경 관할 아님

등급: CRITICAL(70+) / HIGH(50~69) / WATCH(30~49) / NONE
이벤트는 HIGH 이상만 생성 (WATCH는 DB 저장만).

신규 함수:
- algorithms/dark_vessel.py: analyze_dark_pattern, compute_dark_suspicion
- scheduler.py: _is_dark_excluded, _fetch_dark_history (사이클당 1회 7일 이력 일괄 조회)

pipeline path + lightweight path 모두 동일 로직 적용.
결과는 features JSONB에 {dark_suspicion_score, dark_patterns,
dark_tier, dark_history_7d, dark_history_24h, gap_start_*} 저장.

## transship 재설계: 베테랑 함정근무자 기준

한정된 함정 자원으로 단속 출동을 결정할 수 있는 신뢰도 확보.

상수 재조정:
- SOG_THRESHOLD_KN: 2.0 → 1.0 (완전 정박만)
- PROXIMITY_DEG: 0.001 → 0.0007 (~77m)
- SUSPECT_DURATION_MIN: 60 → 45 (gap tolerance 있음)
- PAIR_EXPIRY_MIN: 120 → 180
- GAP_TOLERANCE_CYCLES: 2 신규 (GPS 노이즈 완화)

필수 조건 (모두 충족):
- 한국 EEZ 관할 수역 이내
- 환적 불가 선종 제외 (passenger/military/tanker/pilot/tug/sar)
- 어구 AIS 양쪽 제외
- 45분 이상 지속 (miss_count 2 사이클까지 용인)

점수 체계 (base 40):
- 야간(KST 20~04): +15
- 무허가 가점: +20
- COG 편차 > 45°: +20 (나란히 가는 선단 배제)
- 지속 ≥ 90분: +20
- 영해/접속수역 위치: +15

등급: CRITICAL(90+) / HIGH(70~89) / WATCH(50~69)
WATCH는 저장 없이 로그만. HIGH/CRITICAL만 이벤트.

pair_history 구조 확장:
- 기존: {(a,b): datetime}
- 신규: {(a,b): {'first_seen', 'last_seen', 'miss_count', 'last_lat/lon/cog_a/cog_b'}}
- miss_count > GAP_TOLERANCE_CYCLES면 삭제 (즉시 리셋 아님)

## event_generator 룰 교체

- dark_vessel_long 룰 제거 → dark_critical, dark_high (features.dark_tier 기반)
- transship 룰 제거 → transship_critical, transship_high (features.transship_tier 기반)
- DEDUP: ILLEGAL_TRANSSHIP 67→181, DARK_VESSEL 127→131, ZONE_DEPARTURE 127→89

## 공통 정리

- scheduler.py의 _gear_re 삭제, fleet_tracker.GEAR_PATTERN 단일 SSOT로 통합
2026-04-09 07:42:15 +09:00
Nan Kyung Lee
49cee6c5e0 fix(backend): PredictionAlert 직렬화 500 에러 수정 + 로컬 프록시 Origin 헤더 보정
- PredictionAlert.event Lazy 연관 필드에 @JsonIgnore 추가 (Jackson 직렬화 실패 방지)
- Vite 프록시에 Origin 헤더 재작성 추가 (로컬 개발 시 CSRF 403 방지)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 20:34:56 +09:00
2e5d55a27f fix(prediction): dark 판정에 한국 AIS 수신 영역 필터 추가
16:00 cron 1차 분석 결과, lightweight path가 6,500척 중 5,250척(80%)을
dark로 판정. 좌표 검증 결과 모두 30~37°N/122~125°E (동중국해/서해)로
한국 AIS 수신소 도달 한계 영역에 위치하여 정상 운항 중에도 20~24h 통째로
수신이 끊기는 자연 gap이 발생.

핫픽스: lightweight path에서 dark 판정 직후 마지막 위치가
북위 32~39.5, 동경 124~132 (한반도 + EEZ + 접속수역 여유 포함) 밖이면
dark를 False로 강제. 한국 측 관심 영역의 dark 탐지는 그대로 유지.

근본 개편(STATIONARY 정박 필터, 진입 후 단절 패턴, gap 임계값 재조정 등)은
12시간 추적 데이터 수집 후 내일 진행.
2026-04-08 16:11:02 +09:00
0a4d023c76 fix(prediction): output 5종 이상 정상화 (stats/event/lightweight)
5가지 출력 이상 동시 해결:

1. stats_aggregator (이상 1, 5)
   - aggregate_hourly에 by_category, by_zone JSON 집계 추가
   - hour_start를 KST 기준으로 변경 (대시보드 표기와 boundary 일치)

2. event_generator 룰 정리 (이상 2, 3, 4)
   - critical_risk 임계값 90→70 (risk.py CRITICAL 분류와 일치)
   - territorial_sea_violation, contiguous_zone_high_risk, special_zone_entry 신설
     (실측 zone_code: TERRITORIAL_SEA/CONTIGUOUS_ZONE/ZONE_*)
   - 잘못된 NLL/SPECIAL_FISHING_* 룰 제거
   - HIGH_RISK_VESSEL 신규 카테고리 (50~69 MEDIUM, 70+ CRITICAL)
   - break 제거: 한 분석결과가 여러 카테고리에 동시 매칭 가능

3. dedup window prime 분산 (이상 5)
   - 30/60/120/360분 → 33/67/127/367분
   - 5분 사이클 boundary와 LCM 회피하여 정시 일제 만료 패턴 완화

4. lightweight path 신호 보강 (이상 2, 3, 4 근본 해결)
   - vessel_store._tracks의 24h 누적 궤적으로 dark/spoof/speed_jump 산출
   - 6,500 vessels(전체 93%)의 is_dark, spoofing_score가 비로소 채워짐
   - compute_lightweight_risk_score에 dark gap, spoofing 가점 추가
     (max 60→100 가능, CRITICAL 도달 가능)

시간 처리 원칙 적용:
- DB 컬럼은 모두 timestamptz 확인 완료
- aggregate_hourly KST aware datetime 사용
- pandas Timestamp는 source-internal 비교만 (안전)
2026-04-08 15:18:18 +09:00
1897ff45d3 Merge pull request 'chore: main → develop back-merge (릴리즈 노트 동기화)' (#20) from main into develop 2026-04-08 13:44:03 +09:00
30ef2cd593 Merge pull request 'release: 2026-04-08 (35건 커밋)' (#19) from release/2026-04-08 into main
All checks were successful
Build and Deploy KCG AI Monitoring (Frontend) / build-and-deploy (push) Successful in 16s
2026-04-08 13:43:24 +09:00
5d99ed0b77 docs: 릴리즈 노트 정리 (2026-04-08) 2026-04-08 13:42:41 +09:00
c4a621e1d7 Merge pull request 'feat(frontend): 디자인 시스템 SSOT 확립 + 전체 페이지 마이그레이션 + a11y 전수 처리' (#18) from feature/design-system-showcase into develop 2026-04-08 13:40:00 +09:00
479a4bfc56 docs: 디자인 시스템 SSOT 개발 지침 + 릴리즈 노트 갱신
CLAUDE.md '디자인 시스템' 섹션 신규:
- 쇼케이스(/design-system.html)를 단일 진실 공급원으로 명시
- 공통 컴포넌트 목록 (Button/Input/Select/PageContainer/PageHeader/Badge 등)
- 카탈로그 API 사용 패턴 (getAlertLevelIntent/Label 등)
- CSS 작성 6대 규칙 (인라인 색상 금지, 하드코딩 Tailwind 색상 금지,
  className override 정책, 시맨틱 토큰 우선, !important 절대 금지,
  vendor prefix 수동 대응)
- 페이지 작성 표준 템플릿
- 접근성 (WCAG 2.1 Level A) 필수 사항
- 변경 사이클 (쇼케이스 → 카탈로그 → 컴포넌트 → 자동 반영)
- 금지 패턴 체크리스트

RELEASE-NOTES.md [Unreleased]에 디자인 시스템 SSOT 작업 항목 추가:
- 쇼케이스 페이지 + 신규 공통 컴포넌트 + 중앙 레지스트리
- 35+ feature 페이지 마이그레이션
- Badge intent 팔레트 테마 분리
- 접근성 전수 처리 (Select TypeScript 강제 등)
2026-04-08 13:29:28 +09:00
f4d56ea891 fix(frontend): 아이콘 전용 버튼 접근 이름 누락 7곳 보완
이전 스캐너가 놓친 패턴 — 모달 닫기 X 버튼과 토글 스위치 등:

- NoticeManagement: 모달 헤더 X → '닫기'
- ReportManagement: 업로드 패널 X → '업로드 패널 닫기'
- AIModelManagement: 규칙 토글 → role=switch + aria-checked + aria-label
                     API 예시 복사 → '예시 URL 복사'
- FileUpload: 파일 제거 X → '{파일명} 제거'
- NotificationBanner: 알림 닫기 X → '알림 닫기'
- SearchInput: 입력 aria-label (placeholder), 지우기 버튼 → '검색어 지우기'

검증:
- 개선된 스캐너로 remaining=0 확인 (JSX tag 중첩 파싱)
- tsc 
2026-04-08 13:16:20 +09:00
c51873ab85 fix(frontend): a11y/호환성 — backdrop-filter webkit prefix + Button/Input/Select 접근 이름
axe/forms/backdrop 에러 3종 모두 해결:

1) CSS: backdrop-filter Safari 호환성
   - design-system CSS에 -webkit-backdrop-filter 추가
   - trk-pulse 애니메이션을 outline-color → opacity로 변경
     (composite만 트리거, paint/layout 없음 → 더 나은 성능)

2) 아이콘 전용 <button> aria-label 추가 (9곳):
   - MainLayout 알림 버튼 → '알림'
   - UserRoleAssignDialog 닫기 → '닫기'
   - AIAssistant/MLOpsPage 전송 → '전송'
   - ChinaFishing 좌/우 네비 → '이전'/'다음'
   - 공통 컴포넌트 (PrintButton/ExcelExport/SaveButton) type=button 누락 보정

3) <input>/<textarea> 접근 이름 27곳 추가:
   - 로그인 폼, ParentReview/LabelSession/ParentExclusion 폼 (10)
   - NoticeManagement 제목/내용/시작일/종료일 (4)
   - SystemConfig/DataHub/PermissionsPanel 검색·역할 입력 (5)
   - VesselDetail 조회 시작/종료/MMSI (3)
   - GearIdentification InputField에 label prop 추가
   - AIAssistant/MLOpsPage 질의 input/textarea
   - MainLayout 페이지 내 검색
   - 공통 placeholder → aria-label 자동 복제 (3)

Button 컴포넌트에는 접근성 정책 JSDoc 명시 (타입 강제는 API 복잡도 대비
이득 낮아 문서 가이드 + 코드 리뷰로 대응).

검증:
- 실제 위반 지표: inaccessible button 0, inaccessible input 0, textarea 0
- tsc , eslint , vite build 
- dist CSS에 -webkit-backdrop-filter 확인됨
2026-04-08 13:04:23 +09:00
9dfa8f5422 fix(frontend): Select 접근성 — aria-label 필수 + 네이티브 <select> 보완
이슈: "Select element must have an accessible name" — 스크린 리더가 용도를
인지할 수 없어 WCAG 2.1 Level A 위반.

수정:
- Select 공통 컴포넌트 타입을 union으로 강제
  - aria-label | aria-labelledby | title 중 하나는 TypeScript 컴파일 타임에 필수
  - 누락 시 tsc 단계에서 즉시 실패 → 회귀 방지
- 네이티브 <select> 5곳 aria-label 추가:
  - admin/SystemConfig: 대분류 필터
  - detection/RealVesselAnalysis: 해역 필터
  - detection/RealGearGroups: 그룹 유형 필터
  - detection/ChinaFishing: 관심영역 선택
  - detection/GearIdentification: SelectField에 label prop 추가
- 쇼케이스 FormSection Select 샘플에 aria-label 추가

이제 모든 Select 사용처가 접근 이름을 가지며,
향후 신규 Select 사용 시 tsc가 누락을 차단함.
2026-04-08 12:50:51 +09:00
da4dc86e90 refactor(frontend): 인라인 버튼/하드코딩 색상 전수 제거
Phase C-2 (인라인 <button>):
- TabBar/TabButton 공통 컴포넌트 신규 (underline/pill/segmented 3종)
- DataHub: 메인 탭 → TabBar + TabButton 전환, 필터 pill 전환,
  CTA 버튼 (작업 등록/스토리지 관리/새로고침) → Button variant
- PermissionsPanel: 역할 생성/저장 → Button variant, icon 버튼 유지
- Python 일괄 치환: 51개 inline <button>에 type="button" 추가
- 남은 <button> type 누락 0건 (multi-line 포함)

Phase C-3 (하드코딩 색상):
- AdminPanel SERVER_STATUS 뱃지: getStatusIntent() 사용으로 통일
- bg-X-500/20 text-X-400 패턴 0건

Phase C-4 (인라인 style):
- LiveMapView BaseMap minHeight → className="min-h-[400px]"
- 나머지 89건 style={{}}은 모두 dynamic value
  (progress width, toggle left, 데이터 기반 color 등)로 정당함

4개 catalog (eventStatuses/enforcementResults/enforcementActions/
patrolStatuses)에 intent 필드 추가, statusIntent.ts 공통 유틸 신규.
이제 모든 Badge가 쇼케이스 팔레트 자동 적용됨.

빌드 검증:
- tsc , eslint , vite build 
- 남은 위반 지표: Badge className 0, button-type-missing 0, 하드코딩 색상 0
2026-04-08 12:36:07 +09:00
2483174081 refactor(frontend): Badge className 위반 37건 전수 제거
- 4개 catalog(eventStatuses/enforcementResults/enforcementActions/patrolStatuses)에
  intent 필드 추가 + getXxxIntent() 헬퍼 신규
- statusIntent.ts 공통 유틸: 한글/영문 상태 문자열 → BadgeIntent 매핑
  + getRiskIntent(0-100) 점수 기반 매핑
- 모든 Badge className="..." 패턴을 intent prop으로 치환:
  - admin (AuditLogs/AccessControl/SystemConfig/NoticeManagement/DataHub)
  - ai-operations (AIModelManagement/MLOpsPage)
  - enforcement (EventList/EnforcementHistory)
  - field-ops (AIAlert)
  - detection (GearIdentification)
  - patrol (PatrolRoute/FleetOptimization)
  - parent-inference (ParentExclusion)
  - statistics (ExternalService/ReportManagement)
  - surveillance (MapControl)
  - risk-assessment (EnforcementPlan)
  - monitoring (SystemStatusPanel — ServiceCard statusColor → statusIntent 리팩토)
  - dashboard (Dashboard PatrolStatusBadge)

이제 Badge의 테마별 팔레트(라이트 파스텔 + 다크 translucent)가 자동 적용되며,
쇼케이스에서 palette 조정 시 모든 Badge 사용처에 일관되게 반영됨.
2026-04-08 12:28:23 +09:00
85cb6b40a2 refactor(frontend): 복잡 페이지 PageContainer 적용 (Phase B-5)
- MonitoringDashboard: 표준 PageHeader
- MapControl: demo 배지
- RiskMap: 수집 중 배지 + secondary Button 2개 액션
- Dashboard: PageContainer 래핑 (커스텀 DEFCON 헤더는 유지)
- LiveMapView: PageContainer fullBleed + flex 레이아웃 유지
- VesselDetail: PageContainer fullBleed + -m-4 해킹 제거
- TransferDetection: PageHeader 적용

Phase B 전체 완료. 실제 프론트엔드의 모든 주요 페이지가 쇼케이스 기준
공통 컴포넌트(PageContainer/PageHeader/Button/Select/Badge)를 사용한다.
카탈로그/variant 변경 시 쇼케이스와 실 페이지 동시 반영됨.

최종 통계:
- 7개 batch에서 총 30+ 파일 마이그레이션
- PageContainer 도입률: ~100% (SPA 메인 라우트 기준)
- PageHeader 도입률: ~95%
- 신규 Button 컴포넌트 도입: admin/enforcement/parent-inference 등 주요 액션

빌드 검증:
- tsc , eslint  (경고만), vite build 
2026-04-08 12:09:17 +09:00
64e24cea71 refactor(frontend): statistics/ai-ops/parent-inference PageContainer 적용
statistics:
- Statistics: icon=BarChart3, secondary 보고서 생성 Button
- ReportManagement: destructive '새 보고서' + Button 그룹 + demo
- ExternalService: demo

ai-operations:
- AIAssistant: PageContainer + h-full flex 조합 (chat 레이아웃)
- AIModelManagement: 운영 모델 상태 뱃지는 actions 슬롯 유지
- MLOpsPage: demo

parent-inference:
- ParentReview/LabelSession/ParentExclusion: size=lg + Select + primary Button

Phase B-4 완료. 총 9개 파일.
2026-04-08 12:04:05 +09:00
2976796652 refactor(frontend): enforcement/field-ops/patrol PageContainer/PageHeader 적용
- EnforcementHistory/EventList/EnforcementPlan: primary Button 액션
- EventList: Select 공통 컴포넌트로 등급 필터 치환
- AIAlert/ShipAgent/MobileService: PageContainer + PageHeader(demo)
- PatrolRoute/FleetOptimization: primary Button 액션 2개씩

Phase B-3 완료. 총 10개 파일.
2026-04-08 11:57:01 +09:00
a1c521792d refactor(frontend): detection 계열 PageContainer/PageHeader 적용
- DarkVesselDetection: icon=EyeOff color=red
- GearDetection: icon=Anchor color=orange
- ChinaFishing: PageContainer size=sm (tab 기반 멀티뷰 루트)

GearIdentification/RealVesselAnalysis/RealGearGroups는 ChinaFishing의 서브
컴포넌트라 독립 마이그레이션 불필요.
2026-04-08 11:51:32 +09:00
4ee8f05dfd refactor(frontend): admin 계열 PageContainer/PageHeader 적용
- AdminPanel: PageContainer + PageHeader(demo)
- AuditLogs/AccessLogs/LoginHistoryView: size=lg + primary Button
- AccessControl: size=lg + 우측 stats 유지 + ghost 새로고침 Button
- DataHub: PageContainer + demo 배지 + secondary 새로고침
- NoticeManagement: primary '새 알림 등록' Button
- SystemConfig: secondary 2개 액션 Button

인라인 <button>/<div className="p-5 space-y-4"> 패턴을 쇼케이스 공통 컴포넌트로
치환. admin 계열 9개 파일 중 7개 완료 (PermissionsPanel은 서브 컴포넌트라 제외).
UserRoleAssignDialog는 dialog라 제외.
2026-04-08 11:48:41 +09:00
52749638ef refactor(frontend): 쇼케이스 SSOT 구조 — 카탈로그 레지스트리 + variant 메타
Phase A: 쇼케이스의 카탈로그/variant 정보를 중앙 상수로 끌어올림

- shared/constants/catalogRegistry.ts 신규
  - 19+ 카탈로그의 id/showcaseId/titleKo/titleEn/description/source/items를
    단일 레지스트리로 통합 관리
  - 새 카탈로그 추가 = 레지스트리에 1줄 추가로 쇼케이스 자동 노출
  - CATALOG_REGISTRY + getCatalogById()
- lib/theme/variantMeta.ts 신규
  - BADGE_INTENT_META: 8 intent의 titleKo/titleEn/description
  - BUTTON_VARIANT_META: 5 variant의 titleKo/titleEn/description
  - BADGE_INTENT_ORDER/SIZE_ORDER, BUTTON_VARIANT_ORDER/SIZE_ORDER
- 쇼케이스 섹션 리팩토링 — 하드코딩 제거
  - CatalogSection: CATALOG_REGISTRY 자동 열거 (CATALOGS 배열 삭제)
  - BadgeSection: BADGE_INTENT_META에서 의미 가이드 + titleKo 참조
  - ButtonSection: BUTTON_VARIANT_META에서 의미 가이드 + titleKo 참조

효과:
- 카탈로그의 라벨/색상/intent 변경 시 쇼케이스와 실 페이지 동시 반영
- Badge/Button의 variant 의미가 variantMeta 한 곳에서 관리됨
- 쇼케이스 섹션에 분산돼 있던 하드코딩 제거 (INTENT_USAGE, VARIANT_USAGE 등)

다음 단계: 실 페이지를 PageContainer/PageHeader/Button/Input으로 마이그레이션
2026-04-08 11:42:43 +09:00
4170824f15 feat(frontend): 쇼케이스 테마 전환 단축키 A 추가 2026-04-08 11:31:16 +09:00
d7f8db88ee fix(frontend): Badge 다크 팔레트를 translucent로 통일 + 쇼케이스 한/영 병기
- badgeVariants 다크 팔레트를 classes 기반 4종 카탈로그와 동일 패턴으로 통일
  - 이전: dark:bg-X-400 dark:text-slate-900 dark:border-X-600 (솔리드)
  - 이후: dark:bg-X-500/20 dark:text-X-400 dark:border-X-500/30 (translucent)
  - 라이트 팔레트는 그대로 유지 (이미 통일되어 있음)
- CatalogSection: 각 카탈로그 항목을 [code / 한글 배지 / 영문 배지] 3열 행으로 렌더
  - 한글/영문 라벨 두 버전을 한눈에 비교 검토 가능
  - 추적 ID Trk는 행 전체를 감싸서 호버/복사 동작
2026-04-08 11:28:02 +09:00
f589cb0f94 fix(frontend): 카탈로그 배지 테마 분리 + 단속 조치 가독성 수정
변경:
- badgeVariants 8 intent 모두 라이트/다크 팔레트 분리
  - 다크: 밝은 솔리드 배경(-400) + slate-900 글자 + 강한 보더(-600)
  - 라이트: 파스텔 배경(-100) + 진한 글자(-900) + 소프트 보더(-300)
  - base에서 text-on-bright 제거 (intent별로 관리)
- classes 기반 카탈로그 4종에 dark: 변형 추가로 라이트 모드 대응:
  - eventStatuses: bg-red-100 text-red-800 dark:bg-red-500/20 dark:text-red-400
  - enforcementResults: 동일 패턴 (red/purple/yellow/green)
  - patrolStatuses: border 포함 (7 상태)
  - enforcementActions: classes 필드 신규 추가 (기존에 없어서 fallback grey로 떨어져
    라이트 모드에서 글자 안 보이던 원인)
- CatalogSection fallback classes도 dark: 변형 추가 (안전장치)
- enforcementActions에 getEnforcementActionClasses() 헬퍼 신규

빌드 검증:
- tsc , vite build 
- CSS 확인: .dark\:bg-red-400:is(.dark *) 컴파일 정상
2026-04-08 11:23:38 +09:00
e0b51efc54 feat(frontend): 디자인 시스템 쇼케이스 페이지 + 신규 공통 컴포넌트
쇼케이스 (/design-system.html):
- 별도 Vite entry (System Flow 패턴 재사용, 메인 SPA 분리)
- 10개 섹션: Intro / Token / Typography / Badge / Button / Form /
  Card / Layout / Catalog (19+) / Guide
- 추적 ID 체계 (TRK-CATEGORY-SLUG):
  - hover 시 툴팁 + "ID 복사 모드"에서 클릭 시 클립보드 복사
  - URL hash 딥링크 (#trk=TRK-BADGE-critical-sm) 스크롤+하이라이트
  - 산출문서/논의에서 특정 변형 정확히 참조 가능
- Dark/Light 테마 토글로 양쪽 시각 검증

신규 공통 컴포넌트:
- Button (@shared/components/ui/button.tsx)
  - 5 variant × 3 size = 15 변형
  - primary/secondary/ghost/outline/destructive × sm/md/lg
- Input / Select / Textarea / Checkbox / Radio
  - Input · Select 공통 inputVariants 공유 (sm/md/lg × default/error/success)
- PageContainer / PageHeader / Section (shared/components/layout/)
  - PageContainer: size sm/md/lg + fullBleed (지도/풀화면 예외)
  - PageHeader: title + description + icon + demo 배지 + actions 슬롯
  - Section: Card + CardHeader + CardTitle + CardContent 단축

variants.ts 확장:
- buttonVariants / inputVariants / pageContainerVariants CVA 정의
- Button/Input/Select는 variants.ts에서 import하여 fast-refresh 경고 회피

빌드 검증 완료:
- TypeScript 타입 체크 통과
- ESLint 통과 (경고 0)
- vite build: designSystem-*.js 54KB (메인 SPA와 분리)

이 쇼케이스가 확정된 후 실제 40+ 페이지 마이그레이션 진행 예정.
2026-04-08 11:09:36 +09:00
a07b7d9ba5 docs: 릴리즈 노트 갱신 (UI 카탈로그 시스템) 2026-04-08 10:55:09 +09:00
a07c745cbc feat(frontend): 40+ 페이지 Badge/시맨틱 토큰 마이그레이션
- 모든 feature 페이지의 Badge className 패턴을 intent/size prop으로 변환
- 컬러풀 액션 버튼 (bg-*-500/600/700 + text-heading) -> text-on-vivid
- 검색/필터 버튼 배경 bg-blue-400 + text-on-bright (밝은 배경 위 검정)
- ROLE_COLORS 4곳 중복 제거 (MainLayout/UserRoleAssignDialog/
  PermissionsPanel/AccessControl) -> getRoleBadgeStyle 공통 호출
- PermissionsPanel 역할 생성/수정에 ColorPicker 통합
- MainLayout: PagePagination + scroll page state 제거 (데이터 페이지네이션 혼동)
- Dashboard RiskBar 단위 버그 수정 (0~100 정수 처리)
- ReportManagement, TransferDetection p-5 space-y-4 padding 복구
- EnforcementHistory 그리드 minmax 적용으로 컬럼 잘림 해소
- timeline 시간 formatDateTime 적용 (ISO T 구분자 처리)
- 각 feature 페이지가 공통 카탈로그 API (getXxxIntent/Label/Classes) 사용
2026-04-08 10:53:58 +09:00
5812d9dea3 feat(frontend): UI 공통 인프라 + 19개 분류 카탈로그 구축
- cn() 유틸 신규 (clsx + tailwind-merge, 시맨틱 토큰 classGroup 등록)
- theme.css @layer utilities로 직접 정의 (Tailwind v4 복합 이름 매핑 실패 대응):
  text-heading/label/hint/on-vivid/on-bright, bg-surface-raised/overlay
- badgeVariants (CVA) 재구축: 8 intent x 4 size, rem 기반, !important 제거
- Badge 컴포넌트가 cn(badgeVariants, className)로 override 허용
- DataTable width 의미 변경: 고정 -> 선호 최소 너비 (minWidth), truncate + title 툴팁
- dateFormat.ts sv-SE 로케일로 YYYY-MM-DD HH:mm:ss 일관된 KST 출력
- ColorPicker 신규 (팔레트 + native color + hex 입력)
- shared/constants/ 19개 카탈로그: violation/alert/event/enforcement/patrol/
  engine/userRole/device/parentResolution/modelDeployment/gearGroup/darkVessel/
  httpStatus/userAccount/loginResult/permission/vesselAnalysis/connection/trainingZone
  + kpiUiMap. 백엔드 enum/code_master 기반 SSOT
- i18n ko/en common.json에 카테고리 섹션 추가
- adminApi.fetchRoles()가 updateRoleColorCache 자동 호출
- 공통 컴포넌트 (ExcelExport/NotificationBanner/Pagination/SaveButton) 시맨틱 토큰 적용
2026-04-08 10:53:40 +09:00
20d6743c17 feat(backend): Role.colorHex 추가 + V017 migration
- auth_role.color_hex VARCHAR(7) 컬럼 추가 (Flyway V017)
- 빌트인 5개 역할 기본 색상 시드 (ADMIN/OPERATOR/ANALYST/FIELD/VIEWER)
- Role 엔티티 + RoleCreate/UpdateRequest DTO + RoleManagementService
- PermTreeController 응답에 colorHex 필드 포함
2026-04-08 10:52:36 +09:00
7ed07093db Merge pull request 'docs: 프로젝트 문서 최신화 (2026-04-08)' (#16) from docs/project-status-2026-04-08 into develop 2026-04-08 07:11:07 +09:00
7f35103c60 docs: 프로젝트 문서 최신화 (2026-04-08)
## 메모리 갱신 (Claude 내부)
- project-snapshot.md: 48테이블, V001~V016, prediction e2e 정상, System Flow 뷰어, 데모계정 5종
- project-history.md: 2026-04-07~08 릴리즈 이력 요약 (MR #3~#15)
- next-task: 1순위를 UI/표기 다듬기로 전환
- api-types: /api/stats/hourly + V014~V016 보조 테이블 추가
- debugging: 최근 해결된 11개 이슈 패턴 정리
- 구버전 참고 파일 정리 (data-analysis, refactoring-decisions)

## 리포지토리 문서
- docs/RELEASE-NOTES.md: Unreleased 섹션에 prediction e2e 수정, System Flow 포커스 모드,
  hourly API, V014~V016, mock 정리, KST 통일, DemoQuickLogin hostname 등 추가
- CLAUDE.md: database/ 설명 V001~V016, 48 테이블로 갱신

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 07:10:22 +09:00
8ccae2fee5 Merge pull request 'release: DemoQuickLogin hostname 대응' (#15) from develop into main
All checks were successful
Build and Deploy KCG AI Monitoring (Frontend) / build-and-deploy (push) Successful in 16s
2026-04-08 06:59:04 +09:00
930fbfc841 Merge pull request 'fix: DemoQuickLogin hostname 기반 노출' (#14) from feature/demo-login-hostname into develop 2026-04-08 06:59:02 +09:00
666d6e88c8 fix: DemoQuickLogin 노출 조건에 hostname 기반 판정 추가
Gitea CI 빌드에 .env 파일이 포함되지 않아 VITE_SHOW_DEMO_LOGIN이
주입되지 않는 문제 대응. 허용된 호스트에서는 환경변수 없이도 데모 퀵로그인 표시.

허용 호스트:
- localhost / 127.0.0.1 (로컬 개발)
- kcg-ai-monitoring.gc-si.dev (현재 데모 운영)

실운영 호스트로 전환 시 DEMO_ALLOWED_HOSTS에서 제거 필요.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 06:58:39 +09:00
24f3473daa Merge pull request 'release: prediction e2e 수정' (#13) from develop into main 2026-04-08 06:48:20 +09:00
1ff8a6ac7f Merge pull request 'fix: prediction e2e — Decimal/violation/KPI/stats/parent workflow 5건 수정' (#12) from feature/prediction-e2e-fixes into develop 2026-04-08 06:48:19 +09:00
da37a00b8e fix: prediction 5가지 이슈 수정 — 모든 파이프라인 정상 동작
## 이슈 1: gear_correlation Decimal → float TypeError
- prediction/algorithms/gear_correlation.py:785
- _load_all_scores()가 NUMERIC 컬럼을 Decimal로 읽어 float 상수와 연산 시 실패
- float() 명시 변환으로 수정
- 효과: gear correlation 24,474 raw metrics + 3,966 scores 정상 기록

## 이슈 2: violation_classifier classified=0 문제
- prediction/output/violation_classifier.py
- result.get('id')는 AnalysisResult에 없어 항상 None → 모든 UPDATE 건너뜀
- 존재하지 않는 permit_status/gear_judgment 필드에 의존
- (mmsi, analyzed_at) 기준 UPDATE로 변경
- 중국 선박(412/413*) + EEZ 진입은 permit 없어도 EEZ_VIOLATION 판정
- 효과: classified=0 → classified=4~6/cycle

## 이슈 3: kpi_writer 모두 0 (tracking_active 외)
- prediction/output/kpi_writer.py:27
- date.today() + timezone.utc 혼용 → 현재 시각이 UTC로는 아직 '어제'라 '오늘 >= today_start' 쿼리가 0 반환
- KST 기준으로 today_start 계산
- 효과: realtime_detection 0 → 7,107, illegal_transship 0 → 5,033

## 이슈 4: stats_daily 오늘 0건
- prediction/output/stats_aggregator.py:96, 194
- aggregate_daily/monthly가 UTC 경계 사용
- KST 기준 자정으로 수정
- 효과: 2026-04-08 detections 0 → 543,656, events 0 → 5,253

## 이슈 5: parent workflow 테이블 누락 컬럼 (V005 ↔ prediction 불일치)
V016 마이그레이션으로 일괄 추가:
- gear_parent_label_sessions: label_parent_name, normalized_parent_name,
  duration_days, actor, comment, metadata, updated_at 등 8개 컬럼
- gear_group_parent_resolution: parent_name, normalized_parent_name,
  selected_parent_name, confidence, decision_source, top_score, second_score,
  score_margin, stable_cycles, evidence_summary, episode_id, continuity_*,
  prior_bonus_total, last_evaluated_at, last_promoted_at 등 17개 컬럼
- gear_parent_candidate_exclusions: normalized_parent_name, reason_type,
  duration_days, metadata, updated_at, active_from, active_until +
  candidate_mmsi GENERATED ALWAYS AS (excluded_mmsi) 별칭
- gear_group_parent_candidate_snapshots: parent_name

효과: gear parent inference: 925 groups, 301 direct-match, 1329 candidates,
      188 review-required, 925 episode-snapshots 기록 — 전체 모선 워크플로우 정상

## 검증 결과 (e2e)

- analysis cycle: 6,824 vessels, 112초/cycle 정상
- vessel_analysis_results: 10분 13,650건, 총 125만건
- prediction_events: 1시간 138건, 총 12,258건
- prediction_alerts: 1시간 183건
- gear_correlation_scores: 3,966건
- gear_group_parent_resolution: 926건
- stats_hourly: 17행, stats_daily: 오늘 543,656건
- 백엔드 Flyway V016 정상 적용

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 06:47:53 +09:00
4f1572cd4e Merge pull request 'release: System Flow 포커스 모드' (#11) from develop into main
All checks were successful
Build and Deploy KCG AI Monitoring (Frontend) / build-and-deploy (push) Successful in 16s
2026-04-08 05:48:30 +09:00
99764e7c99 Merge pull request 'feat: System Flow 뷰어 포커스 모드 + 엣지 겹침 완화' (#10) from feature/system-flow-focus into develop 2026-04-08 05:48:29 +09:00
17215be29c feat: System Flow 뷰어 — 포커스 모드 + 엣지 겹침 완화
## 포커스 모드
- 노드 선택 시 해당 노드 + 1-hop 연결된 노드만 활성
- 나머지 노드는 opacity 0.18 + grayscale, 엣지는 0.08로 dim 처리
- 상단 중앙 배지로 포커스 상태 표시 + "전체 보기 ✕" 해제 버튼
- 캔버스 빈 공간(pane) 클릭 시 포커스 해제
- 선택 노드는 스케일 1.02 + glow 효과로 강조

## 엣지 겹침 완화
- COL_WIDTH 360 → 440, ROW_HEIGHT 130 → 170 (간격 확대)
- smoothstep pathOptions.offset을 source별로 20→34→48... 분산
- 같은 노드에서 나가는 N번째 엣지는 서로 다른 경로로 라우팅
- 선택/포커스된 엣지는 zIndex 1000으로 최상단 표시

## 시각 보강
- 포커스된 엣지는 stroke #f8fafc + strokeWidth 2.8 + animated
- 비활성 엣지 라벨은 opacity 0.3, 텍스트 #475569로 어둡게
- 선택 노드 body: box-shadow + glow + scale 1.02

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 05:47:51 +09:00
1a6604d3b9 Merge pull request 'release: System Flow 뷰어 추가' (#9) from develop into main
All checks were successful
Build and Deploy KCG AI Monitoring (Frontend) / build-and-deploy (push) Successful in 16s
2026-04-07 17:11:46 +09:00
d55b177dec Merge pull request 'feat: System Flow 뷰어 — 시스템 전체 데이터 흐름 시각화 (102 노드)' (#8) from feature/system-flow-viewer into develop 2026-04-07 17:11:13 +09:00
5d0bca73e1 docs: 릴리즈 노트에 System Flow 뷰어 항목 추가
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 17:10:46 +09:00
a6f6003c5f feat: System Flow 뷰어 추가 (system-flow.html) — 102 노드, 133 엣지
iran 프로젝트의 gear-parent-flow 패턴을 차용하여 시스템 전체 데이터 흐름을
노드/엣지로 시각화하는 별도 React 앱 추가. 메인 SPA와 완전 분리.

## 인프라
- @xyflow/react 추가
- frontend/system-flow.html (별도 entry HTML)
- frontend/src/systemFlowMain.tsx (React entry)
- vite.config.ts: rollupOptions.input에 systemFlow 추가
- 빌드 산출물: dist/system-flow.html + dist/assets/systemFlow-*.js (231kB, 메인과 분리)

## 매니페스트 (frontend/src/flow/manifest/)
카테고리별 JSON 분할 + 빌드 시 병합:
- 01-ingest.json (6) — snpdb, vessel_store, refresh
- 02-pipeline.json (7) — 7단계 분류 파이프라인
- 03-algorithms.json (12) — zone/dark/spoofing/risk/transship 등
- 04-fleet.json (9) — fleet_tracker, polygon_builder, gear_correlation, parent_inference
- 05-output.json (8) — event/violation/kpi/stats/alert/redis
- 06-storage.json (18) — 핵심 DB 테이블
- 07-backend.json (15) — Spring Boot 컨트롤러 + endpoint
- 08-frontend.json (17) — 프론트 화면 (menu 매핑 포함)
- 09-decision.json (8) — 운영자 의사결정 액션
- 10-external.json (2) — iran, redis
- edges.json (133) — data/trigger/feedback 분류

## 뷰어 컴포넌트
- SystemFlowViewer.tsx — 3단 레이아웃 + React Flow + 상태 관리
- components/FilterBar.tsx — 검색/단계/메뉴/상세필터 + 레이아웃 토글
- components/NodeListSidebar.tsx — 좌측 카테고리별 노드 리스트
- components/NodeDetailPanel.tsx — 우측 선택 정보 + incoming/outgoing 흐름
- components/nodeShapes.ts — kind별 모양/색상 헬퍼
- SystemFlowViewer.css — 전용 다크 테마 스타일

## 기능
- stage(단계) ⇄ menu(메뉴) 두 가지 그룹화 토글
- 통합 검색 (label/file/symbol/tag)
- 다중 필터 (kind/trigger/status)
- 노드 모양: kind별 (algorithm=다이아몬드, decision=마름모, api=6각형 등)
- 엣지 색상: data=회색, trigger=녹색, feedback=노란 점선
- 딥링크: /system-flow.html#node=<id> (산출문서에서 직접 참조)

## /version 스킬 통합
- CLAUDE.md에 "/version 스킬 사후 처리" 섹션 추가
  Claude가 /version 호출 후 자동으로 manifest.meta version/updatedAt/releaseDate 갱신
- .gitea/workflows/deploy.yml에 archive 보존 단계 추가
  /deploy/kcg-ai-monitoring-archive/system-flow/v{version}_{date}/ 영구 누적
  (nginx 노출 X, 서버 로컬 보존)
- docs/system-flow-guide.md 작성 (URL, 노드 ID 명명, 산출문서 참조법, 갱신 절차)

## URL
- 운영: https://kcg-ai-monitoring.gc-si.dev/system-flow.html
- 메인 SPA에 링크 노출 없음 (개발 단계 페이지)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 17:10:22 +09:00
1c69e2cdb8 Merge pull request 'release: 2026-04-07.2 (prediction e2e + 프론트 mock 정리)' (#7) from develop into main
All checks were successful
Build and Deploy KCG AI Monitoring (Frontend) / build-and-deploy (push) Successful in 16s
2026-04-07 15:37:25 +09:00
8dd86b692e Merge pull request 'fix: prediction e2e + 프론트 mock 전수 정리 + KST 통일' (#6) from feature/prediction-e2e-backend-fix into develop 2026-04-07 15:37:07 +09:00
19b1613157 feat: 프론트 전수 mock 정리 + UTC→KST 통일 + i18n 수정 + stats hourly API
## 시간 표시 KST 통일
- shared/utils/dateFormat.ts 공통 유틸 신규 (formatDateTime/formatDate/formatTime/toDateParam)
- 14개 파일에서 인라인 toLocaleString → 공통 유틸 교체

## i18n 'group.parentInference' 사이드바 미번역 수정
- ko/en common.json의 'group' 키 중복 정의를 병합
  (95행 두번째 group 객체가 35행을 덮어써서 parentInference 누락)

## Dashboard/MonitoringDashboard/Statistics 더미→실 API
- 백엔드 GET /api/stats/hourly 신규 (PredictionStatsHourly 엔티티/리포지토리)
- Dashboard: HOURLY_DETECTION/VESSEL_TYPE/AREA_RISK 하드코딩 제거 →
  getHourlyStats(24) + getDailyStats(today) 결과로 useMemo 변환
- MonitoringDashboard: TREND Math.random() 제거 → getHourlyStats 기반
  위험도 가중평균 + 경보 카운트
- Statistics: KPI_DATA 하드코딩 제거 → getKpiMetrics() 결과를 표 행으로

## Store mock 의존성 제거
- eventStore.alerts/MOCK_ALERTS 제거 (MobileService는 events에서 직접 추출)
- enforcementStore.plans 제거 (EnforcementPlan은 이미 직접 API 호출)
- transferStore + MOCK_TRANSFERS 완전 제거
  (ChinaFishing/TransferDetection은 RealTransshipSuspects 컴포넌트 사용)
- mock/events.ts, mock/enforcement.ts, mock/transfers.ts 파일 삭제

## RiskMap 랜덤 격자 제거
- generateGrid() Math.random() 제거 → 빈 배열 + 'AI 분석 데이터 수집 중' 안내
- MTIS 외부 통계 5개 탭에 [MTIS 외부 통계] 배지 추가

## 12개 mock 화면에 '데모 데이터' 노란색 배지 추가
- patrol/PatrolRoute, FleetOptimization
- admin/AdminPanel, DataHub, NoticeManagement, SystemConfig
- ai-operations/AIModelManagement, MLOpsPage
- field-ops/ShipAgent
- statistics/ReportManagement, ExternalService
- surveillance/MapControl

## 백엔드 NUMERIC precision 동기화
- PredictionKpi.deltaPct: 5,2 → 12,2
- PredictionStatsDaily/Monthly.aiAccuracyPct: 5,2 → 12,2
- (V015 마이그레이션과 동기화)

44 files changed, +346 / -787

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 15:36:38 +09:00
e12d1c33e2 fix: prediction e2e — 누락 테이블 12개 + 컬럼 매핑 + NUMERIC precision 통합 수정
- V014: fleet_vessels, fleet_tracking_snapshot, gear_identity_log,
  gear_correlation_scores/raw_metrics, correlation_param_models,
  group_polygon_snapshots, gear_group_episodes/episode_snapshots,
  gear_group_parent_candidate_snapshots, gear_parent_label_tracking_cycles,
  system_config 테이블 추가
- V015: 점수/비율 NUMERIC precision 일괄 확대 (score→7,4 / pct→12,2) +
  vessel_analysis_results UNIQUE(mmsi, analyzed_at) 인덱스 추가
- prediction kcgdb.py: timestamp→analyzed_at, zone→zone_code,
  is_leader→fleet_is_leader, is_transship_suspect→transship_suspect 매핑

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 15:00:29 +09:00
7f11a400ca Merge pull request 'release: 2026-04-07 (26건 커밋)' (#5) from develop into main
All checks were successful
Build and Deploy KCG AI Monitoring (Frontend) / build-and-deploy (push) Successful in 16s
2026-04-07 13:59:55 +09:00
4db7874082 Merge pull request 'docs: 릴리즈 노트 정리 (2026-04-07)' (#4) from release/2026-04-07 into develop 2026-04-07 13:58:53 +09:00
49140cc8fc docs: 릴리즈 노트 정리 (2026-04-07)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 13:58:28 +09:00
a01cd926a7 Merge pull request 'feat: 모노레포 전환 + 백엔드 RBAC + prediction 이식 + 15화면 실데이터 연동' (#3) from feature/monorepo-backend-rbac into develop 2026-04-07 13:57:27 +09:00
69ff79d90d docs: 릴리즈 노트 업데이트
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 13:56:43 +09:00
19c69c72c3 chore: .env.development.example → .env.example (서버 hook .env.* 차단 대응)
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 13:54:22 +09:00
a7f3490091 chore: .env.development → .example + pre-commit 모노레포 대응
- .env.development을 git에서 제거, .example로 대체 (서버 정책 준수)
- pre-commit hook을 frontend/ 기준으로 수정 (모노레포 구조)
- custom_pre_commit 플래그 활성화

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 13:52:53 +09:00
3ced9ffaaa docs: 배포 문서 보강 + CI/CD 모노레포 수정 + CLAUDE.md 배포 섹션
deploy/README.md:
- 접속 정보 섹션 추가 (URL, 데모 계정, DB)
- CI/CD 섹션: 프론트 자동(Gitea Actions), 백엔드/prediction 수동
- 서버별 실행 경로 정리 (rocky-211, redis-211)

.gitea/workflows/deploy.yml:
- 모노레포 구조 반영 (working-directory: frontend)
- paths 필터: frontend/** 변경 시만 트리거

CLAUDE.md:
- 모노레포 구조에 prediction/, deploy/, .gitea/ 추가
- 배포 환경 섹션 추가 (서버/포트/관리 방법)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 13:43:19 +09:00
2cb8414676 feat: 배포 환경 구성 + 로컬 프록시 서버 전환
rocky-211 백엔드 배포:
- /devdata/services/kcg-ai-monitoring/backend/ (JAR + application-prod.yml)
- systemd kcg-ai-backend.service (포트 18080)

redis-211 prediction 배포:
- /home/apps/kcg-ai-prediction/ (포트 18092)
- systemd kcg-ai-prediction.service

nginx 프록시 (rocky-211):
- /api/ → localhost:18080 (Spring Boot)
- /api/prediction/ → 192.168.1.18:18092 (prediction)
- /api/prediction-chat → SSE 프록시

로컬 개발:
- vite 프록시 기본값을 서버(kcg-ai-monitoring.gc-si.dev)로 변경
- 로컬 백엔드 사용 시: VITE_API_PROXY=http://localhost:8080

deploy/README.md: 배포 가이드 문서화

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 13:38:45 +09:00
6ac9184016 feat: VesselDetail + LiveMapView 실데이터 전환
VesselDetail: 인라인 mock → fetchVesselAnalysis() + vessel-permits API
  - MMSI 기반 선박 분석 데이터 + 허가 정보 + 관련 이벤트 이력
  - 알고리즘 결과 전체 표시 (risk/dark/spoofing/transship/fleet)

LiveMapView: vesselStore mock → fetchVesselAnalysis() + getEvents()
  - 위험도 TOP 100 선박 마커 (riskLevel별 색상)
  - 활성 이벤트 오버레이

EventController에 vesselMmsi 필터 파라미터 추가

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 13:29:43 +09:00
cc1b1e20df feat: S4 alerts API + AIAlert/Dashboard 위험선박 실데이터 전환
백엔드:
- PredictionAlert 엔티티 + Repository
- AlertController: GET /api/alerts (페이징 + eventId 필터)

프론트:
- AIAlert: mock alerts → GET /api/alerts 실제 호출
- Dashboard 위험선박: vesselStore mock → fetchVesselAnalysis() API
  - riskScore TOP 8 선박, 다크/GPS변조/전재 배지 표시
- Dashboard 이벤트 타임라인: eventStore API 기반 동작 확인

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 13:09:08 +09:00
474e672683 feat: S3 prediction 신규 출력 모듈 5종 + scheduler 통합
분석 사이클 완료 후 자동 실행되는 출력 파이프라인:
- event_generator: 분석결과 → 이벤트 자동 생성 (7개 룰, 카테고리별 dedup)
- violation_classifier: 위반 유형 라벨링 (EEZ/DARK/MMSI/TRANSSHIP/GEAR/RISK)
- kpi_writer: 실시간 KPI 6개 갱신 (오늘 기준 카운트)
- stats_aggregator: hourly/daily/monthly 사전 집계 (UPSERT)
- alert_dispatcher: CRITICAL/HIGH 이벤트 자동 알림 생성

scheduler.py에 출력 모듈 통합 (분석 8단계 완료 후 실행, non-fatal)
DB 연동 테스트 통과 (alerts 8건 생성, KPI tracking_active=2)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 13:00:50 +09:00
e2fc355b2c feat: S2 prediction 분석 엔진 모노레포 이식
iran prediction 47개 Python 파일을 prediction/ 디렉토리로 복제:
- algorithms/ 14개 분석 알고리즘 (어구추론, 다크베셀, 스푸핑, 환적, 위험도 등)
- pipeline/ 7단계 분류 파이프라인
- cache/vessel_store (24h 슬라이딩 윈도우)
- db/ 어댑터 (snpdb 원본조회, kcgdb 결과저장)
- chat/ AI 채팅 (Ollama, 후순위)
- data/ 정적 데이터 (기선, 특정어업수역 GeoJSON)

config.py를 kcgaidb로 재구성 (DB명, 사용자, 비밀번호)
DB 연결 검증 완료 (kcgaidb 37개 테이블 접근 확인)
Makefile에 dev-prediction / dev-all 타겟 추가
CLAUDE.md에 prediction 섹션 추가

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 12:56:51 +09:00
c17d190e1d feat: S5 프론트 나머지 화면 실데이터 전환 — 탐지/함정/단속계획
탐지 화면 3개:
- GearDetection: gearStore 더미 → fetchGroups() API (GEAR_IN/OUT_ZONE)
- DarkVesselDetection: vesselStore 더미 → fetchVesselAnalysis() + filterDarkVessels()
  - 패턴 자동 분류 (완전차단/장기소실/MMSI변조/간헐송출)
- ChinaFishing: inline 더미 → fetchVesselAnalysis() + mmsi 412* 필터
  - 센서 카운터 동적 계산, 위험도 분포 도넛 차트

함정/단속계획:
- patrol.ts: 스텁 → GET /api/patrol-ships 실제 호출
- patrolStore: API 기반 (routes/scenarios는 mock 유지)
- EnforcementPlan: GET /api/enforcement/plans 연결

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 12:46:08 +09:00
4e6ac8645a feat: S5 프론트 실데이터 전환 — EventList/Statistics/EnforcementHistory/Dashboard KPI
이벤트 목록 (EventList):
- eventStore를 GET /api/events 호출로 전환
- 서버 필터링 (level/status/category), 페이지네이션
- 상태 배지 (NEW/ACK/IN_PROGRESS/RESOLVED/FALSE_POSITIVE)
- getEventStats() 기반 KPI 카드

단속 이력 (EnforcementHistory):
- 신규 services/enforcement.ts (GET/POST /enforcement/records, /plans)
- enforcementStore를 API 기반으로 전환
- KPI 카드 (총단속/처벌/AI일치/오탐) 클라이언트 계산

통계 (Statistics):
- kpi.ts를 GET /api/stats/kpi, /stats/monthly 실제 호출로 전환
- toMonthlyTrend/toViolationTypes 변환 헬퍼 추가
- BarChart/AreaChart 기존 구조 유지

대시보드 KPI:
- kpiStore를 API 기반으로 전환 (getKpiMetrics + getMonthlyStats)
- Dashboard KPI_UI_MAP에 kpiKey 기반 매핑 추가

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 12:14:53 +09:00
b70ef399b5 fix: prediction_stats_monthly.stat_month CHAR(7) → DATE 타입 변경
날짜 기반 정렬/범위쿼리/집계함수 활용을 위해 VARCHAR(7)→DATE로 변환.
매월 1일(2026-04-01)로 저장. 엔티티/Repository/Controller 파라미터 동시 수정.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 12:05:04 +09:00
91deb3ae55 feat: S1 백엔드 API — 이벤트/통계/단속/마스터 데이터 CRUD
이벤트 허브 (domain/event/):
- PredictionEvent/EventWorkflow 엔티티 + JPA Specification 필터
- EventController: 목록/상세/이력/상태변경/통계 6개 엔드포인트
- 상태 변경 시 EventWorkflow 자동 기록 (감사 추적)

통계/KPI (domain/stats/):
- PredictionKpi/StatsMonthly/StatsDaily 엔티티
- StatsController: KPI/월별/일별 통계 3개 엔드포인트

단속 이력/계획 (domain/enforcement/):
- EnforcementRecord/Plan 엔티티 + UID 자동생성
- EnforcementController: 단속이력/계획 CRUD 6개 엔드포인트
- 단속 등록 시 이벤트 상태 자동 RESOLVED 연동

마스터 데이터 (master/):
- CodeMaster/GearType/PatrolShip/VesselPermit 엔티티 + Repository
- MasterDataController: 코드/어구유형/함정/선박허가 10개 엔드포인트

총 25개 신규 엔드포인트, 98개 Java 소스 파일 컴파일 성공.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 12:02:26 +09:00
883b347359 feat: S1 마스터 데이터 + prediction 기반 DB 스키마 (V008~V013)
prediction 모노레포 이관을 위한 DB 기반 구축:
- V008: 계층형 code_master (12그룹 72코드, 위반유형/이벤트/단속/허가/함정 등)
- V009: gear_type_master 어구 유형 6종 (분류 룰 + 합법성 기준)
- V010: zone_polygon_master PostGIS 해역 폴리곤 (8개 주요 해역)
- V011: vessel_permit_master + patrol_ship_master + fleet_companies 시드
- V012: vessel_analysis_results(파티션) + prediction_events 허브 + 알림 + 통계 + KPI
- V013: enforcement_records/plans + patrol_assignments + ai_model 메타
- Hibernate Spatial 의존성 추가 (PostGIS 지원)
- 프론트엔드 더미 데이터 기반 시드 (이벤트 15건, 단속 6건, 계획 5건, 월별통계 7개월)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 11:49:26 +09:00
f545aeafac fix: 권한 트리 UX 개선 + 라벨 사이드바 일치 + EXPORT 가드
PermissionsPanel UI 수정:
- 같은 노드의 effective READ가 거부되면 C/U/D/E도 forced-denied
  (READ가 안 되면 그 페이지 자체에 접근 못 하므로 다른 작업도 의미 없음)
  → 사용자가 Read를 N으로 바꾸는 즉시 같은 행의 CUDE도 회색 비활성

DataTable EXPORT 권한 가드:
- exportResource prop 추가
- useAuth().hasPermission(resource, 'EXPORT')로 export 버튼 표시 여부 결정
- AccessControl의 사용자 관리 / 감사 로그 DataTable에 적용
  - exportResource="admin:user-management"
  - exportResource="admin:audit-logs"

Operation 의미 명확화:
- ParentExclusion release 엔드포인트를 UPDATE → DELETE 로 재분류
  (제외 항목을 "삭제(해제)"하는 의미가 더 정확)

V007 마이그레이션: 권한 트리 명칭을 사이드바 i18n 라벨과 일치
- Level 0 13개 + Level 1 32개 노드의 rsrc_nm을 nav.* / group.* 라벨에 맞춤
- 예: "어구탐지" → "어구 탐지", "Dark Vessel" → "다크베셀 탐지"
- 권한 관리 트리를 운영자가 사이드바와 동일한 명칭으로 이해 가능

API의 RCUDE 적용 현황 (참고):
- READ 19건, UPDATE 8건, CREATE 4건, DELETE 1→2건
- EXPORT는 백엔드 엔드포인트 별도 없음 → 프론트 EXPORT 가드로 처리
- 향후 백엔드 CSV/Excel 생성 API 추가 시 EXPORT operation으로 가드

검증:
- V007 마이그레이션 자동 적용 + Started in 3.272s
- Level 0 13개 모두 사이드바 라벨로 변경됨 확인
- 프론트 빌드 통과 (599ms)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 10:33:29 +09:00
95ca1018b5 feat: Phase 6-8 - iran 백엔드 실연결 + 시스템 상태 + AI 채팅 기반
Phase 6: iran 백엔드 실연결 + 화면 연동
- application.yml: app.iran-backend.base-url=https://kcg.gc-si.dev
- IranBackendClient: RestClient 확장 (Accept JSON header, getAs<T>)
- VesselAnalysisProxyController: HYBRID 합성 로직 추가
  - GET /api/vessel-analysis: stats + 7423건 분석 결과 통과
  - GET /api/vessel-analysis/groups: 476건 그룹 + 자체 DB resolution 합성
  - GET /api/vessel-analysis/groups/{key}/detail
  - GET /api/vessel-analysis/groups/{key}/correlations
  - 권한: detection / detection:gear-detection (READ)
- 프론트 services/vesselAnalysisApi.ts: 타입 + 필터 헬퍼
  (filterDarkVessels, filterSpoofingVessels, filterTransshipSuspects)
- features/detection/RealGearGroups.tsx: 어구/선단 그룹 실시간 표시
  (FLEET/GEAR_IN_ZONE/GEAR_OUT_ZONE 필터, 통계 5종, 운영자 결정 합성 표시)
- features/detection/RealVesselAnalysis.tsx: 분석 결과 모드별 렌더
  - mode='dark' / 'spoofing' / 'transship' / 'all'
  - 위험도순 정렬 + 6개 통계 카드 + 해역/Dark/Spoofing/전재 표시
- 화면 연동:
  - GearDetection → RealGearGroups 추가
  - DarkVesselDetection → RealDarkVessels + RealSpoofingVessels
  - ChinaFishing(dashboard) → RealAllVessels
  - TransferDetection → RealTransshipSuspects

Phase 7: 시스템 상태 대시보드
- features/monitoring/SystemStatusPanel.tsx
  - 3개 서비스 카드: KCG Backend / iran 백엔드 / Prediction
  - 위험도 분포 (CRITICAL/HIGH/MEDIUM/LOW) 4개 박스
  - 30초 자동 폴링
- MonitoringDashboard 최상단에 SystemStatusPanel 추가

Phase 8: AI 채팅 기반 (SSE는 Phase 9 인증 후)
- 프론트 services/chatApi.ts: sendChatMessage (graceful fallback)
- 백엔드 PredictionProxyController.chat 추가
  - POST /api/prediction/chat
  - 권한: ai-operations:ai-assistant (READ)
  - 현재 stub 응답 (iran chat 인증 토큰 필요)
- AIAssistant 페이지에 백엔드 호출 통합
  (handleSend → sendChatMessage → 응답 표시 + graceful 메시지)

검증:
- 백엔드 컴파일/기동 성공 (Started in 5.2s)
- iran 프록시: 471개 그룹, 7423건 분석 결과 정상 통과
- 프론트 빌드 통과 (502ms)
- E2E 시나리오:
  - admin 로그인 → /api/vessel-analysis/groups → 476건 + serviceAvailable=true
  - /api/prediction/chat → stub 응답 (Phase 9 안내)

설계 원칙:
- iran 백엔드 미연결 시 graceful degradation (serviceAvailable=false + 빈 데이터)
- HYBRID 합성: prediction 후보 + 자체 DB의 운영자 결정을 백엔드에서 조합
- 향후 iran 인증 토큰 통과 후 SSE 채팅 활성화

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 10:22:04 +09:00
febfb2cbe8 feat: Phase 5 - 권한 관리 UI 고도화 (트리 RBAC PermissionsPanel)
버그 수정:
- AccessControl 무한 새로고침 (loadRoles의 userStats 의존성 → setUserStats 호출 → 무한 루프)
  loadRoles에서 항상 fetchUserStats를 같이 호출하도록 변경

백엔드 API 추가:
- RoleManagementService (역할/권한 매트릭스 CRUD)
  - createRole / updateRole / deleteRole (built-in 보호)
  - updatePermissions (Y/N upsert + null 시 명시 권한 제거)
  - assignUserRoles (전체 교체 방식, 권한 캐시 evict)
  - 모든 액션에 @Auditable 자동 기록
- PermTreeController 확장:
  - POST /api/roles (admin:role-management:CREATE)
  - PUT /api/roles/{sn} (admin:role-management:UPDATE)
  - DELETE /api/roles/{sn} (admin:role-management:DELETE)
  - PUT /api/roles/{sn}/permissions (admin:permission-management:UPDATE)
  - PUT /api/admin/users/{id}/roles (admin:user-management:UPDATE)
- DTO: RoleCreateRequest, RoleUpdateRequest, PermissionUpdateRequest, UserRoleAssignRequest
- GlobalExceptionHandler:
  - IllegalArgumentException → 400 BAD_REQUEST
  - IllegalStateException → 409 CONFLICT
  - AccessDeniedException → 403 FORBIDDEN

프론트엔드:
- lib/permission/permResolver.ts (TypeScript 미러)
  - resolveSingleRoleEffective: 백엔드 PermResolver와 동일 알고리즘
  - 4가지 셀 상태 계산 (explicit-granted/inherited-granted/explicit-denied/forced-denied)
- PermissionsPanel.tsx (트리 + R/C/U/D/E 매트릭스)
  - 좌측: 역할 목록 + 신규 생성 + 삭제 (built-in 보호)
  - 우측: 트리 표 + 셀 클릭 (Y → N → 미지정 순환)
  - 부모 READ 게이팅 시각화 (강제 거부 회색 비활성)
  - 변경된 셀만 일괄 저장 (dirty 추적)
- UserRoleAssignDialog.tsx
  - 사용자에게 역할 다중 선택 배정 (체크박스)
- adminApi.ts 확장: createRole/updateRole/deleteRole/updateRolePermissions/assignUserRoles
- AccessControl.tsx 갱신:
  - 역할 관리 탭 → PermissionsPanel 통합
  - 사용자 관리 탭 → 역할 배정 버튼 추가 (UserCog 아이콘)

검증:
- 역할 생성 → TESTROLE 6번으로 추가
- 권한 매트릭스 갱신 → dashboard/monitoring READ 부여 (changed: 2)
- 역할 삭제 → built-in이 아니면 OK
- built-in ADMIN 삭제 시도 → 400 BAD_REQUEST (BUILTIN_ROLE_CANNOT_DELETE)
- viewer에게 OPERATOR + ANALYST 다중 배정 → roles=[OPERATOR, ANALYST]
  → 재로그인 시 detection READ 등 자동 상속 확인
- 권한 캐시 evictAllPermissions 즉시 반영
- 프론트 빌드 통과 (533ms)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 10:11:27 +09:00
fc1a686700 feat: 시스템 관리 페이지 백엔드 연결 + 메트릭 카드
백엔드 API 추가:
- UserManagementController (admin:user-management)
  - GET /api/admin/users : 사용자 목록 + 역할 코드
  - GET /api/admin/users/stats : 상태별/역할별/인증방식별 카운트
  - POST /api/admin/users/{id}/unlock : 잠금 해제 (@Auditable USER_UNLOCK)
  - PUT /api/admin/users/{id}/status : 상태 변경 (@Auditable USER_STATUS_CHANGE)
  - 권한 캐시 evict 자동 호출
- AdminStatsController (admin:audit-logs/access-logs/login-history READ)
  - GET /api/admin/stats/audit : 전체/24시간/실패/액션별/시간별 통계
  - GET /api/admin/stats/access : 전체/24시간/4xx/5xx/평균응답/인기경로
  - GET /api/admin/stats/login : 성공률/사용자별/일별 추세

프론트엔드 연결:
- adminApi.ts 확장: AdminUser/UserStats/AuditStats/AccessStats/LoginStats
  타입 정의 + 사용자/통계 fetch 함수
- AccessControl.tsx (시스템 관리 > 권한 관리):
  - 4개 탭 모두 백엔드 연결
  - 역할 관리: GET /api/roles + 사용자별 카운트 표시
  - 사용자 관리: GET /api/admin/users + DataTable + 잠금 해제 버튼
    + 통계 카드 4개 (총/활성/잠금/비활성)
  - 감사 로그: GET /api/admin/audit-logs + GET /api/admin/stats/audit
    + 액션별 분포 Badge + 통계 카드
  - 보안 정책: 실제 백엔드 동작과 일치하도록 갱신
- AuditLogs.tsx: 메트릭 카드 4개 + 액션별 분포
- AccessLogs.tsx: 메트릭 카드 5개 (전체/24시간/4xx/5xx/평균) + Top 10 경로 테이블
- LoginHistoryView.tsx: 메트릭 카드 5개 + 사용자별 + 일별 추세

검증:
- /api/admin/users → 5명 (admin/operator/analyst/field/viewer)
- /api/admin/users/stats → byRole, byStatus, byProvider 카운트
- /api/admin/stats/audit → total 15, 액션 6종, hourly 추세
- /api/admin/stats/login → success 80%, byUser top, daily 추세
- 프론트엔드 빌드 통과 (493ms)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 09:57:59 +09:00
bae2f33b86 feat: Phase 4 - 모선 워크플로우 + 관리자 화면 + 권한 라우트 가드
Phase 4-1: 운영자 워크플로우 백엔드 (자체 DB)
- ParentResolution / ParentReviewLog / CandidateExclusion / LabelSession 엔티티
- Repository 4종 + DTO 5종
- ParentInferenceWorkflowService (HYBRID 패턴):
  - review (CONFIRM/REJECT/RESET) - parent-inference-workflow:parent-review (UPDATE)
  - excludeForGroup - parent-inference-workflow:parent-exclusion (CREATE)
  - excludeGlobal - parent-inference-workflow:exclusion-management (CREATE) [admin]
  - releaseExclusion (UPDATE)
  - createLabelSession / cancelLabelSession (CREATE/UPDATE)
- ParentInferenceWorkflowController: @RequirePermission으로 권한 강제
- 모든 액션에 @Auditable AOP → audit_log + review_log 동시 기록

Phase 4-2: PermTreeController + AdminLogController
- GET /api/perm-tree (모든 사용자) - 메뉴/사이드바 구성용
- GET /api/roles (admin:role-management) - 역할+권한 매트릭스
- GET /api/admin/audit-logs / access-logs / login-history

Phase 4-3: iran 백엔드 프록시 (stub)
- IranBackendClient: RestClient 기반, 호출 실패 시 null 반환 (graceful)
- VesselAnalysisProxyController: serviceAvailable=false 응답
- PredictionProxyController: DISCONNECTED 응답
- Phase 5에서 iran 백엔드 실 연결 시 코드 변경 최소

Phase 4-4: 프론트엔드 services
- parentInferenceApi.ts: 모선 워크플로우 22개 함수
- adminApi.ts: 감사로그/접근이력/로그인이력/권한트리/역할 조회

Phase 4-5: 사이드바 권한 필터링 + ProtectedRoute 권한 가드
- AuthContext.PATH_TO_RESOURCE에 신규 경로 매핑 추가
- ProtectedRoute에 resource/operation prop 추가
  → 권한 거부 시 403 페이지 표시
- 모든 라우트에 권한 리소스 명시
- MainLayout 사이드바: parent-inference-workflow + admin 로그 메뉴 추가
- 사이드바 hasAccess 필터링 (이전부터 구현됨, 신규 메뉴에도 자동 적용)

Phase 4-6: 신규 페이지 3종
- ParentReview.tsx: 모선 확정/거부/리셋 + 신규 등록 폼
- ParentExclusion.tsx: GROUP/GLOBAL 제외 등록 + 해제
- LabelSession.tsx: 학습 세션 생성/취소
- AuditLogs.tsx: 감사 로그 조회
- AccessLogs.tsx: 접근 이력 조회
- LoginHistoryView.tsx: 로그인 이력 조회

Phase 4-7: i18n 키 + 라우터 등록
- 한국어/영어 nav.* + group.* 키 추가
- App.tsx에 12개 신규 라우트 등록 + 권한 가드 적용

Phase 4-8: 검증 완료
- 백엔드 컴파일/기동 성공
- 프론트엔드 빌드 성공 (475ms)
- E2E 시나리오:
  - operator 로그인 → CONFIRM 확정 → MANUAL_CONFIRMED 갱신
  - operator GROUP 제외 → 성공
  - operator GLOBAL 제외 → 403 FORBIDDEN (권한 없음)
  - operator 학습 세션 생성 → ACTIVE
  - admin GLOBAL 제외 → 성공
  - 감사 로그 자동 기록: REVIEW_PARENT/EXCLUDE_CANDIDATE_GROUP/
    LABEL_PARENT_CREATE/EXCLUDE_CANDIDATE_GLOBAL 등 14건
  - 권한 트리 RBAC + AOP 정상 동작 확인

설계 핵심:
- 운영자 의사결정만 자체 DB에 저장 (HYBRID)
- iran 백엔드 데이터는 향후 Phase 5에서 합쳐서 표시
- @RequirePermission + @Auditable로 모든 액션 권한 + 감사 자동화
- 데모 계정으로 완전한 워크플로우 시연 가능

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 09:44:43 +09:00
b0c9a9fffb feat: Phase 3 - 자체 인증 + 트리 기반 RBAC + 감사로그 + 데모 계정
Phase 3-1~10: 백엔드
- pom.xml에 spring-boot-starter-aop 추가
- JPA 엔티티 12종 + Repository 9종
  (User/LoginHistory/Role/UserRole/PermTree/Perm/AuditLog/AccessLog 등)
- PermResolver: wing 프로젝트의 permResolver.ts를 Java로 이식
  - 트리 BFS + 부모 READ 게이팅 + 다중 역할 OR 합집합 + 부모 fallback
- PermissionService: Caffeine 캐싱 (TTL 10분)
- JwtService + JwtAuthFilter (HttpOnly 쿠키 + Authorization 헤더 fallback)
- AuthProvider 인터페이스 + PasswordAuthProvider (BCrypt + 5회 잠금)
  - REQUIRES_NEW + noRollbackFor로 fail_cnt 증가 보존
- AuthService + LoginAuditWriter (REQUIRES_NEW로 실패 기록 보존)
- AuthController: /api/auth/login, /logout, /me
- @RequirePermission 어노테이션 + PermissionAspect (메서드 권한 체크)
- @Auditable 어노테이션 + AuditAspect (의사결정 자동 기록)
- AccessLogFilter: 모든 HTTP 요청 비동기 기록 (BlockingQueue)
- SecurityConfig 본격 도입 (CORS + JWT 필터 + 401/403 핸들러)

Phase 3-10: 데모 계정
- V006__demo_accounts.sql: 5개 데모 계정 (admin/operator/analyst/field/viewer)
  + 역할 매핑 (PLACEHOLDER 해시)
- AccountSeeder.java: 시동 시 BCrypt 해시 시드 (PLACEHOLDER만 갱신)
- 데모 계정도 실제 권한, 로그인 이력, 감사로그 기록 대상

Phase 3-11: 백엔드 검증 완료
- admin/operator/viewer 로그인 성공
- 권한 매트릭스: ADMIN(49), OPERATOR(40), VIEWER(35)
- 트리 상속 검증: detection READ → 자식 4개 자동 상속
- 잘못된 비밀번호 → fail_cnt 증가 + login_hist FAILED 기록
- 정상 로그인 → fail_cnt 0 초기화
- 모든 요청 access_log에 비동기 기록

V001/V002: CHAR(1) → VARCHAR(1) 변경 (Hibernate validate 호환성)

Phase 3-12: 프론트엔드 연동
- services/authApi.ts: 백엔드 호출 클라이언트 (login/logout/me)
- AuthContext.tsx: 백엔드 API 통합 + 트리 기반 hasPermission
  + 부모 fallback (예: detection:gear-detection 미등록 시 detection 검사)
  + 30분 세션 타임아웃 유지
- DemoQuickLogin.tsx: 데모 퀵로그인 컴포넌트 분리
  + isDemoLoginEnabled() = VITE_SHOW_DEMO_LOGIN === 'true'
  + 데모 클릭 시에도 정상 백엔드 인증 플로우 사용
- LoginPage.tsx: 백엔드 인증 호출 + DemoQuickLogin 통합
  + 에러 메시지 한국어 변환 (WRONG_PASSWORD:N, ACCOUNT_LOCKED 등)
  + GPKI/SSO 탭은 disabled (Phase 9 도입 예정)
- frontend/.env.development: VITE_SHOW_DEMO_LOGIN=true
- frontend/.env.production: VITE_SHOW_DEMO_LOGIN=true (현재 단계)
- .gitignore에 frontend/.env.{development,production} 예외 추가

설계 핵심:
- 데모 계정은 백엔드 DB에 실제 권한 부여 + 로그인/감사 기록 대상
- DemoQuickLogin 컴포넌트는 환경변수로 토글 가능하도록 구조 분리
- 향후 운영 배포 시 .env.production만 false로 변경하면 데모 영역 숨김

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 09:29:52 +09:00
04dfdf2d36 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>
2026-04-07 09:01:13 +09:00
e6319a571c refactor: 모노레포 구조로 전환 (frontend/ + backend/ + database/)
Phase 1: 모노레포 디렉토리 구조 구축

- 기존 React 프로젝트를 frontend/ 디렉토리로 이동 (git mv)
- backend/ 디렉토리 생성 (Phase 2에서 Spring Boot 초기화)
- database/migration/ 디렉토리 생성 (Phase 2에서 Flyway 마이그레이션)
- 루트 .gitignore에 frontend/, backend/ 경로 반영
- 루트 CLAUDE.md를 모노레포 가이드로 갱신
- Makefile 추가 (dev/build/lint 통합 명령)
- frontend/vite.config.ts에 /api → :8080 백엔드 proxy 설정
- .githooks/pre-commit을 모노레포 구조에 맞게 갱신
  (frontend/ 변경 시 frontend/ 내부에서 검증)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 08:47:24 +09:00
1eccbd7a72 chore: 팀 워크플로우 초기화 + Prettier + 타입 에러 수정
- /init-project로 팀 표준 워크플로우 적용 (CLAUDE.md, settings.json hooks, pre-commit)
- Prettier + eslint-config-prettier 설치 및 ESLint 연동
- format/format:check npm 스크립트 추가
- vite-env.d.ts 추가 (import.meta.env 타입 정의)
- pre-commit 차단 해제: GearDetection/BaseChart 타입 캐스팅

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 08:44:28 +09:00
84c462955a Merge pull request 'release: CI/CD 초기 설정' (#2) from develop into main
All checks were successful
Build and Deploy KCG AI Monitoring / build-and-deploy (push) Successful in 15s
2026-04-06 14:13:26 +09:00
7a9ece5f0f Merge pull request 'ci: Gitea Actions 자동배포 워크플로우 추가' (#1) from feature/add-cicd into develop 2026-04-06 14:13:24 +09:00
566개의 변경된 파일57547개의 추가작업 그리고 6748개의 파일을 삭제

파일 보기

@ -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,52 @@ 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/
- name: Archive system-flow snapshot (per version)
run: |
# system-flow.html을 manifest version별로 영구 보존 (서버 로컬, nginx 노출 X)
ARCHIVE=/deploy/kcg-ai-monitoring-archive/system-flow
mkdir -p $ARCHIVE
if [ ! -f "frontend/src/flow/manifest/meta.json" ]; then
echo "[archive] meta.json not found, skip"
exit 0
fi
VERSION=$(node -e "console.log(JSON.parse(require('fs').readFileSync('frontend/src/flow/manifest/meta.json')).version)")
DATE=$(date +%Y-%m-%d)
SNAPSHOT="$ARCHIVE/v${VERSION}_${DATE}"
if [ -d "$SNAPSHOT" ]; then
echo "[archive] v${VERSION} already exists, skip"
exit 0
fi
mkdir -p "$SNAPSHOT/assets"
cp /deploy/kcg-ai-monitoring/system-flow.html "$SNAPSHOT/" 2>/dev/null || true
cp /deploy/kcg-ai-monitoring/assets/systemFlow-*.* "$SNAPSHOT/assets/" 2>/dev/null || true
cp /deploy/kcg-ai-monitoring/assets/index-*.* "$SNAPSHOT/assets/" 2>/dev/null || true
# manifest 전체 스냅샷 (JSON 형태로 별도 참조 가능)
cp -r frontend/src/flow/manifest "$SNAPSHOT/manifest" 2>/dev/null || true
echo "[archive] system-flow v${VERSION} snapshot saved → $SNAPSHOT"
ls -la "$SNAPSHOT/"

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

35
.gitignore vendored
파일 보기

@ -1,8 +1,19 @@
# === Build ===
dist/
build/
frontend/dist/
frontend/build/
backend/target/
backend/build/
# === Python (prediction) ===
.venv/
prediction/.venv/
prediction/__pycache__/
prediction/**/__pycache__/
prediction/*.pyc
prediction/.env
# === Dependencies ===
frontend/node_modules/
node_modules/
# === IDE ===
@ -19,6 +30,8 @@ Thumbs.db
.env
.env.*
!.env.example
# 프론트엔드 환경 예시 (.env.example만 커밋)
!frontend/.env.example
secrets/
# === Debug ===
@ -27,18 +40,22 @@ 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/
# === 대용량/참고 문서 ===
*.hwpx
*.docx
# === Claude Code ===
!.claude/
@ -55,3 +72,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

287
CLAUDE.md Normal file
파일 보기

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

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 패턴)
## 책임
- 자체 인증/권한/감사로그
- 운영자 의사결정 (모선 확정/제외/학습)
- prediction 분석 결과 조회 API (`/api/analysis/*`)
- 관리자 화면 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"

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

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

@ -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 SignalBatch signalBatch = new SignalBatch();
private Cors cors = new Cors();
private Jwt jwt = new Jwt();
@Getter @Setter
public static class Prediction {
private String baseUrl;
}
@Getter @Setter
public static class SignalBatch {
private String baseUrl;
}
@Getter @Setter
public static class Cors {
private String allowedOrigins;
}
@Getter @Setter
public static class Jwt {
private String secret;
private long expirationMs;
}
}

파일 보기

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

파일 보기

@ -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,27 @@
package gc.mda.kcg.domain.analysis;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
/**
* vessel_analysis_results 집계 응답.
* MMSI별 최신 row 기준으로 단일 쿼리 COUNT FILTER 집계한다.
*/
public record AnalysisStatsResponse(
long total,
long darkCount,
long spoofingCount,
long transshipCount,
long criticalCount,
long highCount,
long mediumCount,
long lowCount,
long territorialCount,
long contiguousCount,
long eezCount,
long fishingCount,
BigDecimal avgRiskScore,
OffsetDateTime windowStart,
OffsetDateTime windowEnd
) {
}

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

@ -0,0 +1,38 @@
package gc.mda.kcg.domain.analysis;
import java.time.OffsetDateTime;
import java.util.List;
/**
* prediction 자동 어구 탐지 결과 응답 DTO.
* gear_code / gear_judgment NOT NULL row의 핵심 필드만 노출.
*/
public record GearDetectionResponse(
Long id,
String mmsi,
OffsetDateTime analyzedAt,
String vesselType,
String gearCode,
String gearJudgment,
String permitStatus,
String riskLevel,
Integer riskScore,
String zoneCode,
List<String> violationCategories
) {
public static GearDetectionResponse from(VesselAnalysisResult e) {
return new GearDetectionResponse(
e.getId(),
e.getMmsi(),
e.getAnalyzedAt(),
e.getVesselType(),
e.getGearCode(),
e.getGearJudgment(),
e.getPermitStatus(),
e.getRiskLevel(),
e.getRiskScore(),
e.getZoneCode(),
e.getViolationCategories()
);
}
}

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

@ -0,0 +1,118 @@
package gc.mda.kcg.domain.analysis;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.JpaSpecificationExecutor;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Map;
import java.util.Optional;
/**
* vessel_analysis_results 읽기 전용 Repository.
*/
public interface VesselAnalysisRepository
extends JpaRepository<VesselAnalysisResult, Long>, JpaSpecificationExecutor<VesselAnalysisResult> {
/**
* 특정 선박의 최신 분석 결과.
*/
Optional<VesselAnalysisResult> findTopByMmsiOrderByAnalyzedAtDesc(String mmsi);
/**
* 특정 선박의 분석 이력 (시간 범위).
*/
List<VesselAnalysisResult> findByMmsiAndAnalyzedAtAfterOrderByAnalyzedAtDesc(
String mmsi, OffsetDateTime after);
/**
* 다크 베셀 목록 (최근 분석 결과, MMSI 중복 제거).
*/
@Query("""
SELECT v FROM VesselAnalysisResult v
WHERE v.isDark = true AND v.analyzedAt > :after
AND v.analyzedAt = (
SELECT MAX(v2.analyzedAt) FROM VesselAnalysisResult v2
WHERE v2.mmsi = v.mmsi AND v2.analyzedAt > :after
)
ORDER BY v.riskScore DESC
""")
Page<VesselAnalysisResult> findLatestDarkVessels(
@Param("after") OffsetDateTime after, Pageable pageable);
/**
* 환적 의심 목록 (최근 분석 결과, MMSI 중복 제거).
*/
@Query("""
SELECT v FROM VesselAnalysisResult v
WHERE v.transshipSuspect = true AND v.analyzedAt > :after
AND v.analyzedAt = (
SELECT MAX(v2.analyzedAt) FROM VesselAnalysisResult v2
WHERE v2.mmsi = v.mmsi AND v2.analyzedAt > :after
)
ORDER BY v.riskScore DESC
""")
Page<VesselAnalysisResult> findLatestTransshipSuspects(
@Param("after") OffsetDateTime after, Pageable pageable);
/**
* 어구 탐지 결과 목록 (gear_code/judgment NOT NULL, MMSI 중복 제거).
* mmsiPrefix null 이면 전체, 아니면 LIKE ':prefix%'.
*/
@Query("""
SELECT v FROM VesselAnalysisResult v
WHERE v.gearCode IS NOT NULL
AND v.gearJudgment IS NOT NULL
AND v.analyzedAt > :after
AND (:mmsiPrefix IS NULL OR v.mmsi LIKE CONCAT(:mmsiPrefix, '%'))
AND v.analyzedAt = (
SELECT MAX(v2.analyzedAt) FROM VesselAnalysisResult v2
WHERE v2.mmsi = v.mmsi AND v2.analyzedAt > :after
)
ORDER BY v.analyzedAt DESC
""")
Page<VesselAnalysisResult> findLatestGearDetections(
@Param("after") OffsetDateTime after,
@Param("mmsiPrefix") String mmsiPrefix,
Pageable pageable);
/**
* MMSI별 최신 row 기준 집계 (단일 쿼리 COUNT FILTER).
* mmsiPrefix null 이면 전체.
* 반환 Map : total, dark_count, spoofing_count, transship_count,
* critical_count, high_count, medium_count, low_count,
* territorial_count, contiguous_count, eez_count,
* fishing_count, avg_risk_score
*/
@Query(value = """
WITH latest AS (
SELECT DISTINCT ON (mmsi) *
FROM kcg.vessel_analysis_results
WHERE analyzed_at > :after
AND (:mmsiPrefix IS NULL OR mmsi LIKE :mmsiPrefix || '%')
ORDER BY mmsi, analyzed_at DESC
)
SELECT
COUNT(*) AS total,
COUNT(*) FILTER (WHERE is_dark = TRUE) AS dark_count,
COUNT(*) FILTER (WHERE spoofing_score >= 0.3) AS spoofing_count,
COUNT(*) FILTER (WHERE transship_suspect = TRUE) AS transship_count,
COUNT(*) FILTER (WHERE risk_level = 'CRITICAL') AS critical_count,
COUNT(*) FILTER (WHERE risk_level = 'HIGH') AS high_count,
COUNT(*) FILTER (WHERE risk_level = 'MEDIUM') AS medium_count,
COUNT(*) FILTER (WHERE risk_level = 'LOW') AS low_count,
COUNT(*) FILTER (WHERE zone_code = 'TERRITORIAL_SEA') AS territorial_count,
COUNT(*) FILTER (WHERE zone_code = 'CONTIGUOUS_ZONE') AS contiguous_count,
COUNT(*) FILTER (WHERE zone_code = 'EEZ_OR_BEYOND') AS eez_count,
COUNT(*) FILTER (WHERE fishing_pct > 0.5) AS fishing_count,
COALESCE(AVG(risk_score), 0)::NUMERIC(5,2) AS avg_risk_score
FROM latest
""", nativeQuery = true)
Map<String, Object> aggregateStats(
@Param("after") OffsetDateTime after,
@Param("mmsiPrefix") String mmsiPrefix);
}

파일 보기

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

파일 보기

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

파일 보기

@ -0,0 +1,146 @@
package gc.mda.kcg.domain.analysis;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.Map;
/**
* vessel_analysis_results 직접 조회 서비스.
* prediction이 write한 분석 결과를 프론트엔드에 제공.
*/
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class VesselAnalysisService {
private final VesselAnalysisRepository repository;
/**
* 분석 결과 목록 조회 (동적 필터).
*/
public Page<VesselAnalysisResult> getAnalysisResults(
String mmsi, String zoneCode, String riskLevel, Boolean isDark,
String mmsiPrefix, Integer minRiskScore, BigDecimal minFishingPct,
OffsetDateTime after, Pageable pageable
) {
Specification<VesselAnalysisResult> spec = (root, query, cb) -> cb.conjunction();
if (after != null) {
spec = spec.and((root, query, cb) -> cb.greaterThan(root.get("analyzedAt"), after));
}
if (mmsi != null && !mmsi.isBlank()) {
spec = spec.and((root, query, cb) -> cb.equal(root.get("mmsi"), mmsi));
}
if (mmsiPrefix != null && !mmsiPrefix.isBlank()) {
final String prefix = mmsiPrefix;
spec = spec.and((root, query, cb) -> cb.like(root.get("mmsi"), prefix + "%"));
}
if (zoneCode != null && !zoneCode.isBlank()) {
spec = spec.and((root, query, cb) -> cb.equal(root.get("zoneCode"), zoneCode));
}
if (riskLevel != null && !riskLevel.isBlank()) {
spec = spec.and((root, query, cb) -> cb.equal(root.get("riskLevel"), riskLevel));
}
if (isDark != null && isDark) {
spec = spec.and((root, query, cb) -> cb.isTrue(root.get("isDark")));
}
if (minRiskScore != null) {
spec = spec.and((root, query, cb) -> cb.greaterThanOrEqualTo(root.get("riskScore"), minRiskScore));
}
if (minFishingPct != null) {
spec = spec.and((root, query, cb) -> cb.greaterThan(root.get("fishingPct"), minFishingPct));
}
return repository.findAll(spec, pageable);
}
/**
* MMSI별 최신 row 기준 집계 (단일 쿼리).
*/
public AnalysisStatsResponse getStats(int hours, String mmsiPrefix) {
OffsetDateTime windowEnd = OffsetDateTime.now();
OffsetDateTime windowStart = windowEnd.minusHours(hours);
String prefix = (mmsiPrefix != null && !mmsiPrefix.isBlank()) ? mmsiPrefix : null;
Map<String, Object> row = repository.aggregateStats(windowStart, prefix);
return new AnalysisStatsResponse(
longOf(row, "total"),
longOf(row, "dark_count"),
longOf(row, "spoofing_count"),
longOf(row, "transship_count"),
longOf(row, "critical_count"),
longOf(row, "high_count"),
longOf(row, "medium_count"),
longOf(row, "low_count"),
longOf(row, "territorial_count"),
longOf(row, "contiguous_count"),
longOf(row, "eez_count"),
longOf(row, "fishing_count"),
bigDecimalOf(row, "avg_risk_score"),
windowStart,
windowEnd
);
}
/**
* prediction 자동 어구 탐지 결과 목록.
*/
public Page<VesselAnalysisResult> getGearDetections(int hours, String mmsiPrefix, Pageable pageable) {
OffsetDateTime after = OffsetDateTime.now().minusHours(hours);
String prefix = (mmsiPrefix != null && !mmsiPrefix.isBlank()) ? mmsiPrefix : null;
return repository.findLatestGearDetections(after, prefix, pageable);
}
private static long longOf(Map<String, Object> row, String key) {
Object v = row.get(key);
if (v == null) return 0L;
return ((Number) v).longValue();
}
private static BigDecimal bigDecimalOf(Map<String, Object> row, String key) {
Object v = row.get(key);
if (v == null) return BigDecimal.ZERO;
if (v instanceof BigDecimal bd) return bd;
return new BigDecimal(v.toString());
}
/**
* 특정 선박 최신 분석 결과.
*/
public VesselAnalysisResult getLatestByMmsi(String mmsi) {
return repository.findTopByMmsiOrderByAnalyzedAtDesc(mmsi)
.orElseThrow(() -> new IllegalArgumentException("ANALYSIS_NOT_FOUND: " + mmsi));
}
/**
* 특정 선박 분석 이력 (시간 범위).
*/
public List<VesselAnalysisResult> getHistory(String mmsi, int hours) {
OffsetDateTime after = OffsetDateTime.now().minusHours(hours);
return repository.findByMmsiAndAnalyzedAtAfterOrderByAnalyzedAtDesc(mmsi, after);
}
/**
* 다크 베셀 목록 (최신 분석, MMSI 중복 제거).
*/
public Page<VesselAnalysisResult> getDarkVessels(int hours, Pageable pageable) {
OffsetDateTime after = OffsetDateTime.now().minusHours(hours);
return repository.findLatestDarkVessels(after, pageable);
}
/**
* 환적 의심 목록 (최신 분석, MMSI 중복 제거).
*/
public Page<VesselAnalysisResult> getTransshipSuspects(int hours, Pageable pageable) {
OffsetDateTime after = OffsetDateTime.now().minusHours(hours);
return repository.findLatestTransshipSuspects(after, pageable);
}
}

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

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