Compare commits

...

191 커밋

작성자 SHA1 메시지 날짜
50d816e2ff Merge pull request 'release: 2026-04-20 (37건 커밋)' (#89) from release/2026-04-20 into main
All checks were successful
Build and Deploy KCG AI Monitoring (Frontend) / build-and-deploy (push) Successful in 21s
2026-04-20 06:56:08 +09:00
be315f59aa docs: 릴리즈 노트 정리 (2026-04-20) 2026-04-20 06:52:25 +09:00
30abe6a951 Merge pull request 'feat(db): Detection Model Registry 스키마 (V034, Phase 1-1)' (#87) from feature/phase1-1-detection-models-schema into develop 2026-04-20 06:49:02 +09:00
82ffe7cb06 Merge branch 'develop' into feature/phase1-1-detection-models-schema 2026-04-20 06:48:43 +09:00
afde36480d Merge pull request 'fix(ui): 모니터링/디자인시스템 런타임 에러 해소' (#88) from fix/runtime-errors-monitoring-designsystem into develop 2026-04-20 06:36:32 +09:00
d971624090 docs: 릴리즈 노트 업데이트 2026-04-20 06:31:33 +09:00
eee9e79818 fix(ui): 모니터링/디자인시스템 런타임 에러 해소
### SystemStatusPanel TypeError
- 증상: /monitoring 에서 Uncaught TypeError: Cannot read properties of undefined (reading 'toLocaleString')
- 원인: stats 객체는 존재하나 total 필드가 undefined 인 경우 (백엔드 응답이 기대 shape 와 다를 때) 크래시
- 수정: stats?.total != null ? ... / stats.critical ?? 0 식 null-safe 전환 (total/clusterCount/gearGroups/critical/high/medium/low 전부)

### CatalogBadges 렌더링 오류
- 증상: /design-system.html 에서
  (1) Each child in a list should have a unique "key" prop
  (2) Objects are not valid as a React child (found: object with keys {ko, en})
- 원인: PERFORMANCE_STATUS_META 의 meta 는 {intent, hex, label: {ko, en}} 형식. code 필드 없고 label 이 객체.
  - Object.values() + <Trk key={meta.code}> 로 undefined key 중복
  - getKoLabel 이 meta.label (객체) 그대로 반환해 Badge children 에 객체 주입
  다른 카탈로그는 fallback: {ko, en} 패턴이라 문제 없음 (performanceStatus 만 label 객체)
- 수정:
  - Object.entries() 로 순회해 Record key 를 안정적 식별자로 사용
  - AnyMeta.label 타입을 string | {ko,en} 확장
  - getKoLabel/getEnLabel 우선순위: fallback.ko → label.ko → label(문자열) → code → key
  - PERFORMANCE_STATUS_META 자체는 변경 안 함 (admin 페이지들이 label.ko/label.en 직접 참조 중)

### 검증
- npx tsc --noEmit 통과
- pre-commit tsc+ESLint 통과
2026-04-20 06:31:11 +09:00
410d0da9cf docs: 릴리즈 노트 업데이트 2026-04-20 06:22:58 +09:00
dae7aea861 feat(db): Detection Model Registry 스키마 (V034, Phase 1-1)
prediction 의 17 탐지 알고리즘을 "명시적 모델 단위" 로 분리하고 프론트엔드에서
파라미터·버전을 관리할 수 있도록 하는 기반 인프라의 DB 계층.

기존 V014 correlation_param_models 패턴을 일반화:
- JSONB params + is_active(V014) → model_id × version × role×status (V034)
- 한 모델을 여러 파라미터 셋으로 동시 실행 지원 (PRIMARY/SHADOW/CHALLENGER)
- Compare API 기반 제공 (PRIMARY vs SHADOW diff 집계)

### 스키마 (테이블 4 + 뷰 1)

1. detection_models — 모델 카탈로그 (model_id PK, tier 1~5, category, entry_module/callable, is_enabled)
2. detection_model_dependencies — 모델 간 DAG 엣지 (self-loop 금지, input_key 포함)
3. detection_model_versions — 파라미터 스냅샷 + 라이프사이클 + role
   - status: DRAFT / TESTING / ACTIVE / ARCHIVED
   - role: PRIMARY / SHADOW / CHALLENGER (ACTIVE 일 때만)
   - UNIQUE partial index: model_id 당 PRIMARY×ACTIVE 최대 1건
   - SHADOW/CHALLENGER×ACTIVE 는 N건 허용 (병렬 비교)
   - parent_version_id 로 fork 계보 추적
4. detection_model_run_outputs — 버전별 실행 결과 원시 snapshot
   - PARTITION BY RANGE (cycle_started_at)
   - 초기 2개월(2026-04, 2026-05) 파티션 + 이후 월별 자동생성 TODO (Phase 1-2)
   - input_ref JSONB GIN index — 같은 입력 기준 PRIMARY×SHADOW JOIN 용
5. detection_model_metrics — 사이클 단위 집계 메트릭 (cycle_duration_ms 등)
6. v_detection_model_comparison — PRIMARY×SHADOW 같은 입력으로 JOIN 한 VIEW

### 권한
- auth_perm_tree: 'ai-operations:detection-models' (parent=admin, nav_sort=250)
- ADMIN 5 ops / OPERATOR READ+UPDATE / ANALYST·VIEWER READ

### 후속 (Phase 1-2)
- partition_manager 에 detection_model_run_outputs 월별 자동 생성/만료 로직 추가
- 기본 retention 7일 (SHADOW 대량 누적 대비)

### 검증
- Flyway 자동 적용 (백엔드 재빌드+배포)
- 이후 Python Model Registry 가 이 스키마 위에서 동작
2026-04-20 06:22:25 +09:00
2395ef1613 Merge pull request 'feat(detection): 환적 의심 전용 탐지 페이지 신설 (Phase 0-3)' (#86) from feature/phase0-3-transshipment-detection into develop 2026-04-20 05:52:05 +09:00
a8ce9a4ea9 docs: 릴리즈 노트 업데이트 2026-04-20 05:51:25 +09:00
cbfed23823 feat(detection): 환적 의심 전용 탐지 페이지 신설 (Phase 0-3)
docs/prediction-analysis.md §7 P1 권고의 "UI 미노출 탐지" 해소 중 두 번째.
prediction algorithms/transshipment.py 5단계 필터 파이프라인 결과를 전체 목록·
집계·상세 수준으로 조회하는 READ 전용 대시보드.

### 배경
기존 features/vessel/TransferDetection.tsx 는 선박 상세 수준(특정 MMSI 의 환적
이력)이고, 환적 의심 선박 전체 목록을 보려면 ChinaFishing 의 탭 중 하나를 거쳐야
했다. /api/analysis/transship 엔드포인트는 이미 존재하나 전용 페이지가 없었음.

### 변경
- frontend/src/features/detection/TransshipmentDetection.tsx 신설 (405 라인)
  - PageContainer + PageHeader(ArrowLeftRight) + KPI 5장
    (Total / Transship tier CRITICAL/HIGH/MEDIUM / Risk CRITICAL)
  - DataTable 8컬럼 (analyzedAt / mmsi / pairMmsi / duration / tier / risk / zone)
  - features.transship_tier 읽어 Badge 로 심각도 표시
  - 필터: hours(1/6/12/24/48) / riskLevel / mmsi 검색
  - 상세 패널: 분석 피처 JSON 원본 + 좌표 + transship_score
  - 기존 analysisApi.getTransshipSuspects 재사용 — backend 변경 없음
- index.ts + componentRegistry.ts 등록
- detection.json (ko/en) transshipment.* 네임스페이스 추가 (각 44키)
- common.json (ko/en) nav.transshipment 추가
- V033__menu_transshipment_detection.sql
  - auth_perm_tree(detection:transshipment, nav_sort=910)
  - ADMIN 5 ops + OPERATOR/ANALYST/FIELD/VIEWER READ

### 권한 주의
/api/analysis/transship 의 @RequirePermission 은 현재 detection:dark-vessel.
이 메뉴 READ 만으로는 API 호출 불가. 현행 운영자 역할(OPERATOR/ANALYST/FIELD)
은 dark-vessel READ 도 보유하므로 실용 동작.
향후 VesselAnalysisController.listTransshipSuspects 의 @RequirePermission 을
detection:transshipment 로 교체하는 권한 일관화는 별도 MR (후속).

### 검증
- npx tsc --noEmit 통과
- pre-commit tsc + ESLint 통과 예정
- Flyway V033 자동 적용 (백엔드 재배포 필요)
2026-04-20 05:51:06 +09:00
f2d145c9a2 Merge pull request 'feat(detection): 불법 조업 이벤트 전용 페이지 신설 (Phase 0-2)' (#85) from feature/phase0-2-illegal-fishing-pattern into develop 2026-04-20 05:47:17 +09:00
214f063f1e docs: 릴리즈 노트 업데이트 2026-04-20 05:46:34 +09:00
e49ab0f4e8 feat(detection): 불법 조업 이벤트 전용 페이지 신설 (Phase 0-2)
docs/prediction-analysis.md §7 P1 권고의 "UI 미노출 탐지" 해소. event_generator
가 생산하는 카테고리 중 불법 조업 관련 3종을 READ 전용 대시보드로 통합.

대상 카테고리:
- GEAR_ILLEGAL   — G-01 수역·어구 / G-05 고정어구 drift / G-06 쌍끌이
- EEZ_INTRUSION  — 영해 침범 / 접속수역 + 고위험
- ZONE_DEPARTURE — 특정수역 진입 (risk ≥ 40)

### 변경
- frontend/src/services/illegalFishingPatternApi.ts 신설
  - 기존 /api/events 를 category 다중 병렬 조회 후 머지 (backend 변경 없음)
  - category '' 이면 3 카테고리 통합, 지정 시 단일 카테고리만
  - size 기본 200 × 3 categories = 최대 600건, occurredAt desc 정렬
  - byCategory / byLevel 집계 포함
- frontend/src/features/detection/IllegalFishingPattern.tsx 신설 (391 라인)
  - PageContainer + PageHeader(Ban 아이콘) + Section + KPI 5장 + 카테고리별 3장
  - DataTable (occurredAt/level/category/title/mmsi/zone/status 7컬럼)
  - 필터: category / level / mmsi (최근 DEFAULT_SIZE 건 범위)
  - 상세 패널: JSON features 포함, EventList 로 네비게이션 링크
  - design-system SSOT 준수: Badge intent, Select aria-label, text-* 시맨틱 토큰
- index.ts + componentRegistry.ts export/lazy 등록
- detection.json (ko/en) illegalPattern.* 네임스페이스 추가 (각 60키)
- common.json (ko/en) nav.illegalFishing 추가
- V032__menu_illegal_fishing_pattern.sql
  - auth_perm_tree 엔트리 (rsrc_cd=detection:illegal-fishing, nav_sort=920)
  - ADMIN 5 ops + OPERATOR/ANALYST/FIELD/VIEWER READ
  - READ 전용 페이지 (처리 액션은 EventList 경유)

### 검증
- npx tsc --noEmit 통과 (0 에러)
- 백엔드 변경 없음 (기존 /api/events category 필터 재사용)
- Flyway V032 자동 적용 (백엔드 재배포 필요)
2026-04-20 05:46:13 +09:00
2e674ccc5b Merge pull request 'fix(db): candidate_source 컬럼 VARCHAR(30)→(100) 확장 (V031)' (#84) from fix/candidate-source-length into develop 2026-04-17 11:53:08 +09:00
2b25dc1c92 docs: 릴리즈 노트 업데이트 2026-04-17 11:52:08 +09:00
0f4a9cb7d6 fix(db): candidate_source 컬럼 VARCHAR(30)→(100) 확장 (V031)
gear_group_parent_candidate_snapshots.candidate_source 의 VARCHAR(30) 제약
때문에 prediction gear_correlation 스테이지가 매 사이클 실패하던 문제 해소.

원인:
- prediction/algorithms/gear_parent_inference.py:875 의
  candidate_source = ','.join(sorted(meta['sources']))
  가 복수 source 라벨 (CORRELATION/EPISODE/LABEL/LINEAGE/MATCH) 을 쉼표 join
  하며 최대 약 39자. VARCHAR(30) 초과 시 psycopg2.errors.StringDataRightTruncation
  을 유발해 _insert_candidate_snapshots 전체 ROLLBACK.

발견 경위:
- Phase 0-1 (PR #83) 의 stage_runner + logger.exception 전환 후 journal 에
  찍힌 풀 스택트레이스로 드러남. 기존에는 logger.warning 한 줄 ("gear
  correlation failed: ...") 만 남아 원인 특정 불가.

영향 범위:
- 백엔드 JPA 엔티티 미참조 → 재빌드·재배포 불필요
- Flyway 자동 적용 (백엔드 기동 시)
- prediction 재기동만 필요 (기존 코드 그대로, 이제 INSERT 성공 기대)

검증:
- 재배포 후 journalctl 에서 'gear correlation failed' 로그 사라짐 확인
- kcg.gear_group_parent_candidate_snapshots 에 최근 15분 건수 증가 확인
2026-04-17 11:51:35 +09:00
3e29bc9995 Merge pull request 'refactor(prediction): 사이클 스테이지 에러 경계 도입 (Phase 0-1)' (#83) from feature/phase0-cycle-error-boundary into develop 2026-04-17 11:30:17 +09:00
a32d09f75a docs: 릴리즈 노트 업데이트 2026-04-17 11:28:58 +09:00
197da13826 refactor(prediction): 사이클 스테이지 에러 경계 도입 (Phase 0-1)
docs/prediction-analysis.md P1 권고 반영. 5분 사이클의 각 스테이지를
한 try/except 로 뭉친 기존 구조를 스테이지 단위로 분리해 실패 지점을
명시적으로 특정하고 부분 실패 시에도 후속 스테이지가 계속 돌아가도록 개선.

- prediction/pipeline/stage_runner.py 신설
  - run_stage(name, fn, *args, required=False, **kwargs) 유틸
  - required=True 면 예외 re-raise (상위 사이클 try/except 가 잡도록)
  - required=False 면 logger.exception 으로 stacktrace 보존 + None 반환
  - 지속시간 로깅 포함

- prediction/scheduler.py run_analysis_cycle() 수정
  - 출력 단계 6모듈을 각각 run_stage() 로 분리:
    violation_classifier / event_generator / kpi_writer /
    stats_aggregate_hourly / stats_aggregate_daily / alert_dispatcher
  - upsert_results / cleanup_old 도 run_stage 로 래핑 (upsert 는 required=True)
  - 내부 try/except 의 logger.warning → logger.exception 으로 업그레이드
    (fetch_dark_history, gear collision event promotion, group polygon,
     gear correlation, pair detection, chat cache)
  - 스테이지 실패 시 journalctl -u kcg-ai-prediction 에서 stacktrace 로
    원인 바로 특정 가능 (기존은 "failed: X" 한 줄만 남아 디버깅 불가)

검증:
- python3 -c "import ast; ast.parse(...)" scheduler.py / stage_runner.py 통과
- run_stage smoke test (정상/실패 흡수/required 재raise 3가지) 통과

범위 밖 (후속):
- Phase 0-2 ILLEGAL_FISHING_PATTERN 전용 페이지 (다음 MR)
- Phase 0-3 Transshipment 전용 페이지 (다음 MR)
2026-04-17 11:28:30 +09:00
bae2dbde08 Merge pull request 'docs: prediction-analysis 신규 + 루트/SFR 문서 drift 해소' (#82) from feature/docs-refresh-2026-04-17 into develop 2026-04-17 11:22:41 +09:00
451f38036a docs: 릴리즈 노트 업데이트 2026-04-17 11:21:56 +09:00
b37e18d952 docs: prediction-analysis 신규 + 루트/SFR 문서 drift 해소
- docs/prediction-analysis.md 신설 — opus 4.7 독립 리뷰 기반 prediction 구조/방향 심층 분석
  (9개 섹션: 아키텍처·5분 사이클·17 알고리즘·4대 도메인 커버리지·6축 구조 평가·개선 제안 P1~P4·임계값 전수표)
- AGENTS.md / README.md — V001~V016→V030, Python 3.9→3.11+, 14→17 알고리즘 모듈
- docs/architecture.md — /gear-collision 라우트 추가 (26→27 보호 경로)
- docs/sfr-traceability.md — V029→V030, 48→51 테이블, SFR-10 에 GEAR_IDENTITY_COLLISION 추가
- docs/sfr-user-guide.md — 어구 정체성 충돌 페이지 섹션 신설
- docs/system-flow-guide.md — 노드 수 102→115, V030 manifest 미반영 경고
- backend/README.md — "Phase 2 예정" 상태 → 실제 운영 구성 + PR #79 hotfix 요구사항 전면 재작성
2026-04-17 11:20:53 +09:00
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
292개의 변경된 파일30520개의 추가작업 그리고 5134개의 파일을 삭제

2
.gitignore vendored
파일 보기

@ -5,6 +5,7 @@ backend/target/
backend/build/
# === Python (prediction) ===
.venv/
prediction/.venv/
prediction/__pycache__/
prediction/**/__pycache__/
@ -54,6 +55,7 @@ frontend/.vite/
# === 대용량/참고 문서 ===
*.hwpx
*.docx
# === Claude Code ===
!.claude/

248
AGENTS.md Normal file
파일 보기

@ -0,0 +1,248 @@
# KCG AI Monitoring (모노레포)
해양경찰청 AI 기반 불법어선 탐지 및 단속 지원 플랫폼
## 모노레포 구조
```
kcg-ai-monitoring/
├── frontend/ # React 19 + TypeScript + Vite (UI)
├── backend/ # Spring Boot 3.x + Java 21 (인증/권한/감사 + 분석 API)
├── prediction/ # Python 3.11+ + FastAPI (AIS 분석 엔진, 5분 주기)
├── database/ # PostgreSQL 마이그레이션 참조용 README (실제 Flyway 파일은 backend/src/main/resources/db/migration/ V001~V030, 51 테이블)
│ └── migration/
├── deploy/ # 배포 가이드 + 서버 설정 문서
├── docs/ # 프로젝트 문서 (SFR, 아키텍처)
├── .gitea/ # Gitea Actions CI/CD (프론트 자동배포)
├── .Codex/ # Codex 워크플로우
├── .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
- 17개 알고리즘 모듈 (다크베셀, 스푸핑, 환적, 어구 상관·부모·정체성 충돌, 쌍끌이, 위험도, 어선 분류 등)
- 7단계 분류 파이프라인 (전처리→행동→리샘플→특징→분류→클러스터→계절)
- AIS 원본: SNPDB (5분 증분), 결과: kcgaidb (직접 write)
- prediction과 backend는 DB만 공유 (HTTP 호출 X, 단 실시간 상태 조회용 FastAPI 프록시 `/api/prediction/*` 예외)
### 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 단위 개별 제어.
상세는 `.Codex/plans/vast-tinkering-knuth.md` 참조.
## 팀 컨벤션
- 팀 규칙: `.Codex/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 버전이 결정되면, Codex는 이어서 다음 작업을 **자동으로** 수행한다 (`/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'`

173
CLAUDE.md
파일 보기

@ -2,6 +2,45 @@
해양경찰청 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>` 필수
위반 시 리뷰 단계에서 반려 대상. 신규 페이지는 하단의 **"페이지 작성 표준 템플릿"** 을 시작점으로 사용한다.
---
## 모노레포 구조
```
@ -9,7 +48,7 @@ kcg-ai-monitoring/
├── frontend/ # React 19 + TypeScript + Vite (UI)
├── backend/ # Spring Boot 3.x + Java 21 (인증/권한/감사 + 분석 API)
├── prediction/ # Python 3.9 + FastAPI (AIS 분석 엔진, 5분 주기)
├── database/ # PostgreSQL 마이그레이션 (Flyway V001~V013)
├── database/ # PostgreSQL 마이그레이션 (Flyway V001~V016, 48 테이블)
│ └── migration/
├── deploy/ # 배포 가이드 + 서버 설정 문서
├── docs/ # 프로젝트 문서 (SFR, 아키텍처)
@ -24,14 +63,14 @@ kcg-ai-monitoring/
```
[Frontend Vite :5173] ──→ [Backend Spring :8080] ──→ [PostgreSQL kcgaidb]
↑ write
[Prediction FastAPI :8001] ──────┘ (5분 주기 분석 결과 저장)
↑ read ↑ read
[SNPDB PostgreSQL] (AIS 원본) [Iran Backend] (레거시 프록시, 선택)
[Prediction FastAPI :18092] ─────┘ (5분 주기 분석 결과 저장)
↑ read
[SNPDB PostgreSQL] (AIS 원본)
```
- **자체 백엔드**: 인증/권한/감사로그/관리자 + 운영자 의사결정 (확정/제외/학습)
- **iran 백엔드 프록시**: 분석 결과 read-only 참조 (vessel_analysis, group_polygons, correlations)
- **신규 DB (kcgaidb)**: 자체 생산 데이터만 저장, prediction 분석 테이블은 미복사
- **자체 백엔드**: 인증/권한/감사로그/관리자 + 운영자 의사결정 (확정/제외/학습) + prediction 분석 결과 조회 API (`/api/analysis/*`)
- **Prediction**: AIS → 분석 결과를 kcgaidb 에 직접 write (백엔드 호출 없음)
- **DB 공유 아키텍처**: 백엔드와 prediction 은 HTTP 호출 없이 kcgaidb 를 통해서만 연동
## 명령어
@ -101,6 +140,126 @@ make format # 프론트 prettier
- 커밋: 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와 별개)

파일 보기

@ -51,6 +51,7 @@ src/
| [docs/page-workflow.md](docs/page-workflow.md) | 31개 페이지 역할 + 4개 업무 파이프라인 |
| [docs/data-sharing-analysis.md](docs/data-sharing-analysis.md) | 데이터 공유 분석 + mock 통합 결과 |
| [docs/next-refactoring.md](docs/next-refactoring.md) | 다음 단계 TODO (API 연동, 실시간, 코드 스플리팅) |
| [docs/prediction-analysis.md](docs/prediction-analysis.md) | Prediction 모듈 구조/방향 심층 분석 (2026-04-17, opus 4.7 독립 리뷰) |
## SFR 요구사항 대응 현황
@ -67,7 +68,7 @@ src/
| SFR-07 | 단일함정 순찰경로 | `/patrol-route` | UI 완료 |
| SFR-08 | 다함정 경로최적화 | `/fleet-optimization` | UI 완료 |
| SFR-09 | Dark Vessel 탐지 | `/dark-vessel` | UI 완료 |
| SFR-10 | 어구 탐지 | `/gear-detection` | UI 완료 |
| SFR-10 | 어구 탐지 | `/gear-detection`, `/gear-collision`(V030) | UI 완료 |
| SFR-11 | 단속·탐지 이력 | `/enforcement-history` | UI 완료 |
| SFR-12 | 모니터링 대시보드 | `/dashboard`, `/monitoring` | UI 완료 |
| SFR-13 | 통계·성과 분석 | `/statistics` | UI 완료 |

파일 보기

@ -1,18 +1,88 @@
# Backend (Spring Boot)
Phase 2에서 초기화 예정.
운영 배포 중 — rocky-211 `:18080` (`kcg-ai-backend` systemd).
## 계획된 구성
- Spring Boot 3.x + Java 21
- PostgreSQL + Flyway
- Spring Security + JWT
- Caffeine 캐시
- 트리 기반 RBAC 권한 체계 (wing 패턴)
## 구성
- **Runtime**: Spring Boot 3.5.7 + Java 21
- **DB**: PostgreSQL (kcgaidb) + Flyway V001~V030 (51 테이블)
- **Auth**: Spring Security + JWT 쿠키 + BCrypt
- **Cache**: Caffeine (권한 트리 10분 TTL)
- **Permission**: 트리 기반 RBAC (47 리소스 × 5 operation)
- **HTTP client**: `RestClient` + 명시적 `@Bean` 주입 (`predictionRestClient`, `signalBatchRestClient`)
## 책임
- 자체 인증/권한/감사로그
- 운영자 의사결정 (모선 확정/제외/학습)
- iran 백엔드 분석 데이터 프록시
- 관리자 화면 API
상세 설계: `.claude/plans/vast-tinkering-knuth.md`
- 자체 인증/권한/감사 로그 + 관리자 API
- 운영자 의사결정 (모선 확정·제외·학습, 어구 정체성 충돌 분류)
- prediction 분석 결과 조회 (`/api/analysis/*`) + 이벤트 허브 (`/api/events`, `/api/alerts`)
- prediction 실시간 상태 프록시 (`/api/prediction/*`)
## 빌드 · 배포
```bash
# 로컬 실행
./mvnw spring-boot:run
# 프로덕션 빌드
./mvnw clean package -DskipTests
# → target/kcg-ai-backend-*.jar
# 운영 배포 (수동)
scp target/kcg-ai-backend-*.jar rocky-211:/opt/kcg-ai-backend/
ssh rocky-211 "sudo systemctl restart kcg-ai-backend"
```
## 필수 컴파일 설정 (PR #79 hotfix)
Spring 6.1 의 parameter-level `@Qualifier` 주입이 동작하려면 두 가지가 **필수**:
1. **`pom.xml`** — `maven-compiler-plugin``default-compile``default-testCompile` 양쪽 execution 에
`<parameters>true</parameters>` 설정. 파라미터 이름을 바이트코드에 보존해야 `@Qualifier` resolve 가 가능.
2. **`src/main/java/lombok.config`** — `lombok.copyableAnnotations += org.springframework.beans.factory.annotation.Qualifier`
설정. Lombok `@RequiredArgsConstructor` 가 필드의 `@Qualifier` 를 생성자 파라미터에 복사해야 Spring 이 인식.
둘 중 하나라도 누락되면 `PredictionProxyController` 같은 다중 `RestClient` bean 주입 컨트롤러가 기동 시점에
`NoUniqueBeanDefinitionException` 으로 크래시 루프에 빠진다. 로컬에서 `./mvnw spring-boot:run` 실패는
운영 restart 시 동일하게 재현되므로 **MR 범위 밖이어도 우선 해결**.
## 주요 패키지 구조
```
src/main/java/gc/mda/kcg/
├── config/ # RestClientConfig, SecurityConfig, OpenApi, CorsConfig, CaffeineConfig 등
├── auth/ # LoginController, JWT, 비밀번호 정책
├── permission/ # 트리 RBAC, @RequirePermission AOP, 캐시
├── audit/ # @Auditable AOP, AuditLogService, AccessLogFilter
├── domain/
│ ├── analysis/ # VesselAnalysisController, GearCollisionController (V030), PredictionProxyController 등
│ ├── fleet/ # ParentInferenceWorkflowController, FleetService
│ ├── event/ # EventController, AlertController
│ ├── enforcement/ # EnforcementController, EnforcementPlanController
│ ├── master/ # MasterDataController (CodeMaster, GearTypeMaster, VesselPermit 등)
│ ├── admin/ # AdminLogController, AdminStatsController, UserManagementController
│ └── stats/ # StatsController (KPI 집계)
└── Application.java
```
모든 컨트롤러는 `controller → service → repository` 계층을 준수하며, 쓰기 액션은
`@RequirePermission` + `@Auditable` 로 권한·감사 일관 적용.
## Flyway 마이그레이션
- 경로: [src/main/resources/db/migration/V001__*.sql ~ V030__gear_identity_collision.sql](src/main/resources/db/migration/)
- 최신: **V030** (2026-04-17) — `gear_identity_collisions` 테이블 + `detection:gear-collision` 권한 트리
- Flyway 자동 적용: Spring Boot 기동 시점
## 디렉토리 밖 의존성
- **prediction → kcgaidb**: prediction 이 직접 write. backend 는 HTTP 호출 없이 DB 조회로만 연동
- **signal-batch**: `http://192.168.1.18:18090/signal-batch` (정적정보 보강, `signalBatchRestClient` 주입)
- **prediction (FastAPI)**: `http://redis-211:18092` (실시간 상태/채팅 프록시 전용)
## 운영 체크리스트
1. 빌드 성공 → local `./mvnw spring-boot:run` 기동 확인 → curl `/api/auth/me` 200 확인
2. scp 배포 → `systemctl restart kcg-ai-backend``journalctl -u kcg-ai-backend -n 100`
3. 응답 확인: `curl -k https://kcg-ai-monitoring.gc-si.dev/api/analysis/vessels?hours=1`
4. Flyway 에러 시: `backend.application` 로그에서 `Migration ... failed` 확인

파일 보기

@ -142,6 +142,7 @@
<goal>compile</goal>
</goals>
<configuration>
<parameters>true</parameters>
<annotationProcessorPaths>
<path>
<groupId>org.springframework.boot</groupId>
@ -161,6 +162,7 @@
<goal>testCompile</goal>
</goals>
<configuration>
<parameters>true</parameters>
<annotationProcessorPaths>
<path>
<groupId>org.projectlombok</groupId>

파일 보기

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

파일 보기

@ -4,6 +4,7 @@ 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;
@ -25,6 +26,7 @@ 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,
@ -95,7 +97,8 @@ public class AuthController {
u.getUserSttsCd(),
u.getAuthProvider(),
info.roles(),
info.permissions()
info.permissions(),
menuConfigService.getActiveMenuConfig()
);
}

파일 보기

@ -1,5 +1,7 @@
package gc.mda.kcg.auth.dto;
import gc.mda.kcg.menu.MenuConfigDto;
import java.util.List;
import java.util.Map;
@ -12,5 +14,6 @@ public record UserInfoResponse(
String status,
String authProvider,
List<String> roles,
Map<String, List<String>> permissions
Map<String, List<String>> permissions,
List<MenuConfigDto> menuConfig
) {}

파일 보기

@ -12,7 +12,7 @@ import org.springframework.context.annotation.Configuration;
public class AppProperties {
private Prediction prediction = new Prediction();
private IranBackend iranBackend = new IranBackend();
private SignalBatch signalBatch = new SignalBatch();
private Cors cors = new Cors();
private Jwt jwt = new Jwt();
@ -22,7 +22,7 @@ public class AppProperties {
}
@Getter @Setter
public static class IranBackend {
public static class SignalBatch {
private String baseUrl;
}

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

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

파일 보기

@ -32,9 +32,10 @@ public class EnforcementController {
@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, pageable);
return service.listRecords(violationType, vesselMmsi, pageable);
}
/**

파일 보기

@ -1,5 +1,6 @@
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;
@ -32,7 +33,10 @@ public class EnforcementService {
// 단속 이력
// ========================================================================
public Page<EnforcementRecord> listRecords(String violationType, Pageable pageable) {
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);
}
@ -45,6 +49,7 @@ public class EnforcementService {
}
@Transactional
@Auditable(action = "ENFORCEMENT_CREATE", resourceType = "ENFORCEMENT")
public EnforcementRecord createRecord(CreateRecordRequest req) {
EnforcementRecord record = EnforcementRecord.builder()
.enfUid(generateEnfUid())
@ -84,6 +89,7 @@ public class EnforcementService {
}
@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());
@ -104,6 +110,7 @@ public class EnforcementService {
}
@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())

파일 보기

@ -8,4 +8,5 @@ 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);
}

파일 보기

@ -2,12 +2,9 @@ package gc.mda.kcg.domain.event;
import gc.mda.kcg.permission.annotation.RequirePermission;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* 알림 조회 API.
* 예측 이벤트에 대해 발송된 알림(SMS, 푸시 ) 이력을 제공.
@ -17,7 +14,7 @@ import java.util.List;
@RequiredArgsConstructor
public class AlertController {
private final PredictionAlertRepository alertRepository;
private final AlertService alertService;
/**
* 알림 목록 조회 (페이징). eventId 파라미터로 특정 이벤트의 알림만 필터 가능.
@ -30,10 +27,8 @@ public class AlertController {
@RequestParam(defaultValue = "20") int size
) {
if (eventId != null) {
return alertRepository.findByEventIdOrderBySentAtDesc(eventId);
return alertService.findByEventId(eventId);
}
return alertRepository.findAllByOrderBySentAtDesc(
PageRequest.of(page, size)
);
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);
}
}

파일 보기

@ -5,6 +5,8 @@ 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;
@ -44,6 +46,7 @@ public class PredictionAlert {
@Column(columnDefinition = "jsonb")
private Map<String, Object> metadata;
@JsonIgnore
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "event_id", insertable = false, updatable = false)
private PredictionEvent event;

파일 보기

@ -7,6 +7,7 @@ import org.hibernate.type.SqlTypes;
import java.math.BigDecimal;
import java.time.OffsetDateTime;
import java.util.Map;
import java.util.UUID;
/**
@ -93,6 +94,10 @@ public class PredictionEvent {
@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;

파일 보기

@ -16,8 +16,8 @@ import java.time.OffsetDateTime;
import java.util.List;
/**
* 모선 워크플로우 핵심 서비스 (HYBRID).
* - 후보 데이터: iran 백엔드 API 호출 (현재 stub)
* 모선 워크플로우 핵심 서비스.
* - 후보 데이터: prediction이 kcgaidb 저장한 분석 결과를 참조
* - 운영자 결정: 자체 DB (gear_group_parent_resolution )
*
* 모든 쓰기 액션은 @Auditable로 감사로그 자동 기록.

파일 보기

@ -10,7 +10,7 @@ import java.util.UUID;
/**
* 모선 확정 결과 (운영자 의사결정).
* iran 백엔드의 후보 데이터(prediction이 생성) 별도로 운영자 결정만 자체 DB에 저장.
* prediction이 생성한 후보 데이터 별도로 운영자 결정만 자체 DB에 저장.
*/
@Entity
@Table(name = "gear_group_parent_resolution", schema = "kcg",
@ -34,6 +34,27 @@ public class ParentResolution {
@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;

파일 보기

@ -27,7 +27,7 @@ public class StatsController {
* 실시간 KPI 전체 목록 조회
*/
@GetMapping("/kpi")
@RequirePermission(resource = "statistics", operation = "READ")
@RequirePermission(resource = "statistics:statistics", operation = "READ")
public List<PredictionKpi> getKpi() {
return kpiRepository.findAll();
}
@ -38,7 +38,7 @@ public class StatsController {
* @param to 종료 (: 2026-04)
*/
@GetMapping("/monthly")
@RequirePermission(resource = "statistics", operation = "READ")
@RequirePermission(resource = "statistics:statistics", operation = "READ")
public List<PredictionStatsMonthly> getMonthly(
@RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate from,
@RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate to
@ -52,7 +52,7 @@ public class StatsController {
* @param to 종료 날짜 (: 2026-04-07)
*/
@GetMapping("/daily")
@RequirePermission(resource = "statistics", operation = "READ")
@RequirePermission(resource = "statistics:statistics", operation = "READ")
public List<PredictionStatsDaily> getDaily(
@RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate from,
@RequestParam @DateTimeFormat(pattern = "yyyy-MM-dd") LocalDate to
@ -65,7 +65,7 @@ public class StatsController {
* @param hours 조회 시간 범위 (기본 24시간)
*/
@GetMapping("/hourly")
@RequirePermission(resource = "statistics", operation = "READ")
@RequirePermission(resource = "statistics:statistics", operation = "READ")
public List<PredictionStatsHourly> getHourly(
@RequestParam(defaultValue = "24") int hours
) {

파일 보기

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

파일 보기

@ -0,0 +1,115 @@
package gc.mda.kcg.master;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.server.ResponseStatusException;
import java.util.List;
/**
* 마스터 데이터(코드/어구/함정/선박허가) 조회 관리 서비스.
*/
@Service
@RequiredArgsConstructor
@Transactional(readOnly = true)
public class MasterDataService {
private final CodeMasterRepository codeMasterRepository;
private final GearTypeRepository gearTypeRepository;
private final PatrolShipRepository patrolShipRepository;
private final VesselPermitRepository vesselPermitRepository;
// 코드 마스터
public List<CodeMaster> listCodes(String groupCode) {
return codeMasterRepository.findByGroupCodeAndIsActiveTrueOrderBySortOrder(groupCode);
}
public List<CodeMaster> listChildren(String parentId) {
return codeMasterRepository.findByParentIdOrderBySortOrder(parentId);
}
// 어구 유형
public List<GearType> listGearTypes() {
return gearTypeRepository.findByIsActiveTrueOrderByDisplayOrder();
}
public GearType getGearType(String gearCode) {
return gearTypeRepository.findById(gearCode)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND,
"어구 유형을 찾을 수 없습니다: " + gearCode));
}
@Transactional
public GearType createGearType(GearType gearType) {
if (gearTypeRepository.existsById(gearType.getGearCode())) {
throw new ResponseStatusException(HttpStatus.CONFLICT,
"이미 존재하는 어구 코드입니다: " + gearType.getGearCode());
}
return gearTypeRepository.save(gearType);
}
@Transactional
public GearType updateGearType(String gearCode, GearType gearType) {
if (!gearTypeRepository.existsById(gearCode)) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND,
"어구 유형을 찾을 수 없습니다: " + gearCode);
}
gearType.setGearCode(gearCode);
return gearTypeRepository.save(gearType);
}
// 함정
public List<PatrolShip> listPatrolShips() {
return patrolShipRepository.findByIsActiveTrueOrderByShipCode();
}
@Transactional
public PatrolShip updatePatrolShipStatus(Long id, PatrolShipStatusCommand command) {
PatrolShip ship = patrolShipRepository.findById(id)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND,
"함정을 찾을 수 없습니다: " + id));
if (command.status() != null) ship.setCurrentStatus(command.status());
if (command.lat() != null) ship.setCurrentLat(command.lat());
if (command.lon() != null) ship.setCurrentLon(command.lon());
if (command.zoneCode() != null) ship.setCurrentZoneCode(command.zoneCode());
if (command.fuelPct() != null) ship.setFuelPct(command.fuelPct());
return patrolShipRepository.save(ship);
}
// 선박 허가
public Page<VesselPermit> listVesselPermits(String flag, String permitStatus, Pageable pageable) {
if (flag != null) {
return vesselPermitRepository.findByFlagCountry(flag, pageable);
}
if (permitStatus != null) {
return vesselPermitRepository.findByPermitStatus(permitStatus, pageable);
}
return vesselPermitRepository.findAll(pageable);
}
public VesselPermit getVesselPermit(String mmsi) {
return vesselPermitRepository.findByMmsi(mmsi)
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND,
"선박 허가 정보를 찾을 수 없습니다: " + mmsi));
}
// Command DTO
public record PatrolShipStatusCommand(
String status,
Double lat,
Double lon,
String zoneCode,
Integer fuelPct
) {}
}

파일 보기

@ -0,0 +1,21 @@
package gc.mda.kcg.menu;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* 메뉴 설정 API auth_perm_tree 기반.
*/
@RestController
@RequiredArgsConstructor
public class MenuConfigController {
private final MenuConfigService menuConfigService;
@GetMapping("/api/menu-config")
public List<MenuConfigDto> getMenuConfig() {
return menuConfigService.getActiveMenuConfig();
}
}

파일 보기

@ -0,0 +1,19 @@
package gc.mda.kcg.menu;
import java.util.Map;
public record MenuConfigDto(
String menuCd,
String parentMenuCd,
String menuType,
String urlPath,
String rsrcCd,
String componentKey,
String icon,
String labelKey,
String dividerLabel,
int menuLevel,
int sortOrd,
String useYn,
Map<String, String> labels
) {}

파일 보기

@ -0,0 +1,115 @@
package gc.mda.kcg.menu;
import gc.mda.kcg.permission.PermTree;
import gc.mda.kcg.permission.PermTreeRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.*;
/**
* auth_perm_tree를 메뉴 SSOT로 사용하여 MenuConfigDto 목록을 생성.
* 디바이더는 nav_sub_group 변경 시점에 동적 삽입.
*/
@Service
@RequiredArgsConstructor
public class MenuConfigService {
private final PermTreeRepository permTreeRepository;
// 메뉴 그룹으로 동작하는 Level-0 노드 rsrc_cd
private static final Set<String> GROUP_NODES = Set.of(
"field-ops", "parent-inference-workflow", "admin"
);
@Cacheable("menuConfig")
@Transactional(readOnly = true)
public List<MenuConfigDto> getActiveMenuConfig() {
List<PermTree> all = permTreeRepository.findByUseYn("Y");
List<MenuConfigDto> result = new ArrayList<>();
// 1) 최상위 ITEM (nav_sort > 0, nav_group IS NULL, url_path IS NOT NULL)
List<PermTree> topItems = all.stream()
.filter(n -> n.getNavSort() > 0 && n.getNavGroup() == null && n.getUrlPath() != null)
.sorted(Comparator.comparingInt(PermTree::getNavSort))
.toList();
for (PermTree n : topItems) {
result.add(toDto(n, null, "ITEM", 0));
}
// 2) 그룹 헤더 + 자식 (nav_sort > 0 GROUP_NODES)
List<PermTree> groups = all.stream()
.filter(n -> GROUP_NODES.contains(n.getRsrcCd()) && n.getNavSort() > 0)
.sorted(Comparator.comparingInt(PermTree::getNavSort))
.toList();
for (PermTree g : groups) {
result.add(toDto(g, null, "GROUP", 0));
// 그룹의 자식들
List<PermTree> children = all.stream()
.filter(n -> g.getRsrcCd().equals(n.getNavGroup()) && n.getUrlPath() != null)
.sorted(Comparator.comparingInt(PermTree::getNavSort))
.toList();
// 디바이더 삽입: nav_sub_group 변경 시점마다
String currentSubGroup = null;
int dividerSeq = 0;
for (PermTree c : children) {
String sub = c.getNavSubGroup();
if (sub != null && !sub.equals(currentSubGroup)) {
currentSubGroup = sub;
dividerSeq++;
result.add(new MenuConfigDto(
g.getRsrcCd() + ".div-" + dividerSeq,
g.getRsrcCd(), "DIVIDER",
null, null, null, null, null,
sub, 1, c.getNavSort() - 1, "Y",
Map.of()
));
}
result.add(toDto(c, g.getRsrcCd(), "ITEM", 1));
}
}
// 3) 숨김 라우트 (nav_sort = 0, url_path IS NOT NULL)
List<PermTree> hidden = all.stream()
.filter(n -> n.getNavSort() == 0 && n.getUrlPath() != null && !GROUP_NODES.contains(n.getRsrcCd()))
.toList();
for (PermTree h : hidden) {
result.add(new MenuConfigDto(
h.getRsrcCd(), null, "ITEM",
h.getUrlPath(), h.getRsrcCd(), h.getComponentKey(),
h.getIcon(), h.getLabelKey(), null,
0, 9999, "H",
h.getLabels() != null ? h.getLabels() : Map.of()
));
}
return result;
}
@CacheEvict(value = "menuConfig", allEntries = true)
public void evictCache() {
}
private MenuConfigDto toDto(PermTree n, String parentMenuCd, String menuType, int menuLevel) {
return new MenuConfigDto(
n.getRsrcCd(),
parentMenuCd,
menuType,
n.getUrlPath(),
n.getRsrcCd(),
n.getComponentKey(),
n.getIcon(),
n.getLabelKey(),
n.getNavSubGroup(),
menuLevel,
n.getNavSort(),
n.getUseYn(),
n.getLabels() != null ? n.getLabels() : Map.of()
);
}
}

파일 보기

@ -2,6 +2,8 @@ package gc.mda.kcg.permission;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.annotations.JdbcTypeCode;
import org.hibernate.type.SqlTypes;
import java.time.OffsetDateTime;
@ -39,6 +41,29 @@ public class PermTree {
@Column(name = "use_yn", nullable = false, length = 1)
private String useYn;
// 메뉴 SSOT 컬럼 (V021)
@Column(name = "url_path", length = 200)
private String urlPath;
@Column(name = "label_key", length = 100)
private String labelKey;
@Column(name = "component_key", length = 150)
private String componentKey;
@Column(name = "nav_group", length = 100)
private String navGroup;
@Column(name = "nav_sub_group", length = 100)
private String navSubGroup;
@Column(name = "nav_sort", nullable = false)
private Integer navSort;
@JdbcTypeCode(SqlTypes.JSON)
@Column(name = "labels", columnDefinition = "jsonb")
private java.util.Map<String, String> labels;
@Column(name = "created_at", nullable = false)
private OffsetDateTime createdAt;
@ -53,6 +78,7 @@ public class PermTree {
if (useYn == null) useYn = "Y";
if (sortOrd == null) sortOrd = 0;
if (rsrcLevel == null) rsrcLevel = 0;
if (navSort == null) navSort = 0;
}
@PreUpdate

파일 보기

@ -47,15 +47,16 @@ public class PermTreeController {
List<Role> roles = roleRepository.findAllByOrderByRoleSnAsc();
return roles.stream().<Map<String, Object>>map(r -> {
List<Perm> perms = permRepository.findByRoleSn(r.getRoleSn());
return Map.of(
"roleSn", r.getRoleSn(),
"roleCd", r.getRoleCd(),
"roleNm", r.getRoleNm(),
"roleDc", r.getRoleDc() == null ? "" : r.getRoleDc(),
"dfltYn", r.getDfltYn(),
"builtinYn", r.getBuiltinYn(),
"permissions", perms
);
java.util.Map<String, Object> result = new java.util.LinkedHashMap<>();
result.put("roleSn", r.getRoleSn());
result.put("roleCd", r.getRoleCd());
result.put("roleNm", r.getRoleNm());
result.put("roleDc", r.getRoleDc() == null ? "" : r.getRoleDc());
result.put("colorHex", r.getColorHex());
result.put("dfltYn", r.getDfltYn());
result.put("builtinYn", r.getBuiltinYn());
result.put("permissions", perms);
return result;
}).toList();
}

파일 보기

@ -28,6 +28,10 @@ public class Role {
@Column(name = "role_dc", columnDefinition = "text")
private String roleDc;
/** 역할 UI 표기 색상 (#RRGGBB). NULL 가능 — 프론트가 기본 팔레트 사용. */
@Column(name = "color_hex", length = 7)
private String colorHex;
@Column(name = "dflt_yn", nullable = false, length = 1)
private String dfltYn;

파일 보기

@ -38,6 +38,7 @@ public class RoleManagementService {
.roleCd(req.roleCd().toUpperCase())
.roleNm(req.roleNm())
.roleDc(req.roleDc())
.colorHex(req.colorHex())
.dfltYn("Y".equalsIgnoreCase(req.dfltYn()) ? "Y" : "N")
.builtinYn("N")
.build();
@ -70,6 +71,7 @@ public class RoleManagementService {
}
if (req.roleNm() != null) role.setRoleNm(req.roleNm());
if (req.roleDc() != null) role.setRoleDc(req.roleDc());
if (req.colorHex() != null) role.setColorHex(req.colorHex());
if (req.dfltYn() != null) role.setDfltYn("Y".equalsIgnoreCase(req.dfltYn()) ? "Y" : "N");
return roleRepository.save(role);
}

파일 보기

@ -6,5 +6,6 @@ public record RoleCreateRequest(
@NotBlank String roleCd,
@NotBlank String roleNm,
String roleDc,
String colorHex,
String dfltYn
) {}

파일 보기

@ -3,5 +3,6 @@ package gc.mda.kcg.permission.dto;
public record RoleUpdateRequest(
String roleNm,
String roleDc,
String colorHex,
String dfltYn
) {}

파일 보기

@ -0,0 +1,6 @@
config.stopBubbling = true
# @RequiredArgsConstructor 가 생성하는 constructor parameter 에 필드의 @Qualifier 를 복사한다.
# Spring 6.1+ 의 bean 이름 기반 fallback 은 parameter-level annotation 을 요구하므로,
# 필수 처리하지 않으면 여러 bean 중 모호성이 발생해 기동이 실패한다.
lombok.copyableAnnotations += org.springframework.beans.factory.annotation.Qualifier

파일 보기

@ -33,13 +33,17 @@ spring:
cache:
type: caffeine
cache-names: permissions,users
cache-names: permissions,users,menuConfig
caffeine:
spec: maximumSize=1000,expireAfterWrite=10m
server:
port: 8080
forward-headers-strategy: framework
compression:
enabled: true
min-response-size: 1024
mime-types: application/json,application/xml,text/html,text/plain
management:
endpoints:
@ -60,9 +64,8 @@ logging:
app:
prediction:
base-url: ${PREDICTION_BASE_URL:http://localhost:8001}
iran-backend:
# 운영 환경: https://kcg.gc-si.dev (Spring Boot + Prediction 통합)
base-url: ${IRAN_BACKEND_BASE_URL:https://kcg.gc-si.dev}
signal-batch:
base-url: ${SIGNAL_BATCH_BASE_URL:http://192.168.1.18:18090/signal-batch}
cors:
allowed-origins: ${CORS_ALLOWED_ORIGINS:http://localhost:5173,http://localhost:5174}
jwt:

파일 보기

@ -0,0 +1,18 @@
-- ============================================================================
-- V017: auth_role.color_hex 컬럼 추가
-- ============================================================================
-- 역할별 UI 표기 색상을 DB에서 관리.
-- 프론트엔드는 RoleResponse.colorHex로 받아 ColorPicker 카탈로그에 적용.
-- ----------------------------------------------------------------------------
ALTER TABLE kcg.auth_role
ADD COLUMN IF NOT EXISTS color_hex VARCHAR(7);
COMMENT ON COLUMN kcg.auth_role.color_hex IS '역할 표기 색상 (#RRGGBB hex). NULL이면 프론트 기본 팔레트에서 결정.';
-- 빌트인 5개 역할의 기본 색상 (기존 프론트 ROLE_COLORS 정책과 매칭)
UPDATE kcg.auth_role SET color_hex = '#ef4444' WHERE role_cd = 'ADMIN';
UPDATE kcg.auth_role SET color_hex = '#3b82f6' WHERE role_cd = 'OPERATOR';
UPDATE kcg.auth_role SET color_hex = '#a855f7' WHERE role_cd = 'ANALYST';
UPDATE kcg.auth_role SET color_hex = '#22c55e' WHERE role_cd = 'FIELD';
UPDATE kcg.auth_role SET color_hex = '#eab308' WHERE role_cd = 'VIEWER';

파일 보기

@ -0,0 +1,11 @@
-- ============================================================
-- V018: prediction_events에 features JSONB 컬럼 추가
-- event_generator가 분석 결과의 핵심 특성(dark_tier, transship_score 등)을
-- 이벤트와 함께 저장하여 프론트엔드에서 직접 활용할 수 있도록 한다.
-- ============================================================
ALTER TABLE kcg.prediction_events
ADD COLUMN IF NOT EXISTS features JSONB;
COMMENT ON COLUMN kcg.prediction_events.features IS
'분석 결과 핵심 특성 (dark_tier, dark_suspicion_score, transship_tier, transship_score 등)';

파일 보기

@ -0,0 +1,16 @@
-- ============================================================
-- V019: LLM 운영 페이지 권한 트리 항목 추가
-- PR #22에서 추가된 /llm-ops 페이지에 대응하는 권한 리소스
-- ============================================================
INSERT INTO kcg.auth_perm_tree(rsrc_cd, parent_cd, rsrc_nm, rsrc_level, sort_ord)
VALUES ('ai-operations:llm-ops', 'ai-operations', 'LLM 운영', 1, 35)
ON CONFLICT (rsrc_cd) DO NOTHING;
-- ADMIN 역할에 자동 부여
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
SELECT r.role_sn, 'ai-operations:llm-ops', op.oper_cd, 'Y'
FROM kcg.auth_role r
CROSS JOIN (VALUES ('READ'), ('CREATE'), ('UPDATE'), ('DELETE'), ('EXPORT')) AS op(oper_cd)
WHERE r.role_cd = 'ADMIN'
ON CONFLICT (role_sn, rsrc_cd, oper_cd) DO NOTHING;

파일 보기

@ -0,0 +1,97 @@
-- ============================================================================
-- V020: 메뉴 설정 SSOT 테이블
-- 프론트엔드 사이드바 + 라우팅 + 권한 매핑의 단일 진실 공급원
-- ============================================================================
CREATE TABLE kcg.menu_config (
menu_cd VARCHAR(100) PRIMARY KEY,
parent_menu_cd VARCHAR(100) REFERENCES kcg.menu_config(menu_cd) ON DELETE CASCADE,
menu_type VARCHAR(10) NOT NULL DEFAULT 'ITEM',
url_path VARCHAR(200),
rsrc_cd VARCHAR(100) REFERENCES kcg.auth_perm_tree(rsrc_cd),
component_key VARCHAR(150),
icon VARCHAR(50),
label_key VARCHAR(100),
divider_label VARCHAR(100),
menu_level INT NOT NULL DEFAULT 0,
sort_ord INT NOT NULL DEFAULT 0,
use_yn VARCHAR(1) NOT NULL DEFAULT 'Y',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
COMMENT ON TABLE kcg.menu_config IS '메뉴 레이아웃 SSOT — 사이드바, 라우팅, 경로→권한 매핑 구동';
COMMENT ON COLUMN kcg.menu_config.menu_type IS 'ITEM(네비게이션) | GROUP(접기/펼치기) | DIVIDER(서브그룹 라벨)';
COMMENT ON COLUMN kcg.menu_config.rsrc_cd IS 'auth_perm_tree FK. 권한 체크용. GROUP/DIVIDER는 NULL';
COMMENT ON COLUMN kcg.menu_config.component_key IS '프론트 COMPONENT_REGISTRY 키. GROUP/DIVIDER는 NULL';
COMMENT ON COLUMN kcg.menu_config.use_yn IS 'Y=사이드바+라우트, H=라우트만(숨김), N=비활성';
CREATE INDEX idx_menu_config_parent ON kcg.menu_config(parent_menu_cd);
CREATE INDEX idx_menu_config_sort ON kcg.menu_config(menu_level, sort_ord);
CREATE INDEX idx_menu_config_rsrc ON kcg.menu_config(rsrc_cd);
-- ============================================================================
-- 시드 데이터: 현재 NAV_ENTRIES + Route 정의 기반 35건
-- sort_ord 100 간격 (삽입 여유)
-- ============================================================================
-- ─── Top-level ITEM (13개) ─────────────────────────────────────────────────
INSERT INTO kcg.menu_config(menu_cd, parent_menu_cd, menu_type, url_path, rsrc_cd, component_key, icon, label_key, menu_level, sort_ord) VALUES
('dashboard', NULL, 'ITEM', '/dashboard', 'dashboard', 'features/dashboard/Dashboard', 'LayoutDashboard', 'nav.dashboard', 0, 100),
('monitoring', NULL, 'ITEM', '/monitoring', 'monitoring', 'features/monitoring/MonitoringDashboard', 'Activity', 'nav.monitoring', 0, 200),
('events', NULL, 'ITEM', '/events', 'surveillance:live-map', 'features/surveillance/LiveMapView', 'Radar', 'nav.realtimeEvent', 0, 300),
('map-control', NULL, 'ITEM', '/map-control', 'surveillance:map-control', 'features/surveillance/MapControl', 'Map', 'nav.mapControl', 0, 400),
('risk-map', NULL, 'ITEM', '/risk-map', 'risk-assessment:risk-map', 'features/risk-assessment/RiskMap', 'Layers', 'nav.riskMap', 0, 500),
('enforcement-plan', NULL, 'ITEM', '/enforcement-plan', 'risk-assessment:enforcement-plan', 'features/risk-assessment/EnforcementPlan', 'Shield', 'nav.enforcementPlan', 0, 600),
('dark-vessel', NULL, 'ITEM', '/dark-vessel', 'detection:dark-vessel', 'features/detection/DarkVesselDetection', 'EyeOff', 'nav.darkVessel', 0, 700),
('gear-detection', NULL, 'ITEM', '/gear-detection', 'detection:gear-detection', 'features/detection/GearDetection', 'Anchor', 'nav.gearDetection', 0, 800),
('china-fishing', NULL, 'ITEM', '/china-fishing', 'detection:china-fishing', 'features/detection/ChinaFishing', 'Ship', 'nav.chinaFishing', 0, 900),
('enforcement-history', NULL, 'ITEM', '/enforcement-history', 'enforcement:enforcement-history', 'features/enforcement/EnforcementHistory', 'FileText', 'nav.enforcementHistory', 0, 1000),
('event-list', NULL, 'ITEM', '/event-list', 'enforcement:event-list', 'features/enforcement/EventList', 'List', 'nav.eventList', 0, 1100),
('statistics', NULL, 'ITEM', '/statistics', 'statistics:statistics', 'features/statistics/Statistics', 'BarChart3', 'nav.statistics', 0, 1200),
('reports', NULL, 'ITEM', '/reports', 'statistics:statistics', 'features/statistics/ReportManagement', 'FileText', 'nav.reports', 0, 1300);
-- ─── GROUP: 현장작전 ───────────────────────────────────────────────────────
INSERT INTO kcg.menu_config(menu_cd, parent_menu_cd, menu_type, url_path, rsrc_cd, component_key, icon, label_key, menu_level, sort_ord) VALUES
('field-ops', NULL, 'GROUP', NULL, NULL, NULL, 'Ship', 'group.fieldOps', 0, 1400),
('field-ops.patrol', 'field-ops', 'ITEM', '/patrol-route', 'patrol:patrol-route', 'features/patrol/PatrolRoute', 'Navigation', 'nav.patrolRoute', 1, 100),
('field-ops.fleet', 'field-ops', 'ITEM', '/fleet-optimization', 'patrol:fleet-optimization', 'features/patrol/FleetOptimization', 'Users', 'nav.fleetOptimization', 1, 200),
('field-ops.alert', 'field-ops', 'ITEM', '/ai-alert', 'field-ops:ai-alert', 'features/field-ops/AIAlert', 'Send', 'nav.aiAlert', 1, 300),
('field-ops.mobile', 'field-ops', 'ITEM', '/mobile-service', 'field-ops:mobile-service', 'features/field-ops/MobileService', 'Smartphone', 'nav.mobileService', 1, 400),
('field-ops.ship', 'field-ops', 'ITEM', '/ship-agent', 'field-ops:ship-agent', 'features/field-ops/ShipAgent', 'Monitor', 'nav.shipAgent', 1, 500);
-- ─── GROUP: 모선 워크플로우 ────────────────────────────────────────────────
INSERT INTO kcg.menu_config(menu_cd, parent_menu_cd, menu_type, url_path, rsrc_cd, component_key, icon, label_key, menu_level, sort_ord) VALUES
('parent-inference', NULL, 'GROUP', NULL, NULL, NULL, 'GitBranch', 'group.parentInference', 0, 1500),
('parent-inference.review', 'parent-inference', 'ITEM', '/parent-inference/review', 'parent-inference-workflow:parent-review', 'features/parent-inference/ParentReview', 'CheckSquare', 'nav.parentReview', 1, 100),
('parent-inference.exclusion', 'parent-inference', 'ITEM', '/parent-inference/exclusion', 'parent-inference-workflow:parent-exclusion', 'features/parent-inference/ParentExclusion', 'Ban', 'nav.parentExclusion', 1, 200),
('parent-inference.label', 'parent-inference', 'ITEM', '/parent-inference/label-session', 'parent-inference-workflow:label-session', 'features/parent-inference/LabelSession', 'Tag', 'nav.labelSession', 1, 300);
-- ─── GROUP: 관리자 ─────────────────────────────────────────────────────────
INSERT INTO kcg.menu_config(menu_cd, parent_menu_cd, menu_type, url_path, rsrc_cd, component_key, icon, label_key, divider_label, menu_level, sort_ord) VALUES
('admin-group', NULL, 'GROUP', NULL, NULL, NULL, 'Settings', 'group.admin', NULL, 0, 1600),
-- AI 플랫폼
('admin-group.div-ai', 'admin-group', 'DIVIDER', NULL, NULL, NULL, NULL, NULL, 'AI 플랫폼', 1, 100),
('admin-group.ai-model', 'admin-group', 'ITEM', '/ai-model', 'ai-operations:ai-model', 'features/ai-operations/AIModelManagement', 'Brain', 'nav.aiModel', NULL, 1, 200),
('admin-group.mlops', 'admin-group', 'ITEM', '/mlops', 'ai-operations:mlops', 'features/ai-operations/MLOpsPage', 'Cpu', 'nav.mlops', NULL, 1, 300),
('admin-group.llm-ops', 'admin-group', 'ITEM', '/llm-ops', 'ai-operations:llm-ops', 'features/ai-operations/LLMOpsPage', 'Brain', 'nav.llmOps', NULL, 1, 400),
('admin-group.ai-assistant', 'admin-group', 'ITEM', '/ai-assistant', 'ai-operations:ai-assistant', 'features/ai-operations/AIAssistant', 'MessageSquare', 'nav.aiAssistant', NULL, 1, 500),
-- 시스템 운영
('admin-group.div-sys', 'admin-group', 'DIVIDER', NULL, NULL, NULL, NULL, NULL, '시스템 운영', 1, 600),
('admin-group.system-config', 'admin-group', 'ITEM', '/system-config', 'admin:system-config', 'features/admin/SystemConfig', 'Database', 'nav.systemConfig',NULL, 1, 700),
('admin-group.data-hub', 'admin-group', 'ITEM', '/data-hub', 'admin:system-config', 'features/admin/DataHub', 'Wifi', 'nav.dataHub', NULL, 1, 800),
('admin-group.external', 'admin-group', 'ITEM', '/external-service', 'statistics:external-service', 'features/statistics/ExternalService', 'Globe', 'nav.externalService', NULL, 1, 900),
-- 사용자 관리
('admin-group.div-user', 'admin-group', 'DIVIDER', NULL, NULL, NULL, NULL, NULL, '사용자 관리', 1, 1000),
('admin-group.admin', 'admin-group', 'ITEM', '/admin', 'admin', 'features/admin/AdminPanel', 'Settings', 'nav.admin', NULL, 1, 1100),
('admin-group.access', 'admin-group', 'ITEM', '/access-control', 'admin:permission-management', 'features/admin/AccessControl', 'Fingerprint', 'nav.accessControl', NULL, 1, 1200),
('admin-group.notices', 'admin-group', 'ITEM', '/notices', 'admin', 'features/admin/NoticeManagement', 'Megaphone', 'nav.notices', NULL, 1, 1300),
-- 감사·보안
('admin-group.div-audit', 'admin-group', 'DIVIDER', NULL, NULL, NULL, NULL, NULL, '감사·보안', 1, 1400),
('admin-group.audit-logs', 'admin-group', 'ITEM', '/admin/audit-logs', 'admin:audit-logs', 'features/admin/AuditLogs', 'ScrollText', 'nav.auditLogs', NULL, 1, 1500),
('admin-group.access-logs', 'admin-group', 'ITEM', '/admin/access-logs', 'admin:access-logs', 'features/admin/AccessLogs', 'History', 'nav.accessLogs', NULL, 1, 1600),
('admin-group.login-history', 'admin-group', 'ITEM', '/admin/login-history', 'admin:login-history', 'features/admin/LoginHistoryView', 'KeyRound', 'nav.loginHistory',NULL, 1, 1700);
-- ─── 숨김 라우트 (사이드바 미표시, 라우팅만) ───────────────────────────────
INSERT INTO kcg.menu_config(menu_cd, parent_menu_cd, menu_type, url_path, rsrc_cd, component_key, icon, label_key, menu_level, sort_ord, use_yn) VALUES
('vessel-detail', NULL, 'ITEM', '/vessel/:id', 'vessel:vessel-detail', 'features/vessel/VesselDetail', NULL, NULL, 0, 9999, 'H');

파일 보기

@ -0,0 +1,115 @@
-- ============================================================================
-- V021: auth_perm_tree를 메뉴 SSOT로 확장 + menu_config 테이블 폐기
-- 메뉴·권한·감사가 동일 레코드를 참조하여 완전 동기화
-- ============================================================================
-- ──────────────────────────────────────────────────────────────────
-- 1. auth_perm_tree에 메뉴 컬럼 추가
-- ──────────────────────────────────────────────────────────────────
ALTER TABLE kcg.auth_perm_tree ADD COLUMN url_path VARCHAR(200);
ALTER TABLE kcg.auth_perm_tree ADD COLUMN label_key VARCHAR(100);
ALTER TABLE kcg.auth_perm_tree ADD COLUMN component_key VARCHAR(150);
ALTER TABLE kcg.auth_perm_tree ADD COLUMN nav_group VARCHAR(100); -- 소속 메뉴 그룹 (NULL=최상위)
ALTER TABLE kcg.auth_perm_tree ADD COLUMN nav_sub_group VARCHAR(100); -- 디바이더 라벨 (admin 서브그룹)
ALTER TABLE kcg.auth_perm_tree ADD COLUMN nav_sort INT NOT NULL DEFAULT 0; -- 메뉴 정렬 (0=미표시)
-- ──────────────────────────────────────────────────────────────────
-- 2. 공유 리소스 분리 — 1메뉴=1노드 보장
-- ──────────────────────────────────────────────────────────────────
INSERT INTO kcg.auth_perm_tree(rsrc_cd, parent_cd, rsrc_nm, rsrc_level, sort_ord)
VALUES ('statistics:reports', 'statistics', '보고서 관리', 1, 30)
ON CONFLICT (rsrc_cd) DO NOTHING;
INSERT INTO kcg.auth_perm_tree(rsrc_cd, parent_cd, rsrc_nm, rsrc_level, sort_ord)
VALUES ('admin:data-hub', 'admin', '데이터 허브', 1, 85)
ON CONFLICT (rsrc_cd) DO NOTHING;
INSERT INTO kcg.auth_perm_tree(rsrc_cd, parent_cd, rsrc_nm, rsrc_level, sort_ord)
VALUES ('admin:notices', 'admin', '공지사항', 1, 45)
ON CONFLICT (rsrc_cd) DO NOTHING;
-- 신규 노드에 ADMIN 전체 권한 부여
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
SELECT r.role_sn, n.rsrc_cd, op.oper_cd, 'Y'
FROM kcg.auth_role r
CROSS JOIN (VALUES ('statistics:reports'), ('admin:data-hub'), ('admin:notices')) AS n(rsrc_cd)
CROSS JOIN (VALUES ('READ'), ('CREATE'), ('UPDATE'), ('DELETE'), ('EXPORT')) AS op(oper_cd)
WHERE r.role_cd = 'ADMIN'
ON CONFLICT (role_sn, rsrc_cd, oper_cd) DO NOTHING;
-- 기존 역할에도 READ 부여 (statistics:reports → VIEWER 이상, admin:* → ADMIN 전용)
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
SELECT r.role_sn, 'statistics:reports', 'READ', 'Y'
FROM kcg.auth_role r WHERE r.role_cd IN ('VIEWER', 'ANALYST', 'OPERATOR', 'FIELD')
ON CONFLICT (role_sn, rsrc_cd, oper_cd) DO NOTHING;
-- ──────────────────────────────────────────────────────────────────
-- 3. 메뉴 데이터 채우기 — 최상위 ITEM (13개)
-- ──────────────────────────────────────────────────────────────────
UPDATE kcg.auth_perm_tree SET url_path='/dashboard', label_key='nav.dashboard', component_key='features/dashboard/Dashboard', nav_sort=100 WHERE rsrc_cd='dashboard';
UPDATE kcg.auth_perm_tree SET url_path='/monitoring', label_key='nav.monitoring', component_key='features/monitoring/MonitoringDashboard', nav_sort=200 WHERE rsrc_cd='monitoring';
UPDATE kcg.auth_perm_tree SET url_path='/events', label_key='nav.realtimeEvent', component_key='features/surveillance/LiveMapView', nav_sort=300 WHERE rsrc_cd='surveillance:live-map';
UPDATE kcg.auth_perm_tree SET url_path='/map-control', label_key='nav.mapControl', component_key='features/surveillance/MapControl', nav_sort=400 WHERE rsrc_cd='surveillance:map-control';
UPDATE kcg.auth_perm_tree SET url_path='/risk-map', label_key='nav.riskMap', component_key='features/risk-assessment/RiskMap', nav_sort=500 WHERE rsrc_cd='risk-assessment:risk-map';
UPDATE kcg.auth_perm_tree SET url_path='/enforcement-plan', label_key='nav.enforcementPlan', component_key='features/risk-assessment/EnforcementPlan', nav_sort=600 WHERE rsrc_cd='risk-assessment:enforcement-plan';
UPDATE kcg.auth_perm_tree SET url_path='/dark-vessel', label_key='nav.darkVessel', component_key='features/detection/DarkVesselDetection', nav_sort=700 WHERE rsrc_cd='detection:dark-vessel';
UPDATE kcg.auth_perm_tree SET url_path='/gear-detection', label_key='nav.gearDetection', component_key='features/detection/GearDetection', nav_sort=800 WHERE rsrc_cd='detection:gear-detection';
UPDATE kcg.auth_perm_tree SET url_path='/china-fishing', label_key='nav.chinaFishing', component_key='features/detection/ChinaFishing', nav_sort=900 WHERE rsrc_cd='detection:china-fishing';
UPDATE kcg.auth_perm_tree SET url_path='/enforcement-history',label_key='nav.enforcementHistory', component_key='features/enforcement/EnforcementHistory', nav_sort=1000 WHERE rsrc_cd='enforcement:enforcement-history';
UPDATE kcg.auth_perm_tree SET url_path='/event-list', label_key='nav.eventList', component_key='features/enforcement/EventList', nav_sort=1100 WHERE rsrc_cd='enforcement:event-list';
UPDATE kcg.auth_perm_tree SET url_path='/statistics', label_key='nav.statistics', component_key='features/statistics/Statistics', nav_sort=1200 WHERE rsrc_cd='statistics:statistics';
UPDATE kcg.auth_perm_tree SET url_path='/reports', label_key='nav.reports', component_key='features/statistics/ReportManagement', nav_sort=1300 WHERE rsrc_cd='statistics:reports';
-- ──────────────────────────────────────────────────────────────────
-- 4. 그룹 헤더 (Level 0 노드에 label_key + nav_sort)
-- ──────────────────────────────────────────────────────────────────
UPDATE kcg.auth_perm_tree SET label_key='group.fieldOps', nav_sort=1400 WHERE rsrc_cd='field-ops';
UPDATE kcg.auth_perm_tree SET label_key='group.parentInference', nav_sort=1500 WHERE rsrc_cd='parent-inference-workflow';
UPDATE kcg.auth_perm_tree SET label_key='group.admin', nav_sort=1600 WHERE rsrc_cd='admin';
-- ──────────────────────────────────────────────────────────────────
-- 5. 그룹 자식 — field-ops
-- ──────────────────────────────────────────────────────────────────
UPDATE kcg.auth_perm_tree SET url_path='/patrol-route', label_key='nav.patrolRoute', component_key='features/patrol/PatrolRoute', nav_group='field-ops', nav_sort=100 WHERE rsrc_cd='patrol:patrol-route';
UPDATE kcg.auth_perm_tree SET url_path='/fleet-optimization', label_key='nav.fleetOptimization', component_key='features/patrol/FleetOptimization', nav_group='field-ops', nav_sort=200 WHERE rsrc_cd='patrol:fleet-optimization';
UPDATE kcg.auth_perm_tree SET url_path='/ai-alert', label_key='nav.aiAlert', component_key='features/field-ops/AIAlert', nav_group='field-ops', nav_sort=300 WHERE rsrc_cd='field-ops:ai-alert';
UPDATE kcg.auth_perm_tree SET url_path='/mobile-service', label_key='nav.mobileService', component_key='features/field-ops/MobileService', nav_group='field-ops', nav_sort=400 WHERE rsrc_cd='field-ops:mobile-service';
UPDATE kcg.auth_perm_tree SET url_path='/ship-agent', label_key='nav.shipAgent', component_key='features/field-ops/ShipAgent', nav_group='field-ops', nav_sort=500 WHERE rsrc_cd='field-ops:ship-agent';
-- ──────────────────────────────────────────────────────────────────
-- 6. 그룹 자식 — parent-inference
-- ──────────────────────────────────────────────────────────────────
UPDATE kcg.auth_perm_tree SET url_path='/parent-inference/review', label_key='nav.parentReview', component_key='features/parent-inference/ParentReview', nav_group='parent-inference-workflow', nav_sort=100 WHERE rsrc_cd='parent-inference-workflow:parent-review';
UPDATE kcg.auth_perm_tree SET url_path='/parent-inference/exclusion', label_key='nav.parentExclusion', component_key='features/parent-inference/ParentExclusion', nav_group='parent-inference-workflow', nav_sort=200 WHERE rsrc_cd='parent-inference-workflow:parent-exclusion';
UPDATE kcg.auth_perm_tree SET url_path='/parent-inference/label-session', label_key='nav.labelSession', component_key='features/parent-inference/LabelSession', nav_group='parent-inference-workflow', nav_sort=300 WHERE rsrc_cd='parent-inference-workflow:label-session';
-- ──────────────────────────────────────────────────────────────────
-- 7. 그룹 자식 — admin (서브그룹 포함)
-- ──────────────────────────────────────────────────────────────────
-- AI 플랫폼
UPDATE kcg.auth_perm_tree SET url_path='/ai-model', label_key='nav.aiModel', component_key='features/ai-operations/AIModelManagement', nav_group='admin', nav_sub_group='AI 플랫폼', nav_sort=200 WHERE rsrc_cd='ai-operations:ai-model';
UPDATE kcg.auth_perm_tree SET url_path='/mlops', label_key='nav.mlops', component_key='features/ai-operations/MLOpsPage', nav_group='admin', nav_sub_group='AI 플랫폼', nav_sort=300 WHERE rsrc_cd='ai-operations:mlops';
UPDATE kcg.auth_perm_tree SET url_path='/llm-ops', label_key='nav.llmOps', component_key='features/ai-operations/LLMOpsPage', nav_group='admin', nav_sub_group='AI 플랫폼', nav_sort=400 WHERE rsrc_cd='ai-operations:llm-ops';
UPDATE kcg.auth_perm_tree SET url_path='/ai-assistant', label_key='nav.aiAssistant', component_key='features/ai-operations/AIAssistant', nav_group='admin', nav_sub_group='AI 플랫폼', nav_sort=500 WHERE rsrc_cd='ai-operations:ai-assistant';
-- 시스템 운영
UPDATE kcg.auth_perm_tree SET url_path='/system-config', label_key='nav.systemConfig', component_key='features/admin/SystemConfig', nav_group='admin', nav_sub_group='시스템 운영', nav_sort=700 WHERE rsrc_cd='admin:system-config';
UPDATE kcg.auth_perm_tree SET url_path='/data-hub', label_key='nav.dataHub', component_key='features/admin/DataHub', nav_group='admin', nav_sub_group='시스템 운영', nav_sort=800 WHERE rsrc_cd='admin:data-hub';
UPDATE kcg.auth_perm_tree SET url_path='/external-service',label_key='nav.externalService',component_key='features/statistics/ExternalService', nav_group='admin', nav_sub_group='시스템 운영', nav_sort=900 WHERE rsrc_cd='statistics:external-service';
-- 사용자 관리
UPDATE kcg.auth_perm_tree SET url_path='/admin', label_key='nav.admin', component_key='features/admin/AdminPanel', nav_group='admin', nav_sub_group='사용자 관리', nav_sort=1100 WHERE rsrc_cd='admin:user-management';
UPDATE kcg.auth_perm_tree SET url_path='/access-control', label_key='nav.accessControl', component_key='features/admin/AccessControl', nav_group='admin', nav_sub_group='사용자 관리', nav_sort=1200 WHERE rsrc_cd='admin:permission-management';
UPDATE kcg.auth_perm_tree SET url_path='/notices', label_key='nav.notices', component_key='features/admin/NoticeManagement', nav_group='admin', nav_sub_group='사용자 관리', nav_sort=1300 WHERE rsrc_cd='admin:notices';
-- 감사·보안
UPDATE kcg.auth_perm_tree SET url_path='/admin/audit-logs', label_key='nav.auditLogs', component_key='features/admin/AuditLogs', nav_group='admin', nav_sub_group='감사·보안', nav_sort=1500 WHERE rsrc_cd='admin:audit-logs';
UPDATE kcg.auth_perm_tree SET url_path='/admin/access-logs', label_key='nav.accessLogs', component_key='features/admin/AccessLogs', nav_group='admin', nav_sub_group='감사·보안', nav_sort=1600 WHERE rsrc_cd='admin:access-logs';
UPDATE kcg.auth_perm_tree SET url_path='/admin/login-history',label_key='nav.loginHistory', component_key='features/admin/LoginHistoryView', nav_group='admin', nav_sub_group='감사·보안', nav_sort=1700 WHERE rsrc_cd='admin:login-history';
-- ──────────────────────────────────────────────────────────────────
-- 8. 숨김 라우트 (라우팅만, 사이드바 미표시)
-- ──────────────────────────────────────────────────────────────────
UPDATE kcg.auth_perm_tree SET url_path='/vessel/:id', component_key='features/vessel/VesselDetail' WHERE rsrc_cd='vessel:vessel-detail';
-- ──────────────────────────────────────────────────────────────────
-- 9. menu_config 테이블 폐기
-- ──────────────────────────────────────────────────────────────────
DROP TABLE IF EXISTS kcg.menu_config;

파일 보기

@ -0,0 +1,83 @@
-- ============================================================================
-- V022: auth_perm_tree에 다국어 라벨 JSONB — DB가 i18n SSOT
-- labels = {"ko": "종합 상황판", "en": "Dashboard"} (언어 추가 시 DDL 변경 불필요)
-- ============================================================================
ALTER TABLE kcg.auth_perm_tree ADD COLUMN labels JSONB NOT NULL DEFAULT '{}';
-- ──────────────────────────────────────────────────────────────────
-- 최상위 ITEM
-- ──────────────────────────────────────────────────────────────────
UPDATE kcg.auth_perm_tree SET labels='{"ko":"종합 상황판","en":"Dashboard"}' WHERE rsrc_cd='dashboard';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"경보 현황판","en":"Alert Monitor"}' WHERE rsrc_cd='monitoring';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"실시간 이벤트","en":"Realtime Events"}' WHERE rsrc_cd='surveillance:live-map';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"해역 관리","en":"Zone Management"}' WHERE rsrc_cd='surveillance:map-control';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"위험도 지도","en":"Risk Map"}' WHERE rsrc_cd='risk-assessment:risk-map';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"단속 계획","en":"Enforcement Plan"}' WHERE rsrc_cd='risk-assessment:enforcement-plan';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"다크베셀 탐지","en":"Dark Vessel Detection"}' WHERE rsrc_cd='detection:dark-vessel';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"어구 탐지","en":"Gear Detection"}' WHERE rsrc_cd='detection:gear-detection';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"중국어선 분석","en":"China Fishing"}' WHERE rsrc_cd='detection:china-fishing';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"단속 이력","en":"Enforcement History"}' WHERE rsrc_cd='enforcement:enforcement-history';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"이벤트 목록","en":"Event List"}' WHERE rsrc_cd='enforcement:event-list';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"통계 분석","en":"Statistics"}' WHERE rsrc_cd='statistics:statistics';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"보고서 관리","en":"Report Management"}' WHERE rsrc_cd='statistics:reports';
-- ──────────────────────────────────────────────────────────────────
-- 그룹 헤더
-- ──────────────────────────────────────────────────────────────────
UPDATE kcg.auth_perm_tree SET labels='{"ko":"현장작전","en":"Field Operations"}' WHERE rsrc_cd='field-ops';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"모선 워크플로우","en":"Parent Inference"}' WHERE rsrc_cd='parent-inference-workflow';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"시스템 관리","en":"Administration"}' WHERE rsrc_cd='admin';
-- ──────────────────────────────────────────────────────────────────
-- field-ops 자식
-- ──────────────────────────────────────────────────────────────────
UPDATE kcg.auth_perm_tree SET labels='{"ko":"순찰경로 추천","en":"Patrol Route"}' WHERE rsrc_cd='patrol:patrol-route';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"다함정 최적화","en":"Fleet Optimization"}' WHERE rsrc_cd='patrol:fleet-optimization';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"AI 알림 발송","en":"AI Alert"}' WHERE rsrc_cd='field-ops:ai-alert';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"모바일 서비스","en":"Mobile Service"}' WHERE rsrc_cd='field-ops:mobile-service';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"함정 Agent","en":"Ship Agent"}' WHERE rsrc_cd='field-ops:ship-agent';
-- ──────────────────────────────────────────────────────────────────
-- parent-inference 자식
-- ──────────────────────────────────────────────────────────────────
UPDATE kcg.auth_perm_tree SET labels='{"ko":"모선 확정/거부","en":"Parent Review"}' WHERE rsrc_cd='parent-inference-workflow:parent-review';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"후보 제외","en":"Exclusion Management"}' WHERE rsrc_cd='parent-inference-workflow:parent-exclusion';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"학습 세션","en":"Label Session"}' WHERE rsrc_cd='parent-inference-workflow:label-session';
-- ──────────────────────────────────────────────────────────────────
-- admin 자식
-- ──────────────────────────────────────────────────────────────────
UPDATE kcg.auth_perm_tree SET labels='{"ko":"AI 모델관리","en":"AI Model Management"}' WHERE rsrc_cd='ai-operations:ai-model';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"MLOps","en":"MLOps"}' WHERE rsrc_cd='ai-operations:mlops';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"LLM 운영","en":"LLM Operations"}' WHERE rsrc_cd='ai-operations:llm-ops';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"AI 의사결정 지원","en":"AI Assistant"}' WHERE rsrc_cd='ai-operations:ai-assistant';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"환경설정","en":"System Config"}' WHERE rsrc_cd='admin:system-config';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"데이터 허브","en":"Data Hub"}' WHERE rsrc_cd='admin:data-hub';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"외부 서비스","en":"External Service"}' WHERE rsrc_cd='statistics:external-service';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"시스템 관리","en":"Admin Panel"}' WHERE rsrc_cd='admin:user-management';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"권한 관리","en":"Access Control"}' WHERE rsrc_cd='admin:permission-management';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"공지사항","en":"Notices"}' WHERE rsrc_cd='admin:notices';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"감사 로그","en":"Audit Logs"}' WHERE rsrc_cd='admin:audit-logs';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"접근 이력","en":"Access Logs"}' WHERE rsrc_cd='admin:access-logs';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"로그인 이력","en":"Login History"}' WHERE rsrc_cd='admin:login-history';
-- ──────────────────────────────────────────────────────────────────
-- 메뉴 미표시 권한 노드 (권한 관리 UI에서만 표시)
-- ──────────────────────────────────────────────────────────────────
UPDATE kcg.auth_perm_tree SET labels='{"ko":"감시","en":"Surveillance"}' WHERE rsrc_cd='surveillance';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"탐지","en":"Detection"}' WHERE rsrc_cd='detection';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"선박","en":"Vessel"}' WHERE rsrc_cd='vessel';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"위험평가","en":"Risk Assessment"}' WHERE rsrc_cd='risk-assessment';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"순찰","en":"Patrol"}' WHERE rsrc_cd='patrol';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"단속","en":"Enforcement"}' WHERE rsrc_cd='enforcement';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"AI 운영","en":"AI Operations"}' WHERE rsrc_cd='ai-operations';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"통계","en":"Statistics"}' WHERE rsrc_cd='statistics';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"선박상세","en":"Vessel Detail"}' WHERE rsrc_cd='vessel:vessel-detail';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"전재탐지","en":"Transfer Detection"}' WHERE rsrc_cd='vessel:transfer-detection';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"알림 목록","en":"Alert List"}' WHERE rsrc_cd='monitoring:alert-list';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"KPI 패널","en":"KPI Panel"}' WHERE rsrc_cd='monitoring:kpi-panel';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"어구식별","en":"Gear Identification"}' WHERE rsrc_cd='detection:gear-identification';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"역할 관리","en":"Role Management"}' WHERE rsrc_cd='admin:role-management';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"메뉴 설정","en":"Menu Config"}' WHERE rsrc_cd='admin:menu-management';
UPDATE kcg.auth_perm_tree SET labels='{"ko":"전역 제외 관리","en":"Global Exclusion"}' WHERE rsrc_cd='parent-inference-workflow:exclusion-management';

파일 보기

@ -0,0 +1,22 @@
-- ============================================================================
-- V023: auth_perm_tree Level-0 sort_ord를 좌측 메뉴 순서와 일치
-- 권한 관리 UI와 사이드바 메뉴의 나열 순서를 동일하게 맞춤
-- ============================================================================
-- Level-0 노드 sort_ord를 메뉴 nav_sort 순서 기준으로 재배치
-- 메뉴 자식이 있는 그룹: 자식 nav_sort 최소값 기준으로 부모 위치 결정
UPDATE kcg.auth_perm_tree SET sort_ord = 100 WHERE rsrc_cd = 'dashboard'; -- nav_sort=100
UPDATE kcg.auth_perm_tree SET sort_ord = 200 WHERE rsrc_cd = 'monitoring'; -- nav_sort=200
UPDATE kcg.auth_perm_tree SET sort_ord = 300 WHERE rsrc_cd = 'surveillance'; -- 자식 nav_sort 300~400
UPDATE kcg.auth_perm_tree SET sort_ord = 500 WHERE rsrc_cd = 'risk-assessment'; -- 자식 nav_sort 500~600
UPDATE kcg.auth_perm_tree SET sort_ord = 700 WHERE rsrc_cd = 'detection'; -- 자식 nav_sort 700~900
UPDATE kcg.auth_perm_tree SET sort_ord = 950 WHERE rsrc_cd = 'statistics'; -- 자식 nav_sort 1200~1300
UPDATE kcg.auth_perm_tree SET sort_ord = 1000 WHERE rsrc_cd = 'enforcement'; -- 자식 nav_sort 1000~1100
UPDATE kcg.auth_perm_tree SET sort_ord = 1400 WHERE rsrc_cd = 'field-ops'; -- nav_sort=1400
UPDATE kcg.auth_perm_tree SET sort_ord = 1500 WHERE rsrc_cd = 'parent-inference-workflow'; -- nav_sort=1500
UPDATE kcg.auth_perm_tree SET sort_ord = 1600 WHERE rsrc_cd = 'admin'; -- nav_sort=1600
-- 메뉴 미표시 Level-0 노드: 관련 메뉴 순서 근처에 배치
UPDATE kcg.auth_perm_tree SET sort_ord = 650 WHERE rsrc_cd = 'vessel'; -- detection 뒤, 단속 앞
UPDATE kcg.auth_perm_tree SET sort_ord = 1350 WHERE rsrc_cd = 'patrol'; -- field-ops 바로 앞
UPDATE kcg.auth_perm_tree SET sort_ord = 1550 WHERE rsrc_cd = 'ai-operations'; -- admin 바로 앞

파일 보기

@ -0,0 +1,78 @@
-- ============================================================================
-- V024: 권한 트리 = 메뉴 트리 완전 동기화
-- 보이지 않는 도메인 그룹 8개 삭제, 자식을 메뉴 구조에 맞게 재배치
-- ============================================================================
-- ──────────────────────────────────────────────────────────────────
-- 1. 그룹 레벨 권한 → 개별 자식 권한으로 확장
-- 예: (VIEWER, detection, READ, Y) → 각 detection 자식에 READ Y 복사
-- ──────────────────────────────────────────────────────────────────
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
SELECT ap.role_sn, c.rsrc_cd, ap.oper_cd, ap.grant_yn
FROM kcg.auth_perm ap
JOIN kcg.auth_perm_tree c ON c.parent_cd = ap.rsrc_cd
WHERE ap.rsrc_cd IN (
'surveillance','detection','risk-assessment','enforcement',
'statistics','patrol','ai-operations','vessel'
)
ON CONFLICT (role_sn, rsrc_cd, oper_cd) DO NOTHING;
-- ──────────────────────────────────────────────────────────────────
-- 2. 그룹 권한 행 삭제
-- ──────────────────────────────────────────────────────────────────
DELETE FROM kcg.auth_perm
WHERE rsrc_cd IN (
'surveillance','detection','risk-assessment','enforcement',
'statistics','patrol','ai-operations','vessel'
);
-- ──────────────────────────────────────────────────────────────────
-- 3. 자식 노드 parent_cd 재배치 (그룹 삭제 전에 수행)
-- ──────────────────────────────────────────────────────────────────
-- 최상위 평탄 아이템: parent_cd → NULL
UPDATE kcg.auth_perm_tree SET parent_cd = NULL
WHERE parent_cd IN (
'surveillance','detection','risk-assessment','enforcement','statistics','vessel'
);
-- patrol 자식 → field-ops 메뉴 그룹으로 이동
UPDATE kcg.auth_perm_tree SET parent_cd = 'field-ops'
WHERE parent_cd = 'patrol';
-- ai-operations 자식 → admin 메뉴 그룹으로 이동
UPDATE kcg.auth_perm_tree SET parent_cd = 'admin'
WHERE parent_cd = 'ai-operations';
-- ──────────────────────────────────────────────────────────────────
-- 4. 그룹 노드 삭제 (자식이 모두 이동된 후)
-- ──────────────────────────────────────────────────────────────────
DELETE FROM kcg.auth_perm_tree
WHERE rsrc_cd IN (
'surveillance','detection','risk-assessment','enforcement',
'statistics','patrol','ai-operations','vessel'
);
-- ──────────────────────────────────────────────────────────────────
-- 5. 패널 노드 parent_cd를 실제 소속 페이지로 수정
-- ──────────────────────────────────────────────────────────────────
-- 전역 제외 관리 → 후보 제외 페이지 내부
UPDATE kcg.auth_perm_tree SET parent_cd = 'parent-inference-workflow:parent-exclusion'
WHERE rsrc_cd = 'parent-inference-workflow:exclusion-management';
-- 역할 관리 → 권한 관리 페이지 내부
UPDATE kcg.auth_perm_tree SET parent_cd = 'admin:permission-management'
WHERE rsrc_cd = 'admin:role-management';
-- 메뉴 설정 → 권한 관리 페이지 내부
UPDATE kcg.auth_perm_tree SET parent_cd = 'admin:permission-management'
WHERE rsrc_cd = 'admin:menu-management';
-- 어구식별 → 어구 탐지 페이지 내부
UPDATE kcg.auth_perm_tree SET parent_cd = 'detection:gear-detection'
WHERE rsrc_cd = 'detection:gear-identification';
-- 전재탐지 → 선박상세 페이지 내부
UPDATE kcg.auth_perm_tree SET parent_cd = 'vessel:vessel-detail'
WHERE rsrc_cd = 'vessel:transfer-detection';

파일 보기

@ -0,0 +1,76 @@
-- ============================================================
-- V025: LGCNS MLOps + AI 보안(SER-10) + AI Agent 보안(SER-11) 메뉴 추가
-- 시스템관리 > AI 플랫폼 / 감사·보안 서브그룹
-- ============================================================
-- ──────────────────────────────────────────────────────────────
-- 1. LGCNS MLOps (시스템관리 > AI 플랫폼, MLOps와 LLM 사이)
-- ──────────────────────────────────────────────────────────────
INSERT INTO kcg.auth_perm_tree(rsrc_cd, parent_cd, rsrc_nm, rsrc_level, sort_ord)
VALUES ('admin:lgcns-mlops', 'admin', 'LGCNS MLOps', 1, 36)
ON CONFLICT (rsrc_cd) DO NOTHING;
UPDATE kcg.auth_perm_tree
SET url_path = '/lgcns-mlops',
label_key = 'nav.lgcnsMlops',
component_key = 'features/ai-operations/LGCNSMLOpsPage',
nav_group = 'admin',
nav_sub_group = 'AI 플랫폼',
nav_sort = 350,
labels = '{"ko":"LGCNS MLOps","en":"LGCNS MLOps"}'
WHERE rsrc_cd = 'admin:lgcns-mlops';
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
SELECT r.role_sn, 'admin:lgcns-mlops', op.oper_cd, 'Y'
FROM kcg.auth_role r
CROSS JOIN (VALUES ('READ'), ('CREATE'), ('UPDATE'), ('DELETE'), ('EXPORT')) AS op(oper_cd)
WHERE r.role_cd = 'ADMIN'
ON CONFLICT (role_sn, rsrc_cd, oper_cd) DO NOTHING;
-- ──────────────────────────────────────────────────────────────
-- 2. AI 보안 (SER-10) (시스템관리 > 감사·보안, 로그인 이력 뒤)
-- ──────────────────────────────────────────────────────────────
INSERT INTO kcg.auth_perm_tree(rsrc_cd, parent_cd, rsrc_nm, rsrc_level, sort_ord)
VALUES ('admin:ai-security', 'admin', 'AI 보안', 1, 55)
ON CONFLICT (rsrc_cd) DO NOTHING;
UPDATE kcg.auth_perm_tree
SET url_path = '/admin/ai-security',
label_key = 'nav.aiSecurity',
component_key = 'features/admin/AISecurityPage',
nav_group = 'admin',
nav_sub_group = '감사·보안',
nav_sort = 1800,
labels = '{"ko":"AI 보안","en":"AI Security"}'
WHERE rsrc_cd = 'admin:ai-security';
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
SELECT r.role_sn, 'admin:ai-security', op.oper_cd, 'Y'
FROM kcg.auth_role r
CROSS JOIN (VALUES ('READ'), ('CREATE'), ('UPDATE'), ('DELETE'), ('EXPORT')) AS op(oper_cd)
WHERE r.role_cd = 'ADMIN'
ON CONFLICT (role_sn, rsrc_cd, oper_cd) DO NOTHING;
-- ──────────────────────────────────────────────────────────────
-- 3. AI Agent 보안 (SER-11) (시스템관리 > 감사·보안, AI 보안 뒤)
-- ──────────────────────────────────────────────────────────────
INSERT INTO kcg.auth_perm_tree(rsrc_cd, parent_cd, rsrc_nm, rsrc_level, sort_ord)
VALUES ('admin:ai-agent-security', 'admin', 'AI Agent 보안', 1, 56)
ON CONFLICT (rsrc_cd) DO NOTHING;
UPDATE kcg.auth_perm_tree
SET url_path = '/admin/ai-agent-security',
label_key = 'nav.aiAgentSecurity',
component_key = 'features/admin/AIAgentSecurityPage',
nav_group = 'admin',
nav_sub_group = '감사·보안',
nav_sort = 1900,
labels = '{"ko":"AI Agent 보안","en":"AI Agent Security"}'
WHERE rsrc_cd = 'admin:ai-agent-security';
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
SELECT r.role_sn, 'admin:ai-agent-security', op.oper_cd, 'Y'
FROM kcg.auth_role r
CROSS JOIN (VALUES ('READ'), ('CREATE'), ('UPDATE'), ('DELETE'), ('EXPORT')) AS op(oper_cd)
WHERE r.role_cd = 'ADMIN'
ON CONFLICT (role_sn, rsrc_cd, oper_cd) DO NOTHING;

파일 보기

@ -0,0 +1,28 @@
-- ============================================================
-- V026: 데이터 보관기간 및 파기 정책 (DAR-10) 메뉴 추가
-- 시스템관리 > 감사·보안 서브그룹
-- ============================================================
-- 1. 권한 트리 노드 등록
INSERT INTO kcg.auth_perm_tree(rsrc_cd, parent_cd, rsrc_nm, rsrc_level, sort_ord)
VALUES ('admin:data-retention', 'admin', '데이터 보관·파기', 1, 57)
ON CONFLICT (rsrc_cd) DO NOTHING;
-- 2. 메뉴 메타데이터 갱신
UPDATE kcg.auth_perm_tree
SET url_path = '/admin/data-retention',
label_key = 'nav.dataRetentionPolicy',
component_key = 'features/admin/DataRetentionPolicy',
nav_group = 'admin',
nav_sub_group = '감사·보안',
nav_sort = 2000,
labels = '{"ko":"데이터 보관·파기","en":"Data Retention"}'
WHERE rsrc_cd = 'admin:data-retention';
-- 3. ADMIN 역할에 전체 권한 부여
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
SELECT r.role_sn, 'admin:data-retention', op.oper_cd, 'Y'
FROM kcg.auth_role r
CROSS JOIN (VALUES ('READ'), ('CREATE'), ('UPDATE'), ('DELETE'), ('EXPORT')) AS op(oper_cd)
WHERE r.role_cd = 'ADMIN'
ON CONFLICT (role_sn, rsrc_cd, oper_cd) DO NOTHING;

파일 보기

@ -0,0 +1,28 @@
-- ============================================================
-- V027: 데이터 모델 검증 (DAR-11) 메뉴 추가
-- 시스템관리 > 감사·보안 서브그룹
-- ============================================================
-- 1. 권한 트리 노드 등록
INSERT INTO kcg.auth_perm_tree(rsrc_cd, parent_cd, rsrc_nm, rsrc_level, sort_ord)
VALUES ('admin:data-model-verification', 'admin', '데이터 모델 검증', 1, 58)
ON CONFLICT (rsrc_cd) DO NOTHING;
-- 2. 메뉴 메타데이터 갱신
UPDATE kcg.auth_perm_tree
SET url_path = '/admin/data-model-verification',
label_key = 'nav.dataModelVerification',
component_key = 'features/admin/DataModelVerification',
nav_group = 'admin',
nav_sub_group = '감사·보안',
nav_sort = 2100,
labels = '{"ko":"데이터 모델 검증","en":"Model Verification"}'
WHERE rsrc_cd = 'admin:data-model-verification';
-- 3. ADMIN 역할에 전체 권한 부여
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
SELECT r.role_sn, 'admin:data-model-verification', op.oper_cd, 'Y'
FROM kcg.auth_role r
CROSS JOIN (VALUES ('READ'), ('CREATE'), ('UPDATE'), ('DELETE'), ('EXPORT')) AS op(oper_cd)
WHERE r.role_cd = 'ADMIN'
ON CONFLICT (role_sn, rsrc_cd, oper_cd) DO NOTHING;

파일 보기

@ -0,0 +1,28 @@
-- ============================================================
-- V028: 성능 모니터링 (PER-01~06) 메뉴 추가
-- 시스템관리 > 감사·보안 서브그룹
-- ============================================================
-- 1. 권한 트리 노드 등록
INSERT INTO kcg.auth_perm_tree(rsrc_cd, parent_cd, rsrc_nm, rsrc_level, sort_ord)
VALUES ('admin:performance-monitoring', 'admin', '성능 모니터링', 1, 59)
ON CONFLICT (rsrc_cd) DO NOTHING;
-- 2. 메뉴 메타데이터 갱신
UPDATE kcg.auth_perm_tree
SET url_path = '/admin/performance-monitoring',
label_key = 'nav.performanceMonitoring',
component_key = 'features/admin/PerformanceMonitoring',
nav_group = 'admin',
nav_sub_group = '감사·보안',
nav_sort = 2110,
labels = '{"ko":"성능 모니터링","en":"Performance Monitoring"}'
WHERE rsrc_cd = 'admin:performance-monitoring';
-- 3. ADMIN 역할에 전체 권한 부여
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
SELECT r.role_sn, 'admin:performance-monitoring', op.oper_cd, 'Y'
FROM kcg.auth_role r
CROSS JOIN (VALUES ('READ'), ('CREATE'), ('UPDATE'), ('DELETE'), ('EXPORT')) AS op(oper_cd)
WHERE r.role_cd = 'ADMIN'
ON CONFLICT (role_sn, rsrc_cd, oper_cd) DO NOTHING;

파일 보기

@ -0,0 +1,72 @@
-- V026: 한중어업협정 중국어선 허가현황 원본 테이블 + fleet_vessels 연도 컬럼
-- 출처: docs/중국어선_허가현황_YYYYMMDD.xls (연단위 갱신)
-- ===== 1. fishery_permit_cn : 허가현황 원본 스냅샷 =====
CREATE TABLE IF NOT EXISTS kcg.fishery_permit_cn (
id BIGSERIAL PRIMARY KEY,
permit_year INTEGER NOT NULL,
permit_no VARCHAR(30) NOT NULL,
fishery_type VARCHAR(60), -- 업종 (2척식저인망어업 등)
fishery_code VARCHAR(10) NOT NULL, -- 업종코드 (PT/PT-S/GN/FC/PS/OT)
name_cn VARCHAR(100) NOT NULL,
name_en VARCHAR(200) NOT NULL,
applicant_cn VARCHAR(100),
applicant_en VARCHAR(200),
applicant_addr_cn VARCHAR(300),
applicant_addr_en VARCHAR(300),
registration_no VARCHAR(100),
tonnage NUMERIC(10,2),
port_cn VARCHAR(100),
port_en VARCHAR(200),
callsign VARCHAR(40),
engine_power NUMERIC(10,2),
length_m NUMERIC(6,2),
beam_m NUMERIC(6,2),
depth_m NUMERIC(6,2),
fishing_zones VARCHAR(30), -- Ⅱ,Ⅲ 등
fishing_period_1 VARCHAR(50),
fishing_period_2 VARCHAR(50),
catch_quota_t NUMERIC(10,2),
cumulative_quota_t NUMERIC(10,2),
refrig_hold_count INTEGER,
freezer_hold_count INTEGER,
admin_sanction TEXT,
parent_permit_no VARCHAR(30), -- 부속선(PT-S)이 참조하는 본선 허가번호
volume_enclosed NUMERIC(10,2),
volume_above_deck NUMERIC(10,2),
volume_below_deck NUMERIC(10,2),
volume_excluded NUMERIC(10,2),
raw_data JSONB,
source_file VARCHAR(255),
loaded_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (permit_year, permit_no)
);
CREATE INDEX idx_fishery_permit_cn_name_cn ON kcg.fishery_permit_cn(permit_year, name_cn);
CREATE INDEX idx_fishery_permit_cn_name_en ON kcg.fishery_permit_cn(permit_year, LOWER(name_en));
CREATE INDEX idx_fishery_permit_cn_code ON kcg.fishery_permit_cn(permit_year, fishery_code);
CREATE INDEX idx_fishery_permit_cn_parent ON kcg.fishery_permit_cn(permit_year, parent_permit_no);
COMMENT ON TABLE kcg.fishery_permit_cn IS '한중어업협정 중국어선 허가현황 원본 스냅샷 (연단위 갱신)';
COMMENT ON COLUMN kcg.fishery_permit_cn.permit_year IS '허가 연도 (파일명 YYYY에서 추출)';
COMMENT ON COLUMN kcg.fishery_permit_cn.fishery_code IS 'PT(쌍끌이 본선)/PT-S(부속선)/GN(자망)/FC(운반)/PS(선망)/OT(외끌이)';
COMMENT ON COLUMN kcg.fishery_permit_cn.parent_permit_no IS 'PT-S(부속선)가 소속된 본선의 허가번호';
-- ===== 2. fleet_vessels 확장 : 연도 + 업종코드 추적 =====
ALTER TABLE kcg.fleet_vessels ADD COLUMN IF NOT EXISTS permit_year INTEGER;
ALTER TABLE kcg.fleet_vessels ADD COLUMN IF NOT EXISTS fishery_code VARCHAR(10);
CREATE INDEX IF NOT EXISTS idx_fleet_vessels_permit_year ON kcg.fleet_vessels(permit_year);
CREATE INDEX IF NOT EXISTS idx_fleet_vessels_fishery_code ON kcg.fleet_vessels(fishery_code);
COMMENT ON COLUMN kcg.fleet_vessels.permit_year IS '허가 연도. fleet_tracker는 현재 연도만 조회';
COMMENT ON COLUMN kcg.fleet_vessels.fishery_code IS 'fishery_permit_cn.fishery_code 복제 (PT/PT-S/GN/FC/PS/OT)';
-- ===== 3. V014 데모 seed 제거 =====
-- 기존 6행 데모 vessels 제거 (실제 허가현황만 남김).
-- fleet_companies id=1,2는 vessel_permit_master가 FK로 참조하여 삭제 불가 — 잔존 허용
-- (loader 실행 시 실 허가 신청인 회사가 별도 id로 upsert됨)
DELETE FROM kcg.fleet_vessels WHERE permit_no IN (
'ZY-2024-001','ZY-2024-002','ZY-2024-003',
'ZY-2024-010','ZY-2024-011','ZY-2024-012'
);

파일 보기

@ -0,0 +1,90 @@
-- ============================================================================
-- V030: 어구 정체성 충돌(GEAR_IDENTITY_COLLISION) 탐지 패턴
-- 동일 어구 이름이 서로 다른 MMSI 로 같은 5분 사이클 내 동시 AIS 송출되는
-- 케이스를 독립 탐지 패턴으로 기록. 공존 이력·심각도·운영자 분류 상태를 보존한다.
-- ============================================================================
-- ──────────────────────────────────────────────────────────────────
-- 1. 충돌 이력 테이블
-- ──────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS kcg.gear_identity_collisions (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(200) NOT NULL, -- 동일한 어구 이름
mmsi_lo VARCHAR(20) NOT NULL, -- 정렬된 쌍 (lo < hi)
mmsi_hi VARCHAR(20) NOT NULL,
parent_name VARCHAR(100),
parent_vessel_id BIGINT, -- fleet_vessels.id
first_seen_at TIMESTAMPTZ NOT NULL,
last_seen_at TIMESTAMPTZ NOT NULL,
coexistence_count INT NOT NULL DEFAULT 1, -- 동일 cycle 공존 누적
swap_count INT NOT NULL DEFAULT 0, -- 시간차 스왑 누적(참고)
max_distance_km NUMERIC(8,2), -- 양 위치 최대 거리
last_lat_lo NUMERIC(9,6),
last_lon_lo NUMERIC(10,6),
last_lat_hi NUMERIC(9,6),
last_lon_hi NUMERIC(10,6),
severity VARCHAR(20) NOT NULL DEFAULT 'MEDIUM', -- CRITICAL/HIGH/MEDIUM/LOW
status VARCHAR(30) NOT NULL DEFAULT 'OPEN', -- OPEN/REVIEWED/CONFIRMED_ILLEGAL/FALSE_POSITIVE
resolved_by UUID,
resolved_at TIMESTAMPTZ,
resolution_note TEXT,
evidence JSONB, -- 최근 관측 요약
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT gear_identity_collisions_pair_uk UNIQUE (name, mmsi_lo, mmsi_hi),
CONSTRAINT gear_identity_collisions_pair_ord CHECK (mmsi_lo < mmsi_hi)
);
CREATE INDEX IF NOT EXISTS idx_gic_status
ON kcg.gear_identity_collisions(status, last_seen_at DESC);
CREATE INDEX IF NOT EXISTS idx_gic_severity
ON kcg.gear_identity_collisions(severity, last_seen_at DESC);
CREATE INDEX IF NOT EXISTS idx_gic_parent
ON kcg.gear_identity_collisions(parent_vessel_id)
WHERE parent_vessel_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_gic_name
ON kcg.gear_identity_collisions(name);
CREATE INDEX IF NOT EXISTS idx_gic_last_seen
ON kcg.gear_identity_collisions(last_seen_at DESC);
COMMENT ON TABLE kcg.gear_identity_collisions IS
'동일 어구 이름이 서로 다른 MMSI 로 공존 송출되는 스푸핑 의심 패턴 (GEAR_IDENTITY_COLLISION).';
-- ──────────────────────────────────────────────────────────────────
-- 2. 권한 트리 / 메뉴 슬롯 (V024 이후 detection 그룹은 평탄화됨: parent_cd=NULL)
-- ──────────────────────────────────────────────────────────────────
INSERT INTO kcg.auth_perm_tree
(rsrc_cd, parent_cd, rsrc_nm, rsrc_level, sort_ord,
url_path, label_key, component_key, nav_sort, labels)
VALUES
('detection:gear-collision', NULL, '어구 정체성 충돌', 1, 40,
'/gear-collision', 'nav.gearCollision',
'features/detection/GearCollisionDetection', 950,
'{"ko":"어구 정체성 충돌","en":"Gear Identity Collision"}'::jsonb)
ON CONFLICT (rsrc_cd) DO NOTHING;
-- ──────────────────────────────────────────────────────────────────
-- 3. 권한 부여
-- ADMIN : 전체 op (READ/CREATE/UPDATE/DELETE/EXPORT)
-- OPERATOR: READ + UPDATE (분류 액션)
-- VIEWER/ANALYST/FIELD: READ
-- ──────────────────────────────────────────────────────────────────
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
SELECT r.role_sn, 'detection:gear-collision', op.oper_cd, 'Y'
FROM kcg.auth_role r
CROSS JOIN (VALUES ('READ'), ('CREATE'), ('UPDATE'), ('DELETE'), ('EXPORT')) AS op(oper_cd)
WHERE r.role_cd = 'ADMIN'
ON CONFLICT (role_sn, rsrc_cd, oper_cd) DO NOTHING;
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
SELECT r.role_sn, 'detection:gear-collision', op.oper_cd, 'Y'
FROM kcg.auth_role r
CROSS JOIN (VALUES ('READ'), ('UPDATE')) AS op(oper_cd)
WHERE r.role_cd = 'OPERATOR'
ON CONFLICT (role_sn, rsrc_cd, oper_cd) DO NOTHING;
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
SELECT r.role_sn, 'detection:gear-collision', 'READ', 'Y'
FROM kcg.auth_role r
WHERE r.role_cd IN ('VIEWER', 'ANALYST', 'FIELD')
ON CONFLICT (role_sn, rsrc_cd, oper_cd) DO NOTHING;

파일 보기

@ -0,0 +1,20 @@
-- V031: gear_group_parent_candidate_snapshots.candidate_source VARCHAR(30) → VARCHAR(100)
--
-- 원인: prediction/algorithms/gear_parent_inference.py 의 _rank_candidates 가
-- candidate_source = ','.join(sorted(meta['sources'])) (line 875)
-- 로 여러 source 라벨(CORRELATION / EPISODE / LABEL / LINEAGE / MATCH 등) 을 쉼표로
-- join 해 저장한다. 모든 source 가 맞춰지면 약 39자(5개 라벨 + 쉼표) 에 달해
-- VARCHAR(30) 를 초과하며 psycopg2.errors.StringDataRightTruncation 을 유발.
--
-- 영향: prediction 사이클의 gear_correlation 스테이지에서 `_insert_candidate_snapshots`
-- 실패 → `gear_group_parent_candidate_snapshots` 테이블 갱신 전체 스킵.
-- Phase 0-1 의 사이클 스테이지 에러 격리 덕분에 후속 스테이지(pair_detection /
-- per-vessel analysis / upsert_results / 출력 모듈들)는 정상 완주.
--
-- 대안 검토:
-- (A) VARCHAR(100) — 여유치. 채택.
-- (B) TEXT — 제약 없음. 컬럼 의미가 "짧은 라벨 집합" 이라 VARCHAR 가 자연스러움.
-- (C) 코드에서 [:30] trim — 의미 손실(source 목록 절단). 부적합.
ALTER TABLE kcg.gear_group_parent_candidate_snapshots
ALTER COLUMN candidate_source TYPE VARCHAR(100);

파일 보기

@ -0,0 +1,40 @@
-- V032: 불법 조업 이벤트 (IllegalFishingPattern) 메뉴·권한 seed
--
-- event_generator 가 생산하는 카테고리 중 "불법 조업" 관련 3종
-- (GEAR_ILLEGAL / EEZ_INTRUSION / ZONE_DEPARTURE) 을 통합해서 보여주는
-- READ 전용 대시보드. 운영자 액션(ack/status 변경) 은 /event-list 에서 수행.
--
-- Phase 0-2: prediction-analysis.md P1 권고의 "UI 미노출 탐지" 해소 중 첫 번째.
-- ──────────────────────────────────────────────────────────────────
-- 1. 권한 트리 / 메뉴 슬롯 (V024 이후 detection 그룹은 평탄화됨: parent_cd=NULL)
-- nav_sort=920 은 chinaFishing(900) 과 gearCollision(950) 사이에 배치
-- ──────────────────────────────────────────────────────────────────
INSERT INTO kcg.auth_perm_tree
(rsrc_cd, parent_cd, rsrc_nm, rsrc_level, sort_ord,
url_path, label_key, component_key, nav_sort, labels)
VALUES
('detection:illegal-fishing', NULL, '불법 조업 이벤트', 1, 45,
'/illegal-fishing', 'nav.illegalFishing',
'features/detection/IllegalFishingPattern', 920,
'{"ko":"불법 조업 이벤트","en":"Illegal Fishing"}'::jsonb)
ON CONFLICT (rsrc_cd) DO NOTHING;
-- ──────────────────────────────────────────────────────────────────
-- 2. 권한 부여
-- READ 전용 페이지 — 모든 역할에 READ만 부여.
-- 운영자가 ack/status 변경을 원하면 EventList(monitoring) 권한으로 이동.
-- ADMIN 은 일관성을 위해 5 ops 전부 부여 (메뉴 등록/삭제 정도).
-- ──────────────────────────────────────────────────────────────────
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
SELECT r.role_sn, 'detection:illegal-fishing', op.oper_cd, 'Y'
FROM kcg.auth_role r
CROSS JOIN (VALUES ('READ'), ('CREATE'), ('UPDATE'), ('DELETE'), ('EXPORT')) AS op(oper_cd)
WHERE r.role_cd = 'ADMIN'
ON CONFLICT (role_sn, rsrc_cd, oper_cd) DO NOTHING;
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
SELECT r.role_sn, 'detection:illegal-fishing', 'READ', 'Y'
FROM kcg.auth_role r
WHERE r.role_cd IN ('OPERATOR', 'ANALYST', 'FIELD', 'VIEWER')
ON CONFLICT (role_sn, rsrc_cd, oper_cd) DO NOTHING;

파일 보기

@ -0,0 +1,46 @@
-- V033: 환적 의심 탐지 (TransshipmentDetection) 메뉴·권한 seed
--
-- prediction `algorithms/transshipment.py` 의 5단계 필터 파이프라인 결과
-- (is_transship_suspect=true) 를 전체 목록·집계·상세 수준으로 조회하는 READ 전용
-- 대시보드. 기존 features/vessel/TransferDetection.tsx 는 선박 상세 수준이고,
-- 이 페이지는 전체 목록·통계 운영 대시보드.
--
-- 참고: 실제 API `/api/analysis/transship` 는 VesselAnalysisController 에서 권한을
-- `detection:dark-vessel` 로 가드 중이므로, 이 메뉴 READ 만으로는 API 호출 불가.
-- 현재 운영자(OPERATOR/ANALYST/FIELD) 는 양쪽 READ 를 모두 가지므로 실용상 문제 없음.
-- 향후 VesselAnalysisController.listTransshipSuspects 의 @RequirePermission 을
-- `detection:transshipment` 로 교체하는 것이 권한 일관성상 바람직 (별도 MR).
--
-- Phase 0-3: prediction-analysis.md P1 권고의 "UI 미노출 탐지" 해소 중 두 번째.
-- ──────────────────────────────────────────────────────────────────
-- 1. 권한 트리 / 메뉴 슬롯 (detection 그룹 평탄화: parent_cd=NULL)
-- nav_sort=910 은 chinaFishing(900) 과 illegalFishing(920) 사이
-- ──────────────────────────────────────────────────────────────────
INSERT INTO kcg.auth_perm_tree
(rsrc_cd, parent_cd, rsrc_nm, rsrc_level, sort_ord,
url_path, label_key, component_key, nav_sort, labels)
VALUES
('detection:transshipment', NULL, '환적 의심 탐지', 1, 48,
'/transshipment', 'nav.transshipment',
'features/detection/TransshipmentDetection', 910,
'{"ko":"환적 의심 탐지","en":"Transshipment"}'::jsonb)
ON CONFLICT (rsrc_cd) DO NOTHING;
-- ──────────────────────────────────────────────────────────────────
-- 2. 권한 부여 — READ 전용 대시보드
-- ADMIN: 5 ops 전부 (메뉴 관리 일관성)
-- 나머지(OPERATOR/ANALYST/FIELD/VIEWER): READ
-- ──────────────────────────────────────────────────────────────────
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
SELECT r.role_sn, 'detection:transshipment', op.oper_cd, 'Y'
FROM kcg.auth_role r
CROSS JOIN (VALUES ('READ'), ('CREATE'), ('UPDATE'), ('DELETE'), ('EXPORT')) AS op(oper_cd)
WHERE r.role_cd = 'ADMIN'
ON CONFLICT (role_sn, rsrc_cd, oper_cd) DO NOTHING;
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
SELECT r.role_sn, 'detection:transshipment', 'READ', 'Y'
FROM kcg.auth_role r
WHERE r.role_cd IN ('OPERATOR', 'ANALYST', 'FIELD', 'VIEWER')
ON CONFLICT (role_sn, rsrc_cd, oper_cd) DO NOTHING;

파일 보기

@ -0,0 +1,226 @@
-- V034: Detection Model Registry 기반 스키마 (Phase 1-1)
--
-- prediction 모듈의 17 탐지 알고리즘을 "명시적 모델 단위" 로 분리하고,
-- 운영자가 프론트엔드에서 파라미터·버전을 관리할 수 있도록 하는 기반 인프라.
-- 핵심 개념:
-- * Model — 독립 탐지 단위 (dark_suspicion / gear_violation_g01_g06 등)
-- * Version — 같은 model 의 파라미터 스냅샷. 라이프사이클 DRAFT→ACTIVE→ARCHIVED
-- * Role — PRIMARY(운영 반영, 최대 1개) / SHADOW·CHALLENGER(관측용, N개)
-- * DAG — model_id 간 선행·후행 의존성 (선행 PRIMARY 결과만 후행에 주입)
--
-- 기존 V014 correlation_param_models 패턴의 일반화 — JSONB params + is_active 를
-- model_id × version × role 차원으로 확장. V014 는 Phase 2 에서 이 스키마로
-- 이주 후 2~3 릴리즈 후 deprecate 예정.
--
-- 참고: docs/prediction-analysis.md §7 P1/P2 + .claude/plans/vast-tinkering-knuth.md
-- ══════════════════════════════════════════════════════════════════
-- 1. detection_models — 모델 카탈로그 (고정 메타)
-- ══════════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS kcg.detection_models (
model_id VARCHAR(64) PRIMARY KEY,
display_name VARCHAR(200) NOT NULL,
tier INT NOT NULL, -- 1(Primitive) ~ 5(Meta)
category VARCHAR(40), -- DARK_VESSEL/GEAR/PATTERN/TRANSSHIP/META
description TEXT,
entry_module VARCHAR(200) NOT NULL, -- 'prediction.algorithms.dark_vessel'
entry_callable VARCHAR(100) NOT NULL, -- 'compute_dark_suspicion'
is_enabled BOOLEAN NOT NULL DEFAULT TRUE, -- 전역 ON/OFF 킬스위치
created_at TIMESTAMPTZ DEFAULT now(),
updated_at TIMESTAMPTZ DEFAULT now(),
CHECK (tier BETWEEN 1 AND 5)
);
CREATE INDEX IF NOT EXISTS idx_detection_models_tier
ON kcg.detection_models(tier, category);
CREATE INDEX IF NOT EXISTS idx_detection_models_enabled
ON kcg.detection_models(is_enabled) WHERE is_enabled = TRUE;
COMMENT ON TABLE kcg.detection_models IS
'탐지 모델 카탈로그 — prediction Model 인터페이스의 단위 정의';
-- ══════════════════════════════════════════════════════════════════
-- 2. detection_model_dependencies — 모델 간 DAG 엣지
-- ══════════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS kcg.detection_model_dependencies (
model_id VARCHAR(64) NOT NULL
REFERENCES kcg.detection_models(model_id) ON DELETE CASCADE,
depends_on VARCHAR(64) NOT NULL
REFERENCES kcg.detection_models(model_id) ON DELETE RESTRICT,
input_key VARCHAR(64) NOT NULL, -- 'gap_info' / 'pair_result' 등
PRIMARY KEY (model_id, depends_on, input_key),
CHECK (model_id <> depends_on) -- self-loop 금지
);
CREATE INDEX IF NOT EXISTS idx_detection_model_deps_reverse
ON kcg.detection_model_dependencies(depends_on);
COMMENT ON TABLE kcg.detection_model_dependencies IS
'모델 실행 DAG — 선행 model PRIMARY 결과의 어떤 key 를 후행이 입력으로 쓰는지';
-- ══════════════════════════════════════════════════════════════════
-- 3. detection_model_versions — 파라미터 스냅샷 + 라이프사이클 + role
-- ══════════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS kcg.detection_model_versions (
id BIGSERIAL PRIMARY KEY,
model_id VARCHAR(64) NOT NULL
REFERENCES kcg.detection_models(model_id) ON DELETE CASCADE,
version VARCHAR(32) NOT NULL, -- SemVer 권장 '1.0.0', 자유도 허용
status VARCHAR(20) NOT NULL, -- DRAFT / TESTING / ACTIVE / ARCHIVED
role VARCHAR(20), -- PRIMARY / SHADOW / CHALLENGER (ACTIVE 일 때만)
params JSONB NOT NULL, -- 임계값·가중치·상수
notes TEXT,
traffic_weight INT DEFAULT 0, -- CHALLENGER split (0~100, 후속 기능)
parent_version_id BIGINT
REFERENCES kcg.detection_model_versions(id) ON DELETE SET NULL,
created_by UUID,
created_at TIMESTAMPTZ DEFAULT now(),
activated_at TIMESTAMPTZ,
archived_at TIMESTAMPTZ,
UNIQUE (model_id, version),
CHECK (status IN ('DRAFT','TESTING','ACTIVE','ARCHIVED')),
CHECK (role IS NULL OR role IN ('PRIMARY','SHADOW','CHALLENGER')),
CHECK (status <> 'ACTIVE' OR role IS NOT NULL), -- ACTIVE → role 필수
CHECK (traffic_weight BETWEEN 0 AND 100)
);
-- 한 model_id 에 PRIMARY×ACTIVE 는 최대 1건 (운영 반영 보호)
-- SHADOW/CHALLENGER×ACTIVE 는 N 건 허용
CREATE UNIQUE INDEX IF NOT EXISTS uk_detection_model_primary
ON kcg.detection_model_versions(model_id)
WHERE status = 'ACTIVE' AND role = 'PRIMARY';
CREATE INDEX IF NOT EXISTS idx_detection_model_versions_active
ON kcg.detection_model_versions(model_id, status)
WHERE status = 'ACTIVE';
CREATE INDEX IF NOT EXISTS idx_detection_model_versions_status
ON kcg.detection_model_versions(status, model_id);
COMMENT ON TABLE kcg.detection_model_versions IS
'모델 버전 + 파라미터 + 라이프사이클. ACTIVE 는 role=PRIMARY 1개 + SHADOW/CHALLENGER N개';
-- ══════════════════════════════════════════════════════════════════
-- 4. detection_model_run_outputs — 버전별 입력·출력 비교 (파티션)
-- 같은 (cycle, model_id, input_ref) 로 PRIMARY vs SHADOW JOIN 비교
-- ══════════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS kcg.detection_model_run_outputs (
id BIGSERIAL,
cycle_started_at TIMESTAMPTZ NOT NULL, -- 같은 사이클 식별 (모든 버전 공유)
model_id VARCHAR(64) NOT NULL,
version_id BIGINT NOT NULL,
role VARCHAR(20) NOT NULL, -- PRIMARY / SHADOW / CHALLENGER
input_ref JSONB NOT NULL, -- {mmsi, analyzed_at} 등
outputs JSONB NOT NULL, -- 모델 반환값 snapshot
cycle_duration_ms INT,
recorded_at TIMESTAMPTZ DEFAULT now(),
PRIMARY KEY (id, cycle_started_at),
CHECK (role IN ('PRIMARY','SHADOW','CHALLENGER'))
) PARTITION BY RANGE (cycle_started_at);
-- 2026-04 파티션 (이후는 partition_manager 가 월별 자동 생성)
CREATE TABLE IF NOT EXISTS kcg.detection_model_run_outputs_2026_04
PARTITION OF kcg.detection_model_run_outputs
FOR VALUES FROM ('2026-04-01') TO ('2026-05-01');
CREATE TABLE IF NOT EXISTS kcg.detection_model_run_outputs_2026_05
PARTITION OF kcg.detection_model_run_outputs
FOR VALUES FROM ('2026-05-01') TO ('2026-06-01');
CREATE INDEX IF NOT EXISTS idx_run_outputs_compare
ON kcg.detection_model_run_outputs(model_id, cycle_started_at DESC, role);
CREATE INDEX IF NOT EXISTS idx_run_outputs_version
ON kcg.detection_model_run_outputs(version_id, cycle_started_at DESC);
CREATE INDEX IF NOT EXISTS idx_run_outputs_input
ON kcg.detection_model_run_outputs USING GIN (input_ref jsonb_path_ops);
COMMENT ON TABLE kcg.detection_model_run_outputs IS
'버전별 실행 결과 원시 snapshot — PRIMARY vs SHADOW diff 분석용. 월별 파티션, 기본 7일 retention';
-- ══════════════════════════════════════════════════════════════════
-- 5. detection_model_metrics — 사이클 단위 집계 메트릭
-- ══════════════════════════════════════════════════════════════════
CREATE TABLE IF NOT EXISTS kcg.detection_model_metrics (
id BIGSERIAL PRIMARY KEY,
model_id VARCHAR(64) NOT NULL,
version_id BIGINT NOT NULL
REFERENCES kcg.detection_model_versions(id) ON DELETE CASCADE,
role VARCHAR(20) NOT NULL,
metric_key VARCHAR(64) NOT NULL, -- cycle_duration_ms/detected_count/tier_critical_count
metric_value NUMERIC,
cycle_started_at TIMESTAMPTZ NOT NULL,
recorded_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX IF NOT EXISTS idx_detection_model_metrics_lookup
ON kcg.detection_model_metrics(model_id, version_id, cycle_started_at DESC);
CREATE INDEX IF NOT EXISTS idx_detection_model_metrics_cycle
ON kcg.detection_model_metrics(cycle_started_at DESC, model_id);
COMMENT ON TABLE kcg.detection_model_metrics IS
'모델 실행 집계 메트릭 — dashboard/compare API 의 관측 소스';
-- ══════════════════════════════════════════════════════════════════
-- 6. Compare VIEW — PRIMARY vs SHADOW 결과를 같은 입력 단위로 JOIN
-- ══════════════════════════════════════════════════════════════════
CREATE OR REPLACE VIEW kcg.v_detection_model_comparison AS
SELECT
p.cycle_started_at,
p.model_id,
p.input_ref,
p.outputs AS primary_outputs,
s.outputs AS shadow_outputs,
p.version_id AS primary_version_id,
s.version_id AS shadow_version_id,
s.role AS shadow_role
FROM kcg.detection_model_run_outputs p
JOIN kcg.detection_model_run_outputs s
ON p.cycle_started_at = s.cycle_started_at
AND p.model_id = s.model_id
AND p.input_ref = s.input_ref
WHERE p.role = 'PRIMARY'
AND s.role IN ('SHADOW', 'CHALLENGER');
COMMENT ON VIEW kcg.v_detection_model_comparison IS
'PRIMARY × SHADOW/CHALLENGER 동일 입력 결과 비교 — backend Compare API 의 집계 소스';
-- ══════════════════════════════════════════════════════════════════
-- 7. 권한 트리 / 메뉴 슬롯
-- ai-operations:detection-models — AI 모델관리 하위 혹은 별도 노드.
-- 기존 ai-operations:ai-model(nav=200) 과 구분하기 위해 별도 nav_sort=250.
-- parent_cd='admin' (AI 운영 그룹 평탄화 패턴 따름)
-- ══════════════════════════════════════════════════════════════════
INSERT INTO kcg.auth_perm_tree
(rsrc_cd, parent_cd, rsrc_nm, rsrc_level, sort_ord,
url_path, label_key, component_key, nav_sort, labels)
VALUES
('ai-operations:detection-models', 'admin', '탐지 모델 관리', 1, 25,
'/detection-models', 'nav.detectionModels',
'features/ai-operations/DetectionModelManagement', 250,
'{"ko":"탐지 모델 관리","en":"Detection Models"}'::jsonb)
ON CONFLICT (rsrc_cd) DO NOTHING;
-- ══════════════════════════════════════════════════════════════════
-- 8. 권한 부여
-- ADMIN : 5 ops 전부 (모델 카탈로그 관리)
-- OPERATOR : READ + UPDATE (SHADOW activate 정도. promote-primary 는 ADMIN 만)
-- ANALYST/VIEWER: READ (파라미터 조회 + Compare 분석)
-- FIELD : (생략 — 현장 단속 담당, 모델 관리 불필요)
-- ══════════════════════════════════════════════════════════════════
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
SELECT r.role_sn, 'ai-operations:detection-models', op.oper_cd, 'Y'
FROM kcg.auth_role r
CROSS JOIN (VALUES ('READ'), ('CREATE'), ('UPDATE'), ('DELETE'), ('EXPORT')) AS op(oper_cd)
WHERE r.role_cd = 'ADMIN'
ON CONFLICT (role_sn, rsrc_cd, oper_cd) DO NOTHING;
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
SELECT r.role_sn, 'ai-operations:detection-models', op.oper_cd, 'Y'
FROM kcg.auth_role r
CROSS JOIN (VALUES ('READ'), ('UPDATE')) AS op(oper_cd)
WHERE r.role_cd = 'OPERATOR'
ON CONFLICT (role_sn, rsrc_cd, oper_cd) DO NOTHING;
INSERT INTO kcg.auth_perm(role_sn, rsrc_cd, oper_cd, grant_yn)
SELECT r.role_sn, 'ai-operations:detection-models', 'READ', 'Y'
FROM kcg.auth_role r
WHERE r.role_cd IN ('ANALYST', 'VIEWER')
ON CONFLICT (role_sn, rsrc_cd, oper_cd) DO NOTHING;

파일 보기

@ -1,6 +1,6 @@
# Database Migrations
> ⚠️ **실제 SQL 파일 위치**: [`backend/src/main/resources/db/migration/`](../../backend/src/main/resources/db/migration/)
> **실제 SQL 파일 위치**: [`backend/src/main/resources/db/migration/`](../../backend/src/main/resources/db/migration/)
>
> Spring Boot Flyway 표준 위치를 따르므로 SQL 파일은 백엔드 모듈 안에 있습니다.
> Spring Boot 기동 시 Flyway가 자동으로 적용합니다.
@ -10,37 +10,208 @@
- **User**: `kcg-app`
- **Schema**: `kcg`
- **Host**: `211.208.115.83:5432`
- **현재 버전**: v022 (2026-04-09)
## 적용된 마이그레이션 (V001~V013)
---
### Phase 1~8: 인증/권한/감사 (V001~V007)
## 마이그레이션 히스토리 (V001~V022)
Flyway 마이그레이션은 **증분 방식** — 각 파일은 이전 버전에 대한 변경(ALTER/INSERT/CREATE)만 포함합니다.
V001이 처음 테이블을 만들고, 이후 파일들이 컬럼 추가·시드 INSERT·신규 테이블 생성 등을 수행합니다.
### 인증/권한/감사 (V001~V007)
| 파일 | 내용 |
|---|---|
| `V001__auth_init.sql` | 인증/조직/역할/사용자-역할/로그인 이력 |
| `V002__perm_tree.sql` | 권한 트리 + 권한 매트릭스 |
| `V003__perm_seed.sql` | 초기 역할 5종 + 트리 노드 45개 + 권한 매트릭스 시드 |
| `V004__access_logs.sql` | 감사로그/접근이력 |
| `V005__parent_workflow.sql` | 모선 워크플로우 (resolution/review_log/exclusions/label_sessions) |
| `V006__demo_accounts.sql` | 데모 계정 5종 |
| `V007__perm_tree_label_align.sql` | 트리 노드 명칭을 사이드바 i18n 라벨과 일치 |
| `V001__auth_init.sql` | auth_user, auth_org, auth_role, auth_user_role, auth_login_hist, auth_setting |
| `V002__perm_tree.sql` | auth_perm_tree (권한 트리) + auth_perm (권한 매트릭스) |
| `V003__perm_seed.sql` | 역할 5종 시드 + 트리 노드 47개 + 역할별 권한 매트릭스 |
| `V004__access_logs.sql` | auth_audit_log + auth_access_log |
| `V005__parent_workflow.sql` | gear_group_parent_resolution, review_log, exclusions, label_sessions |
| `V006__demo_accounts.sql` | 데모 계정 5종 (admin/operator/analyst/field/viewer) |
| `V007__perm_tree_label_align.sql` | 트리 노드 명칭 일치 조정 |
### S1: 마스터 데이터 + Prediction 기반 (V008~V013)
### 마스터 데이터 (V008~V011)
| 파일 | 내용 |
|---|---|
| `V008__code_master.sql` | 계층형 코드 마스터 (12그룹, 72코드: 위반유형/이벤트/단속/허가/함정 등) |
| `V009__gear_type_master.sql` | 어구 유형 마스터 6종 (분류 룰 + 합법성 기준) |
| `V010__zone_polygon_master.sql` | 해역 폴리곤 마스터 (PostGIS GEOMETRY, 8개 해역 시드) |
| `V011__vessel_permit_patrol.sql` | 어선 허가 마스터 + 함정 마스터 + fleet_companies (선박 9척, 함정 6척) |
| `V012__prediction_events_stats.sql` | vessel_analysis_results(파티션) + 이벤트 허브 + 알림 + 통계(시/일/월) + KPI + 위험격자 + 학습피드백 |
| `V013__enforcement_operations.sql` | 단속 이력/계획 + 함정 배치 + AI모델 버전/메트릭 (시드 포함) |
| `V008__code_master.sql` | code_master (계층형 72코드: 위반유형/이벤트/단속 등) |
| `V009__gear_type_master.sql` | gear_type_master 6종 (어구 분류 룰 + 합법성 기준) |
| `V010__zone_polygon_master.sql` | zone_polygon_master (PostGIS, 8개 해역 시드) |
| `V011__vessel_permit_patrol.sql` | vessel_permit_master(9척) + patrol_ship_master(6척) + fleet_companies(2개) |
### Prediction 분석 (V012~V015)
| 파일 | 내용 |
|---|---|
| `V012__prediction_events_stats.sql` | vessel_analysis_results(파티션) + prediction_events + alerts + stats(시/일/월) + KPI + risk_grid + label_input |
| `V013__enforcement_operations.sql` | enforcement_records + plans + patrol_assignments + ai_model_versions + metrics |
| `V014__fleet_prediction_tables.sql` | fleet_vessels/tracking_snapshot + gear_identity_log + correlation_scores/raw_metrics + correlation_param_models + group_polygon_snapshots + gear_group_episodes/episode_snapshots + parent_candidate_snapshots + label_tracking_cycles + system_config |
| `V015__fix_numeric_precision.sql` | NUMERIC 정밀도 확대 (점수/비율 컬럼) |
### 모선 워크플로우 확장 + 기능 추가 (V016~V019)
| 파일 | 내용 |
|---|---|
| `V016__parent_workflow_columns.sql` | gear_group_parent_resolution 확장 (confidence, decision_source, episode_id 등) |
| `V017__role_color_hex.sql` | auth_role.color_hex 컬럼 추가 |
| `V018__prediction_event_features.sql` | prediction_events.features JSONB 컬럼 추가 |
| `V019__llm_ops_perm.sql` | ai-operations:llm-ops 권한 트리 노드 + ADMIN 권한 |
### 메뉴 DB SSOT (V020~V022)
| 파일 | 내용 |
|---|---|
| `V020__menu_config.sql` | menu_config 테이블 생성 + 시드 (V021에서 통합 후 폐기) |
| `V021__menu_into_perm_tree.sql` | auth_perm_tree에 메뉴 컬럼 추가 (url_path, label_key, component_key, nav_group, nav_sub_group, nav_sort) + 공유 리소스 분리 (statistics:reports, admin:data-hub, admin:notices) + menu_config DROP |
| `V022__perm_tree_i18n_labels.sql` | auth_perm_tree.labels JSONB 추가 — DB가 i18n SSOT (`{"ko":"...", "en":"..."}`) |
---
## 테이블 목록 (49개, flyway_schema_history 포함)
### 인증/권한 (8 테이블)
| 테이블 | PK | 설명 | 주요 컬럼 |
|---|---|---|---|
| `auth_user` | user_id (UUID) | 사용자 | user_acnt(UQ), pswd_hash, user_nm, rnkp_nm, email, org_sn(FK→auth_org), user_stts_cd, fail_cnt, auth_provider |
| `auth_org` | org_sn (BIGSERIAL) | 조직 | org_nm, org_abbr_nm, org_tp_cd, upper_org_sn(FK 자기참조) |
| `auth_role` | role_sn (BIGSERIAL) | 역할 | role_cd(UQ), role_nm, role_dc, dflt_yn, builtin_yn, color_hex |
| `auth_user_role` | (user_id, role_sn) | 사용자-역할 매핑 | granted_at, granted_by |
| `auth_perm_tree` | rsrc_cd (VARCHAR 100) | 권한 트리 + **메뉴 SSOT** | parent_cd(FK 자기참조), rsrc_nm, icon, rsrc_level, sort_ord, **url_path, label_key, component_key, nav_group, nav_sub_group, nav_sort, labels(JSONB)** |
| `auth_perm` | perm_sn (BIGSERIAL) | 권한 매트릭스 | role_sn(FK→auth_role), rsrc_cd(FK→auth_perm_tree), oper_cd, grant_yn, UQ(role_sn,rsrc_cd,oper_cd) |
| `auth_setting` | setting_key (VARCHAR 50) | 시스템 설정 | setting_val(JSONB) |
| `auth_login_hist` | hist_sn (BIGSERIAL) | 로그인 이력 | user_id, user_acnt, login_dtm, login_ip, result, fail_reason, auth_provider |
### 감사 (2 테이블)
| 테이블 | PK | 설명 | 주요 컬럼 |
|---|---|---|---|
| `auth_audit_log` | audit_sn (BIGSERIAL) | 감사 로그 | user_id, action_cd, resource_type, resource_id, detail(JSONB), ip_address, result |
| `auth_access_log` | access_sn (BIGSERIAL) | API 접근 이력 | user_id, http_method, request_path, status_code, duration_ms, ip_address |
### 모선 워크플로우 (7 테이블)
| 테이블 | PK | 설명 |
|---|---|---|
| `gear_group_parent_resolution` | id (BIGSERIAL), UQ(group_key, sub_cluster_id) | 모선 확정/거부 결과 (status, selected_parent_mmsi, confidence, decision_source, scores, episode_id) |
| `gear_group_parent_review_log` | id (BIGSERIAL) | 운영자 리뷰 이력 (action, actor, comment) |
| `gear_parent_candidate_exclusions` | id (BIGSERIAL) | 후보 제외 관리 (scope_type, excluded_mmsi, reason, active_from/until) |
| `gear_parent_label_sessions` | id (BIGSERIAL) | 학습 세션 (label_parent_mmsi, status, duration_days, anchor_snapshot) |
| `gear_parent_label_tracking_cycles` | (label_session_id, observed_at) | 학습 추적 사이클 (top_candidate, labeled_candidate 비교) |
| `gear_group_episodes` | episode_id (VARCHAR 50) | 어구 그룹 에피소드 (lineage_key, status, member_mmsis, center_point) |
| `gear_group_episode_snapshots` | (episode_id, observed_at) | 에피소드 스냅샷 |
### 마스터 데이터 (5 테이블)
| 테이블 | PK | 설명 | 시드 |
|---|---|---|---|
| `code_master` | code_id (VARCHAR 100) | 계층형 코드 | 12그룹, 72코드 |
| `gear_type_master` | gear_code (VARCHAR 20) | 어구 유형 | 6종 |
| `zone_polygon_master` | zone_code (VARCHAR 30) | 해역 폴리곤 (PostGIS GEOMETRY 4326) | 8해역 |
| `vessel_permit_master` | mmsi (VARCHAR 20) | 어선 허가 | 9척 |
| `patrol_ship_master` | ship_id (BIGSERIAL), UQ(ship_code) | 함정 | 6척 |
### Prediction 이벤트/통계 (8 테이블)
| 테이블 | PK | 설명 |
|---|---|---|
| `vessel_analysis_results` | (id, analyzed_at) 파티션 | 선박 분석 결과 (35컬럼: mmsi, risk_score, is_dark, transship_suspect, features JSONB 등) |
| `vessel_analysis_results_default` | — | 기본 파티션 |
| `prediction_events` | id (BIGSERIAL), UQ(event_uid) | 탐지 이벤트 (level, category, vessel_mmsi, status, features JSONB) |
| `prediction_alerts` | id (BIGSERIAL) | 경보 발송 (event_id FK, channel, delivery_status) |
| `event_workflow` | id (BIGSERIAL) | 이벤트 상태 변경 이력 (prev/new_status, actor) |
| `prediction_stats_hourly` | stat_hour (TIMESTAMPTZ) | 시간별 통계 (by_category/by_zone JSONB) |
| `prediction_stats_daily` | stat_date (DATE) | 일별 통계 |
| `prediction_stats_monthly` | stat_month (DATE) | 월별 통계 |
### Prediction 보조 (7 테이블)
| 테이블 | PK | 설명 |
|---|---|---|
| `prediction_kpi_realtime` | kpi_key (VARCHAR 50) | 실시간 KPI (value, trend, delta_pct) |
| `prediction_risk_grid` | (cell_id, stat_hour) | 위험도 격자 |
| `prediction_label_input` | id (BIGSERIAL) | 학습 피드백 입력 |
| `gear_correlation_scores` | (model_id, group_key, sub_cluster_id, target_mmsi) | 어구-선박 상관 점수 |
| `gear_correlation_raw_metrics` | id (BIGSERIAL) | 상관 원시 지표 |
| `correlation_param_models` | id (BIGSERIAL) | 상관 모델 파라미터 |
| `group_polygon_snapshots` | id (BIGSERIAL) | 그룹 폴리곤 스냅샷 (PostGIS) |
### Prediction 후보 (1 테이블)
| 테이블 | PK | 설명 |
|---|---|---|
| `gear_group_parent_candidate_snapshots` | id (BIGSERIAL) | 모선 후보 스냅샷 (25컬럼: 점수 분해, evidence JSONB) |
### 단속/작전 (3 테이블)
| 테이블 | PK | 설명 |
|---|---|---|
| `enforcement_records` | id (BIGSERIAL), UQ(enf_uid) | 단속 이력 (event_id FK, vessel_mmsi, action, result) |
| `enforcement_plans` | id (BIGSERIAL), UQ(plan_uid) | 단속 계획 (planned_date, risk_level, status) |
| `patrol_assignments` | id (BIGSERIAL) | 함정 배치 (ship_id FK, plan_id FK, waypoints JSONB) |
### AI 모델 (2 테이블)
| 테이블 | PK | 설명 |
|---|---|---|
| `ai_model_versions` | id (BIGSERIAL) | AI 모델 버전 (accuracy, status, train_config JSONB) |
| `ai_model_metrics` | id (BIGSERIAL) | 모델 메트릭 (model_id FK, metric_name, metric_value) |
### Fleet (3 테이블)
| 테이블 | PK | 설명 |
|---|---|---|
| `fleet_companies` | id (BIGSERIAL) | 선단 업체 (name_cn/en/ko, country) |
| `fleet_vessels` | id (BIGSERIAL) | 선단 선박 (company_id FK, mmsi, gear_code, fleet_role) |
| `fleet_tracking_snapshot` | id (BIGSERIAL) | 선단 추적 스냅샷 (company_id FK) |
### 기타 (2 테이블)
| 테이블 | PK | 설명 |
|---|---|---|
| `gear_identity_log` | id (BIGSERIAL) | 어구 식별 로그 (mmsi, name, parent_mmsi, match_method) |
| `system_config` | key (VARCHAR 100) | 시스템 설정 (value JSONB) |
---
## 인덱스 현황 (149개)
주요 패턴:
- **시계열 DESC**: `(occurred_at DESC)`, `(created_at DESC)`, `(analyzed_at DESC)` — 최신 데이터 우선 조회
- **복합 키**: `(group_key, sub_cluster_id, observed_at DESC)` — 어구 그룹 시계열
- **GiST 공간**: `polygon`, `polygon_geom` — PostGIS 공간 검색
- **GIN 배열**: `violation_categories` — 위반 카테고리 배열 검색
- **부분 인덱스**: `(released_at) WHERE released_at IS NULL` — 활성 제외만, `(is_dark) WHERE is_dark = true` — dark vessel만
## FK 관계 (21개)
```
auth_user ─→ auth_org (org_sn)
auth_user_role ─→ auth_user (user_id), auth_role (role_sn)
auth_perm ─→ auth_role (role_sn), auth_perm_tree (rsrc_cd)
auth_perm_tree ─→ auth_perm_tree (parent_cd, 자기참조)
code_master ─→ code_master (parent_id, 자기참조)
zone_polygon_master ─→ zone_polygon_master (parent_zone_code, 자기참조)
auth_org ─→ auth_org (upper_org_sn, 자기참조)
enforcement_records ─→ prediction_events (event_id), patrol_ship_master (patrol_ship_id)
event_workflow ─→ prediction_events (event_id)
prediction_alerts ─→ prediction_events (event_id)
patrol_assignments ─→ patrol_ship_master (ship_id), enforcement_plans (plan_id)
ai_model_metrics ─→ ai_model_versions (model_id)
gear_correlation_scores ─→ correlation_param_models (model_id)
gear_parent_label_tracking_cycles ─→ gear_parent_label_sessions (label_session_id)
fleet_tracking_snapshot ─→ fleet_companies (company_id)
fleet_vessels ─→ fleet_companies (company_id)
vessel_permit_master ─→ fleet_companies (company_id)
```
---
## 실행 방법
### 최초 1회 - DB/사용자 생성 (관리자 권한 필요)
```sql
-- snp 관리자 계정으로 접속
psql -h 211.208.115.83 -U snp -d postgres
CREATE DATABASE kcgaidb;
@ -61,7 +232,11 @@ cd backend && ./mvnw spring-boot:run
### 수동 적용
```bash
cd backend && ./mvnw flyway:migrate -Dflyway.url=jdbc:postgresql://211.208.115.83:5432/kcgaidb -Dflyway.user=kcg-app -Dflyway.password=Kcg2026ai -Dflyway.schemas=kcg
cd backend && ./mvnw flyway:migrate \
-Dflyway.url=jdbc:postgresql://211.208.115.83:5432/kcgaidb \
-Dflyway.user=kcg-app \
-Dflyway.password=Kcg2026ai \
-Dflyway.schemas=kcg
```
### Checksum 불일치 시 (마이그레이션 파일 수정 후)
@ -70,4 +245,18 @@ cd backend && ./mvnw flyway:repair -Dflyway.url=... (위와 동일)
```
## 신규 마이그레이션 추가
[`backend/src/main/resources/db/migration/`](../../backend/src/main/resources/db/migration/)에 `V00N__설명.sql` 형식으로 추가하면 다음 기동 시 자동 적용됩니다.
[`backend/src/main/resources/db/migration/`](../../backend/src/main/resources/db/migration/)에 `V0NN__설명.sql` 형식으로 추가하면 다음 기동 시 자동 적용됩니다.
### 메뉴 추가 시 필수 포함 사항
auth_perm_tree에 INSERT 시 메뉴 SSOT 컬럼도 함께 지정:
```sql
INSERT INTO kcg.auth_perm_tree(
rsrc_cd, parent_cd, rsrc_nm, rsrc_level, sort_ord, icon,
url_path, label_key, component_key, nav_group, nav_sort,
labels
) VALUES (
'new-feature:sub', 'new-feature', '새 기능', 1, 10, 'Sparkles',
'/new-feature/sub', 'nav.newFeatureSub', 'features/new-feature/SubPage', NULL, 1400,
'{"ko":"새 기능 서브","en":"New Feature Sub"}'
);
```

파일 보기

@ -31,7 +31,7 @@
| 서비스 | systemd | 포트 | 로그 |
|---|---|---|---|
| kcg-ai-prediction | `kcg-ai-prediction.service` | 18092 | `journalctl -u kcg-ai-prediction -f` |
| kcg-prediction (기존 iran) | `kcg-prediction.service` | 8001 | `journalctl -u kcg-prediction -f` |
| kcg-prediction (레거시) | `kcg-prediction.service` | 8001 | `journalctl -u kcg-prediction -f` |
| kcg-prediction-lab | `kcg-prediction-lab.service` | 18091 | `journalctl -u kcg-prediction-lab -f` |
## 디렉토리 구조
@ -166,7 +166,7 @@ PGPASSWORD='Kcg2026ai' psql -h 211.208.115.83 -U kcg-app -d kcgaidb
| 443 | nginx (HTTPS) | rocky-211 |
| 18080 | kcg-ai-backend (Spring Boot) | rocky-211 |
| 18092 | kcg-ai-prediction (FastAPI) | redis-211 |
| 8001 | kcg-prediction (기존 iran) | redis-211 |
| 8001 | kcg-prediction (레거시) | redis-211 |
| 18091 | kcg-prediction-lab | redis-211 |
| 5432 | PostgreSQL (kcgaidb, snpdb) | 211.208.115.83 |
| 6379 | Redis | redis-211 |
@ -226,5 +226,5 @@ ssh redis-211 "systemctl restart kcg-ai-prediction"
| `/home/apps/kcg-ai-prediction/.env` | prediction 환경변수 |
| `/home/apps/kcg-ai-prediction/venv/` | Python 3.9 가상환경 |
| `/etc/systemd/system/kcg-ai-prediction.service` | prediction systemd 서비스 |
| `/home/apps/kcg-prediction/` | 기존 iran prediction (포트 8001) |
| `/home/apps/kcg-prediction/` | 레거시 prediction (포트 8001) |
| `/home/apps/kcg-prediction-lab/` | 기존 lab prediction (포트 18091) |

파일 보기

@ -4,13 +4,345 @@
## [Unreleased]
## [2026-04-20]
### 추가
- **Detection Model Registry DB 스키마 (V034, Phase 1-1)** — prediction 17 탐지 알고리즘을 "명시적 모델 단위" 로 분리하고 프론트엔드에서 파라미터·버전·가중치를 관리할 수 있는 기반 인프라. 테이블 4종(`detection_models` 카탈로그 / `detection_model_dependencies` DAG / `detection_model_versions` 파라미터 스냅샷·라이프사이클·role / `detection_model_run_outputs` 월별 파티션) + 뷰 1개(`v_detection_model_comparison` PRIMARY×SHADOW JOIN). 한 모델을 서로 다른 파라미터로 **동시 실행**(PRIMARY 1 + SHADOW/CHALLENGER N) 지원, ACTIVE×PRIMARY 는 UNIQUE partial index 로 1건 보호. 권한 트리 `ai-operations:detection-models`(nav_sort=250) + ADMIN 5 ops / OPERATOR READ+UPDATE / ANALYST·VIEWER READ. (후속: Phase 1-2 Model Registry + DAG Executor, Phase 2 PoC 5 모델 마이그레이션)
- **환적 의심 전용 탐지 페이지 신설 (Phase 0-3)**`/transshipment` 경로에 READ 전용 대시보드 추가. prediction `algorithms/transshipment.py` 의 5단계 필터 파이프라인 결과(is_transship_suspect=true)를 전체 목록·집계·상세 수준으로 조회. KPI 5장(Total + Transship tier CRITICAL/HIGH/MEDIUM + 종합 위험 CRITICAL) + DataTable 8컬럼 + 필터(hours/level/mmsi) + features JSON 상세. 기존 `/api/analysis/transship` + `getTransshipSuspects` 재사용해 backend 변경 없음. V033 마이그레이션으로 `detection:transshipment` 권한 트리 + 전 역할 READ 부여. (docs/prediction-analysis.md P1 UI 미노출 탐지 해소 — 2/2)
- **불법 조업 이벤트 전용 페이지 신설 (Phase 0-2)**`/illegal-fishing` 경로에 READ 전용 대시보드 추가. event_generator 가 생산하는 `GEAR_ILLEGAL`(G-01/G-05/G-06) + `EEZ_INTRUSION`(영해·접속수역) + `ZONE_DEPARTURE`(특정수역 진입) 3 카테고리를 한 화면에서 통합 조회. 심각도 KPI 5장 + 카테고리별 3장 + DataTable(7컬럼) + 필터(category/level/mmsi) + JSON features 상세 패널 + EventList 네비게이션. 기존 `/api/events` 를 category 다중 병렬 조회로 래핑하여 backend 변경 없이 구현. V032 마이그레이션으로 `detection:illegal-fishing` 권한 트리 + 전 역할 READ 부여 (운영자 처리 액션은 EventList 경유)
### 변경
- **Prediction 5분 사이클 스테이지 에러 경계 도입 (Phase 0-1)**`prediction/pipeline/stage_runner.py` 신설해 `run_stage(name, fn, required=False)` 유틸 제공. `scheduler.py run_analysis_cycle()` 의 출력 6모듈(violation_classifier / event_generator / kpi_writer / stats_aggregate_hourly / stats_aggregate_daily / alert_dispatcher)을 한 try/except 로 묶던 구조를 스테이지별 독립 실행으로 분리, 한 모듈이 깨져도 다른 모듈이 계속 돌아가도록 개선. `upsert_results` 는 required=True 로 실패 시 사이클 abort. 내부 try/except 의 `logger.warning``logger.exception` 으로 업그레이드(fetch_dark_history / gear collision event promotion / group polygon / gear correlation / pair detection / chat cache)하여 `journalctl -u kcg-ai-prediction` 에서 stacktrace 로 실패 지점 즉시 특정 가능. (docs/prediction-analysis.md P1 권고)
### 수정
- **모니터링/디자인시스템 런타임 에러 해소**`/monitoring``SystemStatusPanel` 에서 `stats.total.toLocaleString()` 호출이 백엔드 응답 shape 이슈로 `stats.total` 이 undefined 일 때 Uncaught TypeError 로 크래시하던 문제 null-safe 로 해소(`stats?.total != null`). `/design-system.html``CatalogBadges``PERFORMANCE_STATUS_META``label: {ko, en}` 객체를 그대로 Badge children 으로 주입해 "Objects are not valid as a React child" 를 던지고 `code` 필드 부재로 key 중복 경고가 함께 뜨던 문제 해소 — `Object.entries` 순회 + `AnyMeta.label``string | {ko,en}` 로 확장 + getKoLabel/getEnLabel 에 label 객체 케이스 추가
- **gear_group_parent_candidate_snapshots.candidate_source VARCHAR(30)→(100) 확장 (V031)** — prediction `gear_parent_inference` 가 여러 source 라벨을 쉼표로 join 한 값(최대 ~39자)이 VARCHAR(30) 제약을 넘어 매 사이클 `StringDataRightTruncation` 으로 gear correlation 스테이지 전체가 실패하던 기존 버그. Phase 0-1 (PR #83) 의 `logger.exception` 전환으로 풀 stacktrace 가 journal 에 찍히면서 원인 특정. backend JPA 엔티티 미참조로 재빌드 불필요, Flyway 자동 적용, prediction 재기동만으로 해소
### 문서
- **Prediction 모듈 심층 분석 리포트 신설**`docs/prediction-analysis.md` (9개 섹션, 250 라인). opus 4.7 독립 리뷰 관점에서 현재 17 알고리즘의 레이어 분리·5분 사이클 시퀀스·4대 도메인 커버리지를 평가하고, 6축(관심사 분리/재사용성/테스트 가능성/에러 격리/동시성/설정 가능성)으로 구조 채점 + P1~P4 개선 제안·임계값 전수표 제공
- **루트·SFR 문서 drift 해소** — V001~V016 → V030 + 51 테이블, Python 3.9 → 3.11+, 14 → 17 알고리즘 모듈 실측 반영. SFR-10 에 GEAR_IDENTITY_COLLISION 패턴 + GearCollisionDetection 페이지 섹션 추가 (sfr-traceability/sfr-user-guide), `/gear-collision` 라우트 architecture.md 포함, system-flow-guide 노드 수 102→115 + V030 manifest 미반영 경고, backend/README "Phase 2 예정" 상태 → 실제 운영 구성 전면 재작성 (PR #79 hotfix 요구사항 명시)
## [2026-04-17]
### 추가
- **어구 정체성 충돌(GEAR_IDENTITY_COLLISION) 탐지 패턴** — 동일 어구 이름이 서로 다른 MMSI 로 같은 5분 사이클에 동시 AIS 송출되는 스푸핑/복제 의심 패턴을 신규 탐지. prediction `fleet_tracker.track_gear_identity()` 가 공존(simultaneous) / 교체(sequential) 경로를 분리해 공존 쌍은 `gear_identity_collisions` 에 UPSERT (누적 공존 횟수, 최대 거리, 양측 좌표, evidence JSONB append). 심각도는 거리/누적/스왑 기반으로 CRITICAL/HIGH/MEDIUM/LOW 자동 재계산, 운영자 확정 상태(CONFIRMED_ILLEGAL/FALSE_POSITIVE)는 보존. CRITICAL/HIGH 승격 시 `prediction_events` 허브에 `GEAR_IDENTITY_COLLISION` 카테고리 등록(dedup 367분). `/api/analysis/gear-collisions` READ + resolve 액션(REVIEWED/CONFIRMED_ILLEGAL/FALSE_POSITIVE/REOPEN, `@Auditable GEAR_COLLISION_RESOLVE`). 좌측 메뉴 "어구 정체성 충돌" 자동 노출(nav_sort=950, detection:gear-collision)
- **gearCollisionStatuses 카탈로그**`shared/constants/gearCollisionStatuses.ts` + `catalogRegistry` 등록으로 design-system 쇼케이스 자동 노출. OPEN/REVIEWED/CONFIRMED_ILLEGAL/FALSE_POSITIVE 4단계 Badge intent 매핑
- **performanceStatus 카탈로그 등록** — 이미 존재하던 `shared/constants/performanceStatus.ts` (good/normal/warning/critical/running/passed/failed/active/scheduled/archived 10종) 를 `catalogRegistry` 에 등록. design-system 쇼케이스 자동 노출 + admin 성능/보관/검증 페이지 SSOT 일원화
### 변경
- **디자인 시스템 SSOT 일괄 준수 (30파일)**`frontend/design-system.html` 쇼케이스의 공통 컴포넌트와 `shared/constants/` 카탈로그를 우회하던 하드코딩 UI 를 전영역 치환. raw `<button>``<Button variant>` / raw `<input>``<Input>` / raw `<select>``<Select>` / 커스텀 탭 → `<TabBar>` + `<TabButton>` / raw checkbox → `<Checkbox>`. `text-red-400` 같은 다크 전용 색상을 `text-red-600 dark:text-red-400` 쌍으로 라이트 모드 대응. StatBox `color: string` prop 을 `intent: BadgeIntent` + `INTENT_TEXT_CLASS` 매핑으로 재설계. 에러 메시지도 `t('error.errorPrefix', { msg })` 로 통일. 영역: detection(6) / detection/components(4) / enforcement / surveillance(2) / admin(7) / parent-inference(3) / statistics / ai-operations(3) / dashboard / field-ops(2) / auth
- **i18n 하드코딩 한글 제거 (alert/confirm/aria-label 우선순위)**`common.json``aria` / `error` / `dialog` / `success` / `message` 네임스페이스 추가 (ko/en 대칭, 52개 키). 운영자 노출 `alert('실패: ' + msg)` 11건과 접근성 위반 `aria-label="역할 코드"` 등 40+건을 `t('aria.*')` / `t('error.*')` / `t('dialog.*')` 로 일괄 치환
- **iran 백엔드 프록시 잔재 제거**`IranBackendClient` dead class 삭제, `application.yml``iran-backend:` 블록 + `AppProperties.IranBackend` inner class 정리. prediction 이 kcgaidb 에 직접 write 하는 현 아키텍처에 맞춰 CLAUDE.md 시스템 구성 다이어그램 최신화. Frontend UI 라벨 `iran 백엔드 (분석)``AI 분석 엔진` 로 교체, system-flow manifest `external.iran_backend` 노드는 `status: deprecated` 마킹
- **백엔드 계층 분리** — AlertController/MasterDataController/AdminStatsController 에서 repository·JdbcTemplate 직접 주입 패턴 제거. `AlertService` · `MasterDataService` · `AdminStatsService` 신규 계층 도입 + `@Transactional(readOnly=true)` 적용. 공통 `RestClientConfig @Configuration` 으로 `predictionRestClient` / `signalBatchRestClient` Bean 통합 → Proxy controller 들의 `@PostConstruct` ad-hoc 생성 제거
- **감사 로그 보강**`EnforcementService` 의 createRecord / updateRecord / createPlan 에 `@Auditable` 추가 (ENFORCEMENT_CREATE/UPDATE/PLAN_CREATE). `VesselAnalysisGroupService.resolveParent``PARENT_RESOLVE` 액션 기록. 모든 쓰기 액션이 `auth_audit_log` 에 자동 수집
- **alertLevels 카탈로그 확장** — 8개 화면의 `level === 'CRITICAL' ? ... : 'HIGH' ? ...` 식 직접 분기를 제거하기 위해 `isValidAlertLevel` (타입 가드) / `isHighSeverity` / `getAlertLevelOrder` / `ALERT_LEVEL_MARKER_OPACITY` / `ALERT_LEVEL_MARKER_RADIUS` / `ALERT_LEVEL_TIER_SCORE` 헬퍼·상수 신설. LiveMapView 마커 시각 매핑, DarkVesselDetection tier→점수, GearIdentification 타입 가드, vesselAnomaly 패널 severity 할당 헬퍼로 치환
- **prediction 5분 사이클 안정화**`gear_correlation_scores_pkey` 충돌이 매 사이클 `InFailedSqlTransaction` 을 유발해 이벤트 생성·분석 결과 upsert 가 전부 스킵되던 문제 해소. `gear_correlation_scores``target_mmsi` 이전 쿼리를 SAVEPOINT 로 격리해 PK 충돌 시 트랜잭션 유지. 공존 경로는 이전 시도 자체를 하지 않아 재발 방지
### 수정
- **백엔드 RestClient bean 모호성으로 기동 실패 해소** — rocky-211 `kcg-ai-backend` 가 restart 시 `No qualifying bean of type RestClient, but 2 were found: predictionRestClient, signalBatchRestClient` 로 크래시 루프 진입하던 문제. PR #A(2026-04-17) 의 RestClientConfig 도입 이후 잠복해 있던 버그로, `@RequiredArgsConstructor` 가 생성한 constructor parameter 에 필드의 `@Qualifier` 가 복사되지 않아 Spring 6.1 의 parameter-level annotation 기반 주입이 실패한 것. 수정: `backend/pom.xml``maven-compiler-plugin` 실행 설정에 `<parameters>true</parameters>` 명시 + `backend/src/main/java/lombok.config` 신설해 `lombok.copyableAnnotations += org.springframework.beans.factory.annotation.Qualifier` 등록
### 문서
- **절대 지침 섹션 추가** — CLAUDE.md 최상단에 "절대 지침(Absolute Rules)" 섹션 신설. (1) 신규 브랜치 생성 전 `git fetch``origin/develop` 대비 뒤처지면 사용자 확인 → `git pull --ff-only` → 분기하는 동기화 절차 명시, (2) `frontend/` 작업 시 `design-system.html` 쇼케이스 규칙 전면 준수(공통 컴포넌트 우선 사용, 인라인/하드코딩 Tailwind 색상·`!important` 금지, 접근성 필수 체크리스트) 요약. 하단 기존 "디자인 시스템 (필수 준수)" 상세 섹션과 연결
- **프로젝트 산출문서 2026-04-17 기준 정비**`architecture.md` shared/components/ui 9개·i18n 네임스페이스 갱신, `sfr-traceability.md` v3.0 전면 재작성(운영 상태 기반 531라인), `sfr-user-guide.md` 헤더 + SFR-01/02/09/10/11/12/13/17 구현 현황 갱신, stale 3건 제거
## [2026-04-16.7]
### 변경
- **경량 분석 riskScore 해상도 개선**`compute_lightweight_risk_score``dark_suspicion_score`(0~100 패턴 기반 의심도) / `dist_from_baseline_nm`(EEZ 외 기선 근접도 12·24NM 차등) / `dark_history_24h`(반복 이력) 반영. 허가·반복 이중계산 방지 축소 로직. 배포 후 실측: 45점 60.8% 고정 수렴 → **0%** (11~40 구간 고르게 분산)
- **vessel_type 매핑** — fleet_vessels 등록선 `fishery_code` (PT/PT-S/OT/GN/PS/FC) 를 `TRAWL/GILLNET/PURSE/CARGO` 로 매핑하는 `vessel_type_mapping.py` 신설. 경량 경로의 `vessel_type='UNKNOWN'` 하드코딩 제거. 실측: UNKNOWN 98.6% → **89.1%** (886척이 구체 유형으로 전환)
- **VesselType 값 확장** — 기존 TRAWL/PURSE/LONGLINE/TRAP/UNKNOWN 에 `GILLNET`(유자망) / `CARGO`(운반선) 2종 추가
- **중국 선박 분석 그리드 정합성** — Tab 1 상단 `RealAllVessels` 편의 export 를 `mmsiPrefix='412'` 로 고정 + 제목 "중국 선박 전체 분석 결과 (실시간)" 로 변경. 상단/하단 모두 중국 선박 기준으로 일관 표시
### 추가
- **선박 유형 한글 카탈로그**`shared/constants/vesselTypes.ts` 신설. 저인망/선망/유자망/연승/통발/운반선/미분류 한글 라벨 + Badge intent. 기존 `alertLevels` 패턴 답습, `catalogRegistry` 등록으로 design-system 쇼케이스 자동 노출
## [2026-04-16.6]
### 추가
- **중국어선 감시 화면 실데이터 연동 (3개 탭)** — deprecated iran proxy `/api/vessel-analysis` → 자체 백엔드 `/api/analysis/*` 전환. AI 감시 대시보드·환적접촉탐지·어구/어망 판별 모두 prediction 5분 사이클 결과 실시간 반영. 관심영역/VIIRS/기상/VTS 카드는 "데모 데이터" 뱃지, 비허가/제재/관심 선박 탭은 "준비중" 뱃지로 데이터 소스 미연동 항목 명시
- **특이운항 미니맵 + 판별 구간 패널** — AI 감시 대시보드 선박 리스트 클릭 → 24h AIS 항적(MapLibre + deck.gl) + Dark/Spoofing/환적/어구위반/고위험 신호를 시간순 segment 로 병합해 지도 하이라이트(CRITICAL/WARNING/INFO 3단계). 판별 패널에 시작~종료·지속·N회 연속 감지·카테고리·설명 표시. 어구/어망 판별 탭 최하단 자동탐지 결과 row 클릭 시 상단 입력 폼 프리필
- **`/api/analysis/stats`** — MMSI별 최신 row 기준 단일 쿼리 COUNT FILTER 집계(total/dark/spoofing/transship/risk 분포/zone 분포/fishing/avgRiskScore + windowStart/End). 선택적 `mmsiPrefix` 필터(중국 선박 '412' 등)
- **`/api/analysis/gear-detections`** — gear_code/judgment NOT NULL row MMSI 중복 제거 목록. 자동 탐지 결과 섹션 연동용
- **`/api/analysis/vessels` 필터 확장** — `mmsiPrefix` / `minRiskScore` / `minFishingPct` 쿼리 파라미터 추가
- **VesselAnalysisResponse 필드 확장**`violationCategories` / `bd09OffsetM` / `ucafScore` / `ucftScore` / `clusterId` 5개 필드 노출
- **prediction 분석 시점 좌표 저장**`AnalysisResult` + `to_db_tuple` + `upsert_results` SQL 에 `lat/lon` 추가. 분류 파이프라인(last_row) / 경량 분석(all_positions) 두 경로 주입. 기존 `vessel_analysis_results.lat/lon` 컬럼이 항상 NULL 이던 구조적 누락 해결 (첫 사이클 8173/8173 non-null 확인)
## [2026-04-16.5]
### 변경
- **Admin 11개 페이지 디자인 시스템 하드코딩 색상 제거 (Phase 1-B)** — 129건 Tailwind 색상 → 시맨틱 토큰(text-label/text-heading/text-hint) + Badge intent 치환. raw `<button>``<Button>` 컴포넌트 교체. 미사용 import 정리
## [2026-04-16.4]
### 추가
- **G-02 금어기 조업 탐지**`fishery_permit_cn.fishing_period_1/2` 파싱(YYYY/MM/DD 범위) + `classify_gear_violations()` 에 permit_periods/observation_ts 인자 추가. 허가기간 밖 조업 시 `CLOSED_SEASON_FISHING` judgment
- **G-03 미등록/허가외 어구 탐지** — 감지 어구와 `fishery_code` 허용 목록 대조(PT→trawl, GN→gillnet, FC→금지 등). 불일치 시 `UNREGISTERED_GEAR` judgment
- **NAME_FUZZY 매칭** — 선박명 정규화(공백/대소문자/'NO.' 마커 통일, 선박번호 유지) + name_en 기반 fuzzy lookup. 동명이 중복 방지. 매칭률 9.1% → 53.1%
- **서버 스크립트 tier/G-02/G-03/match_method 추적** — diagnostic(5분) + hourly(1시간) 에 pair_tier 분포, reject 사유 카운터, fishery_code×match_method 교차, G-02/G-03 상세 섹션
### 변경
- **pair_trawl tier 분류** — AND 게이트(스펙 100% 2시간) 대신 STRONG/PROBABLE/SUSPECT 3단계. 완화 임계(800m/SOG 1.5-5/sog_delta 1.0/cog 20°)로 실제 공조 신호 포착. G-06 판정은 STRONG/PROBABLE 만
- **pair_trawl join key** — raw AIS timestamp → `time_bucket`(5분 리샘플). sog/cog on-demand 계산(`_ensure_sog_cog`)으로 vessel_store._tracks 직접 사용
- **pair base 확장** — classification 500척 → 전체 중국 MID(412/413/414) 조업 속력대 선박. candidates 61→1,668, detected 0→57
- **match_ais_to_registry 대상 확장** — vessel_dfs(500척) → vessel_store._tracks 전체 중국 선박(8k+)
### 수정
- **violation_classifier**`CLOSED_SEASON_FISHING`, `UNREGISTERED_GEAR` judgment → `ILLEGAL_GEAR` 카테고리 매핑 추가
## [2026-04-16.3]
### 변경
- **Admin 3개 페이지 디자인 시스템 준수 리팩토링 (Phase 1-A)** — PerformanceMonitoring/DataRetentionPolicy/DataModelVerification 자체 탭 네비 → `TabBar/TabButton` 공통 컴포넌트, 원시 `<button>``TabButton`, PerformanceMonitoring 정적 hex 9건 → `performanceStatus` 카탈로그 경유
- **신규 카탈로그** `shared/constants/performanceStatus.ts` — PerformanceStatus(good/warning/critical/running/passed/failed/active/scheduled/archived) → {intent, hex, label} + utilizationStatus(ratio) 헬퍼
- **RBAC skeleton** — 3개 페이지 최상단 `useAuth().hasPermission('admin:{resource}', 'OP')` 호출 배치 (Phase 3 액션 버튼 추가 시 가드로 연결)
## [2026-04-16.2]
### 추가
- **성능 모니터링(PER-01~06) 메뉴** — 시스템관리 > 감사·보안에 성능 모니터링 페이지 추가 (PerformanceMonitoring 컴포넌트 + V028 메뉴 마이그레이션)
- **데이터 모델 검증(DAR-11) 메뉴** — DataModelVerification 페이지 + V027 메뉴
- **데이터 보관·파기 정책(DAR-10) 메뉴** — DataRetentionPolicy 페이지 + V026 메뉴
- **DAR-03 5종 어구 구조 비교** — AI 모델관리 어구 탐지 탭에 저층 트롤 / 스토우넷 / 자망 / 통발 / 쌍끌이 이미지 및 설명 추가
- **단속 계획 탭 확장** — 단일 함정 순찰 작전 / 다함정 순찰 작전 탭 추가 (EnforcementPlan)
## [2026-04-16]
### 추가
- **한중어업협정 중국어선 허가현황 레지스트리**`kcg.fishery_permit_cn` 신규 테이블(29컬럼, 연단위 스냅샷). V029 마이그레이션 + `fleet_vessels.permit_year/fishery_code` 컬럼 추가. `load_fishery_permit_cn.py`로 연도별 XLS → DB 적재(906척/497 신청인사)
- **페어 탐색 재설계**`find_pair_candidates()` bbox 1차(인접 9 cell) + 궤적 유사도 2차(location/sog_corr/cog_alignment). 동종 어선 페어도 허용, role 가점(PT_REGISTERED/COOP_FISHING/TRANSSHIP_LIKE)
- **fleet_tracker API 3개**`get_pt_registered_mmsis` / `get_gear_episodes` / `get_gear_positions`
### 수정
- **DAR-03 G-04/G-05/G-06 Dead code 해결**`classify_gear_violations()` scheduler 호출 연결. `if 'pair_results' in dir()` 버그 제거. 사이클당 G-05 303건 / G-04 1건 탐지 시작
- **spoofing 산식** — 24h 희석 버그 → 최근 1h 윈도우 + teleport 절대 가점(건당 0.20) + extreme(>50kn) 단독 발견 시 score=max(0.6) 확정
- **gear_code DB write 경로**`AnalysisResult.gear_code` 필드 + `kcgdb.upsert_results()` INSERT/UPDATE + scheduler 두 경로에서 `fleet_tracker.get_vessel_gear_code()` 호출
### 변경
- **transshipment 선종 완화**`_CARRIER_HINTS`(cargo/tanker/supply/carrier/reefer) 부분일치 + 412* 중국어선 FISHING 간주
- **gear drift 임계** — 750m → **500m** (DAR-03 스펙 정합)
- **fleet_tracker 현재 연도 필터**`WHERE permit_year = EXTRACT(YEAR FROM now())::int OR permit_year IS NULL`
### 기타
- cron 스크립트 신규 섹션: hourly P1~P5(허가/매칭/gear_code/fleet_role) + D3.5(pair_type) / diagnostic PART 7.5 + 4-5.5
## [2026-04-15]
### 추가
- **DAR-03 G-code 위반 분류** — prediction에 G-01(수역×어구 위반)/G-04(MMSI 사이클링)/G-05(고정어구 표류)/G-06(쌍끌이 공조) 4개 위반 유형 자동 분류 + 점수 합산
- **쌍끌이 공조 탐지 알고리즘** — pair_trawl.py 신규 (cell-key 파티션 O(n) 스캔, 500m 근접·0.5kn 속도차·10° COG 일치·2h 지속 임계값)
- **모선 검토 워크플로우** — 어구 판정 상세 패널에 후보 검토 UI 추가 (관측 지표 7종 평균 + 보정 지표 + 모선 확정/제외 버튼). 별도 화면 진입 없이 어구 탐지 페이지 내에서 의사결정
- **24시간 궤적 리플레이** — TripsLayer fade trail 애니메이션, 멤버별 개별 타임라인 보간(빈 구간 자연 연속), convex hull 폴리곤 실시간 생성, 후보 선박 항적 동시 재생 (signal-batch /api/v2/tracks/vessels 프록시 연동)
- **어구 탐지 그리드 UX** — 다중 선택 필터 패널(설치 해역/판정/위험도/모선 상태/허가/멤버 수 슬라이더, localStorage 영속화), 행 클릭 시 지도 flyTo, 후보 일치율 칼럼 + 정렬
### 변경
- **그리드 후보 일치율 정확도** — resolution.top_score(평가 시점 고정) 대신 correlation_scores.current_score(실시간 갱신)의 최댓값 사용 → 최신 점수 반영
- **어구 그룹 칼럼 표시** — 모선 후보 MMSI가 그룹명 자리에 표시되던 버그 수정 (groupLabel/groupKey 우선 표시)
- **ParentResolution Entity 확장** — top_score/confidence/score_margin/decision_source/stable_cycles 등 점수 근거 7개 필드 추가
- **백엔드 correlation API 응답 정규화** — snake_case 컬럼을 camelCase로 명시 매핑 (프론트 매핑 누락 방지)
### 수정
- **궤적 리플레이 깜박임** — useMapLayers와 useGearReplayLayers가 동시에 overlay.setProps()를 호출하던 경쟁 조건 제거. 리플레이 활성 시 useMapLayers 비활성화 (단일 렌더링 경로)
- **멤버-중심 연결선 제거** — 어구 그룹 선택 모드에서 불필요하게 그려지던 dashed 연결선 코드 삭제
### 기타
- **루트 .venv/ gitignore 추가**
## [2026-04-14]
### 추가
- **DarkVesselDetection 판정 상세 패널** — 테이블 행 클릭 시 점수 산출 내역(P1~P11), GAP 상세, 7일 이력 차트 사이드 패널 표시
- **ScoreBreakdown 공통 컴포넌트** — 가점/감점 분리 점수 내역 시각화
- **darkVesselPatterns 카탈로그 확장** — prediction 실제 판정 패턴 18종 한국어 라벨+점수+설명 + buildScoreBreakdown() 유틸
- **TransferDetection 환적 운영 화면** — 5단계 파이프라인 기반 재구성 (KPI, 쌍 목록, 쌍 상세, 감시영역 지도, 탐지 조건 시각화)
- **GearDetection 모선 추론 연동** — 모선 상태(DIRECT_MATCH/AUTO_PROMOTED/REVIEW_REQUIRED), 추정 모선 MMSI, 후보 수 컬럼
### 변경
- **DarkVesselDetection 위치 표시 수정** — lat/lon null 시 features.gap_start_lat/lon fallback, 클릭 시 지도 하이라이트
- **EnforcementPlan 탐지 기반 단속 대상** — CRITICAL 이벤트를 카테고리별(다크베셀/환적/EEZ침범/고위험) 아이콘+라벨로 통합 표시
- **LGCNS 3개 페이지 디자인 시스템 전환** — LGCNSMLOps/AISecurityPage/AIAgentSecurityPage 공통 구조 적용
## [2026-04-13]
### 추가
- **LGCNS MLOps 메뉴** — 시스템관리 > AI 플랫폼 하위, 모델 레지스트리/학습 파이프라인/서빙 현황/모델 모니터링 탭 구성
- **AI 보안(SER-10) 메뉴** — 시스템관리 > 감사·보안 하위, AI 모델 보안 감사/Adversarial 공격 탐지/데이터 무결성 검증/보안 이벤트 타임라인
- **AI Agent 보안(SER-11) 메뉴** — 시스템관리 > 감사·보안 하위, 에이전트 실행 로그/정책 위반 탐지/자원 사용 모니터링/신뢰도 대시보드
- **V025 마이그레이션** — auth_perm_tree에 admin:lgcns-mlops, admin:ai-security, admin:ai-agent-security 노드 + ADMIN 역할 CRUD 권한 시드
- **prediction 알고리즘 재설계** — dark_vessel 의심 점수화(8패턴 0~100), transshipment 베테랑 재설계, vessel_store/scheduler 개선
- **프론트엔드 지도 레이어 구조 정리** — BaseMap, useMapLayers, static layers 리팩토링
### 변경
- **NoticeManagement CRUD 권한 가드** — admin:notices CREATE/UPDATE/DELETE 체크 추가 (disabled + 툴팁)
- **EventList CRUD 권한 가드** — enforcement:event-list UPDATE + enforcement:enforcement-history CREATE 체크 추가 (disabled + 툴팁)
## [2026-04-09]
### 추가
- **워크플로우 연결 5단계** — 탐지→단속 관통 워크플로우 구현
- **VesselAnalysis 직접 조회 API 5개** (`/api/analysis/*`) — iran proxy 없이 prediction DB 직접 조회
- vessels 목록 (필터: mmsi, zone, riskLevel, isDark)
- vessels/{mmsi} 최신 분석 (features JSONB 포함)
- vessels/{mmsi}/history 분석 이력 (24h)
- dark 베셀 목록 (MMSI 중복 제거)
- transship 의심 목록
- **EventList 인라인 액션 4종** — 확인(ACK)/선박상세/단속등록/오탐 처리
- **MMSI → VesselDetail 링크** — EventList, DarkVessel, EnforcementHistory 3개 화면
- **VesselDetail 전면 개편** — prediction 직접 API 전환, dark 패턴 시각화(tier/score/patterns), 환적 분석, 24h AIS 수신 타임라인, 단속 이력 탭
- **DarkVesselDetection prediction 전환** — iran proxy 제거, tier 기반 KPI/필터/정렬
- **EnforcementHistory eventId 역추적** — 단속→이벤트 역링크
- **EnforcementPlan 미배정 CRITICAL 이벤트 패널** — NEW 상태 CRITICAL 이벤트 표시
- **모선추론 자동 연결** — CONFIRM→LabelSession, REJECT→Exclusion 자동 호출
- **30초 자동 갱신** — EventList, DarkVessel (silentRefresh 패턴, 깜박임 없음)
- **admin 메뉴 4개 서브그룹** — AI 플랫폼/시스템 운영/사용자 관리/감사·보안
- **V018 마이그레이션** — prediction_events.features JSONB 컬럼
- **V019 마이그레이션** — ai-operations:llm-ops 권한 트리 항목
- **analysisApi.ts** 프론트 서비스 (직접 조회 API 5개 연동)
- **PredictionEvent.features** 타입 확장 (dark_tier, transship_score 등)
- **메뉴 DB SSOT 구조화** — auth_perm_tree 기반 메뉴·권한·i18n 통합
- auth_perm_tree에 메뉴 컬럼 추가 (url_path, label_key, component_key, nav_group, nav_sort)
- labels JSONB 다국어 지원 (`{"ko":"종합 상황판", "en":"Dashboard"}`) — DB가 i18n SSOT
- 보이지 않는 도메인 그룹 8개 삭제 (surveillance, detection 등) → 권한 트리 = 메뉴 트리 완전 동기화
- 패널 노드 parent_cd 실제 소속 페이지로 수정 (어구식별→어구탐지, 전역제외→후보제외)
- vessel:vessel-detail 권한 노드 제거 (드릴다운 전용, 인증만 체크)
- 공유 리소스 분리: statistics:reports, admin:data-hub, admin:notices 독립 노드 생성
- V020~V024 마이그레이션 5건
- **프론트엔드 동적 메뉴/라우팅** — DB 기반 자동 구성
- menuStore(Zustand) + componentRegistry(lazy loading) + iconRegistry
- NAV_ENTRIES/PATH_TO_RESOURCE 하드코딩 제거
- App.tsx DynamicRoutes: DB menuConfig에서 Route 자동 생성
- MainLayout: DB menuConfig에서 사이드바 자동 렌더링
- **PermissionsPanel 개선** — DB labels 기반 표시명 + 페이지/패널 아이콘 구분 + 메뉴 순서 정렬
- **DB migration README.md 전면 재작성** — V001~V024, 49테이블, 149인덱스 실측 문서화
- **Dark Vessel 의심 점수화** — 기존 "gap≥30분→dark" 이분법에서 8가지 패턴 기반 0~100점 점수 산출 + CRITICAL/HIGH/WATCH/NONE 등급 분류
- P1 이동 중 OFF / P2 민감 수역 / P3 반복 이력(7일) / P4 거리 비정상 / P5 주간 조업 OFF / P6 직전 이상행동 / P7 무허가 / P8 장기 gap
- 한국 AIS 수신 커버리지 밖은 자연 gap 가능성으로 감점
- 어구(gear) AIS, 한국 선박(440/441) dark 판정 완전 제외
- `features` JSONB에 `dark_suspicion_score`, `dark_patterns`, `dark_tier`, `dark_history_7d` 등 저장
- **Transshipment 베테랑 관점 재설계** — 점수 기반 40~130점 판정 (CRITICAL/HIGH/WATCH)
- SOG 2.0→1.0kn, 근접 110→77m, 지속 60→45분 + gap tolerance 2사이클
- 한국 EEZ 관할 수역 이내 필수, 어구/여객/군함/유조 제외
- 야간·무허가·COG편차·장기지속·영해위치 가점
- pair_history 구조 확장: `{'first_seen', 'last_seen', 'miss_count'}` (GPS 노이즈 내성)
### 변경
- **event_generator.py** INSERT에 features JSONB 추가 (이벤트에 분석 핵심 특성 저장)
- **@RequirePermission 12곳 수정** — 삭제된 그룹 rsrc_cd → 구체적 자식 리소스
- **EnforcementController** vesselMmsi 필터 파라미터 추가
- **enforcement.ts** getEnforcementRecords에 vesselMmsi 파라미터 추가
- **stats_aggregator hourly**: UTC→KST hour boundary 전환, `by_category`/`by_zone` JSONB 집계 추가
- **event_generator 룰 전면 재편**:
- EEZ_INTRUSION: 실측 zone_code(TERRITORIAL_SEA/CONTIGUOUS_ZONE/ZONE_*) 기반 신규 3룰
- HIGH_RISK_VESSEL: risk.py CRITICAL 임계값과 동일(70점) 연동, 50~69점 MEDIUM 분리
- DARK_VESSEL: features.dark_tier 기반 CRITICAL/HIGH 룰 (기존 gap>60 룰 교체)
- ILLEGAL_TRANSSHIP: features.transship_tier 기반 CRITICAL/HIGH 룰 (기존 단순 룰 교체)
- break 제거 → mmsi당 복수 카테고리 동시 매칭 가능
- dedup 윈도우 prime 값 분산 (60/120/360→67/127/367 등, 정시 일제 만료 회피)
- **lightweight path 신호 보강**: vessel_store 24h 궤적으로 dark/spoofing/speed_jump 산출
- `compute_lightweight_risk_score`에 dark gap + spoofing 가점 추가 (max 60→100)
- `_gear_re` 중복 제거 → `fleet_tracker.GEAR_PATTERN` SSOT 통합
- `AnalysisResult.to_db_tuple` features sanitize: 중첩 dict/list 지원
### 수정
- `/map-control` labelKey 중복 해소 (nav.riskMap → nav.mapControl, "해역 관리")
- system-flow 08-frontend.json 누락 노드 14개 추가
- `prediction_stats_hourly.by_category`/`by_zone` 영구 NULL → 채움
- `prediction_stats_hourly.critical_count` 영구 0 → CRITICAL 이벤트 수 반영
- `prediction_events` 카테고리 2종(ZONE_DEPARTURE/ILLEGAL_TRANSSHIP)만 → 6종 이상
- KPI `dark_vessel`/`eez_violation` 영구 0 → 정상 집계
- 이벤트 홀수/짝수시 4~22배 진폭 → dedup prime 분산으로 완화
- dark 과다 판정 해소: 핫픽스(한국 수신 영역 필터) + 2차(의심 점수화)
- transship 과다 판정 해소: 사이클당 2,400~12,600 → CRITICAL/HIGH/WATCH 점수 기반
### 문서
- i18n darkTier/transshipTier/adminSubGroup/mapControl 키 추가 (ko/en)
## [2026-04-08]
### 추가
- **디자인 시스템 쇼케이스** (`/design-system.html`) — UI 단일 진실 공급원(SSOT)
- 별도 Vite entry, 메인 SPA와 분리 (designSystem-*.js 54KB)
- 10개 섹션: Intro / Token / Typography / Badge / Button / Form / Card / Layout / Catalog / Guide
- 추적 ID 체계 `TRK-<카테고리>-<슬러그>` (예: `TRK-BADGE-critical-sm`)
- 호버 시 툴팁, "ID 복사 모드", URL 해시 딥링크 `#trk=...`
- 단축키 `A`로 다크/라이트 토글
- 한글/영문 라벨 병기로 카탈로그 검토 용이
- **신규 공통 컴포넌트**:
- `Button` (5 variant × 3 size = 15) `@shared/components/ui/button`
- `Input` / `Select` / `Textarea` / `Checkbox` / `Radio` `@shared/components/ui/`
- `TabBar` / `TabButton` (underline / pill / segmented 3 variant) `@shared/components/ui/tabs`
- `PageContainer` (size sm/md/lg + fullBleed) `@shared/components/layout/PageContainer`
- `PageHeader` (icon + title + description + demo + actions) `@shared/components/layout/PageHeader`
- `Section` (Card 단축) `@shared/components/layout/Section`
- **중앙 레지스트리**:
- `catalogRegistry.ts` — 23개 카탈로그 메타 (id/title/description/source/items 자동 enumerate)
- `variantMeta.ts` — Badge intent 8종 + Button variant 5종 의미 가이드
- `statusIntent.ts` — 한글/영문 ad-hoc 상태 → BadgeIntent + getRiskIntent(0~100)
- **4 catalog에 intent 필드 추가**: eventStatuses / enforcementResults / enforcementActions / patrolStatuses + getXxxIntent() 헬퍼
- **UI 공통 카탈로그 19종** (`frontend/src/shared/constants/`) — 백엔드 enum/code_master 기반 SSOT
- violation/alert/event/enforcement/patrol/engine/userRole/device/parentResolution/
modelDeployment/gearGroup/darkVessel/httpStatus/userAccount/loginResult/permission/
vesselAnalysis/connection/trainingZone + kpiUiMap
- 표준 API: `get{Cat}Intent(code)`, `get{Cat}Label(code, t, lang)`, `get{Cat}Classes/Hex`
- **시맨틱 텍스트 토큰** (`theme.css @layer utilities`) — Tailwind v4 복합 이름 매핑 실패 대응
- `text-heading/label/hint/on-vivid/on-bright`, `bg-surface-raised/overlay` 직접 정의
- **Badge 시스템 재구축** — CVA 기반 8 intent × 4 size (rem), !important 제거
- **cn() 유틸** (`lib/utils/cn.ts`) — clsx + tailwind-merge, 시맨틱 토큰 classGroup 등록
- **ColorPicker 컴포넌트** — 팔레트 grid + native color + hex 입력
- **Role.colorHex** (백엔드 V017 migration) — auth_role.color_hex VARCHAR(7), 빌트인 5개 역할 기본 색상 시드
- System Flow 뷰어 (`/system-flow.html`) — 시스템 전체 데이터 흐름 시각화
- 102 노드 + 133 엣지, 10개 카테고리 매니페스트
- stage/menu 두 가지 그룹화 토글, 검색/필터/딥링크 지원
- 포커스 모드 (1-hop 연결 노드만 활성화, 나머지 dim)
- 메인 SPA와 분리된 별도 entry, 산출문서 노드 ID 참조용
- `/version` 스킬 사후 처리로 manifest version 자동 동기화
- CI/CD에서 버전별 스냅샷을 서버 archive에 영구 보존
- 백엔드 `GET /api/stats/hourly?hours=24` — 시간별 통계 조회 (PredictionStatsHourly)
- V014 prediction 보조 테이블 12개 (fleet_vessels, gear_correlation_scores 등)
- V015 NUMERIC precision 일괄 확대 (score→7,4, pct→12,2)
- V016 parent workflow 누락 컬럼 일괄 추가 (17+ 컬럼, candidate_mmsi generated column)
### 변경
- **35+ feature 페이지 PageContainer/PageHeader 마이그레이션** — admin/detection/enforcement/field-ops/patrol/statistics/ai-operations/parent-inference/dashboard/monitoring/surveillance/vessel/risk-assessment 전체
- **VesselDetail `-m-4` negative margin 해킹 → `<PageContainer fullBleed>`** 정리
- **LiveMapView fullBleed 패턴** 적용
- **Badge intent 팔레트 테마 분리**: 라이트(파스텔 `bg-X-100 text-X-900`) + 다크(translucent `bg-X-500/20 text-X-400`)
- **40+ 페이지 Badge/시맨틱 토큰 마이그레이션**
- Badge className 직접 작성 → intent/size prop 변환
- 컬러풀 액션 버튼 → `text-on-vivid` (흰색), 검색/필터 버튼 → `bg-blue-400 text-on-bright`
- ROLE_COLORS 4곳 중복 제거 → `getRoleBadgeStyle()` 공통 호출
- PermissionsPanel 역할 생성/수정에 ColorPicker 통합
- DataTable `width` 의미 변경: 고정 → 선호 최소 너비 (minWidth), 컨텐츠 자동 확장 + truncate
- `dateFormat.ts` sv-SE 로케일로 `YYYY-MM-DD HH:mm:ss` 일관된 KST 출력
- MonitoringDashboard `PagePagination` 제거 (데이터 페이지네이션 오해 해소)
### 수정
- **접근성 (WCAG 2.1 Level A)** — axe DevTools 위반 전수 처리:
- `<Select>` 컴포넌트 TypeScript union 타입으로 `aria-label`/`aria-labelledby`/`title` 컴파일 강제
- 네이티브 `<select>` 5곳 aria-label
- 아이콘 전용 `<button>` 16곳 aria-label
- `<input>`/`<textarea>` 28곳 aria-label (placeholder 자동 복제 포함)
- AIModelManagement 토글 → `role="switch"` + `aria-checked`
- **Badge className 위반 37건 전수 제거**`<Badge intent="...">` 패턴으로 통일
- **하드코딩 `bg-X-500/20 text-X-400` 56곳 제거** — 카탈로그 API + intent 사용
- **인라인 `<button>` type 누락 86곳 보정**
- **CSS Safari 호환성**: `backdrop-filter` `-webkit-` prefix 추가 (디자인 쇼케이스)
- `trk-pulse` keyframe outline-color → opacity (composite-only 최적화)
- Dashboard RiskBar 단위 버그 (0~100 정수를 `*100` 하던 코드 → 범위 감지)
- ReportManagement, TransferDetection `p-5 space-y-4` padding 복구
- EnforcementHistory 그리드 minmax 적용으로 컬럼 잘림 해소
- timeline 시간 `formatDateTime` 적용 (ISO `T` 구분자 처리)
- **prediction e2e 5가지 이슈 수정** (2026-04-08)
- gear_correlation: psycopg2 Decimal × float TypeError → `_load_all_scores()` float 변환
- violation_classifier: `(mmsi, analyzed_at)` 기준 UPDATE + 중국선박 EEZ 판정 로직
- kpi_writer / stats_aggregator: UTC → KST 날짜 경계 통일
- parent workflow 스키마 ↔ 코드 불일치 → V016로 일괄 해결
- DemoQuickLogin hostname 기반 노출 (Gitea CI `.env` 차단 대응)
- 프론트 전수 mock 정리: eventStore.alerts, enforcementStore.plans, transferStore 완전 제거
- Dashboard/MonitoringDashboard/Statistics 하드코딩 → 실 API 전환
- UTC → KST 시간 표시 통일 (`@shared/utils/dateFormat.ts` 공통 유틸)
- i18n `group.parentInference` JSON 중복키 제거
- RiskMap Math.random() 격자 제거, MTIS 라벨 + "AI 분석 데이터 수집 중" 안내
- 12개 mock 화면에 "데모 데이터" 노란 배지 추가
## [2026-04-07]

파일 보기

@ -50,6 +50,7 @@ src/
│ ├── i18n/ # 10 NS (common, dashboard, detection, patrol, enforcement, statistics, ai, fieldOps, admin, auth)
│ │ ├── config.ts # i18next 초기화 (ko 기본, en 폴백)
│ │ └── locales/ # ko/*.json, en/*.json (10파일 x 2언어)
│ │ # 2026-04-17: common.json 에 aria(36)/error(7)/dialog(4)/success(2)/message(5) 네임스페이스 추가
│ └── theme/ # tokens, colors, variants (CVA)
│ ├── tokens.ts # CSS 변수 매핑 + resolved 색상값
│ ├── colors.ts # 시맨틱 팔레트 (risk, alert, vessel, status, chartSeries)
@ -89,20 +90,28 @@ src/
│ ├── ws.ts # connectWs (STOMP 스텁, 미구현)
│ └── index.ts # 배럴 export
├── shared/components/ # 공유 UI 컴포넌트
│ ├── ui/
│ │ ├── card.tsx # Card(CVA variant), CardHeader, CardTitle, CardContent
│ │ └── badge.tsx # Badge(CVA intent/size)
├── shared/components/ # 공유 UI 컴포넌트 (design-system.html SSOT)
│ ├── ui/ # 9개 공통 컴포넌트 (2026-04-17 모든 화면 SSOT 준수 완료)
│ │ ├── card.tsx # Card(CVA variant), CardHeader, CardTitle, CardContent (4 variant)
│ │ ├── badge.tsx # Badge(CVA intent 8종 × size 4단계, LEGACY_MAP 변형 호환)
│ │ ├── button.tsx # Button (variant 5종 × size 3단계, icon/trailingIcon prop)
│ │ ├── input.tsx # Input (size/state, forwardRef)
│ │ ├── select.tsx # Select (aria-label|aria-labelledby|title TS union 강제)
│ │ ├── textarea.tsx # Textarea
│ │ ├── checkbox.tsx # Checkbox (native input 래퍼)
│ │ ├── radio.tsx # Radio
│ │ └── tabs.tsx # TabBar + TabButton (underline/pill/segmented 3 variant)
│ ├── layout/ # PageContainer / PageHeader / Section (표준 페이지 루트)
│ └── common/
│ ├── DataTable.tsx # 범용 테이블 (가변너비, 검색, 정렬, 페이징, 엑셀, 출력)
│ ├── Pagination.tsx # 페이지네이션
│ ├── SearchInput.tsx # 검색 입력
│ ├── SearchInput.tsx # 검색 입력 (i18n 통합)
│ ├── ExcelExport.tsx # 엑셀 다운로드
│ ├── FileUpload.tsx # 파일 업로드
│ ├── PageToolbar.tsx # 페이지 상단 툴바
│ ├── PrintButton.tsx # 인쇄 버튼
│ ├── SaveButton.tsx # 저장 버튼
│ └── NotificationBanner.tsx # 알림 배너
│ └── NotificationBanner.tsx # 알림 배너 (common.aria.closeNotification)
├── features/ # 13 도메인 그룹 (31 페이지)
│ ├── dashboard/ # 종합 대시보드 (Dashboard)
@ -309,7 +318,7 @@ deps 변경 → useMapLayers → RAF → overlay.setProps() (React 리렌
---
## 라우팅 구조 (26 보호 경로 + login)
## 라우팅 구조 (27 보호 경로 + login)
`App.tsx`에서 `BrowserRouter` > `AuthProvider` > `Routes`로 구성된다.
@ -322,6 +331,7 @@ deps 변경 → useMapLayers → RAF → overlay.setProps() (React 리렌
- `/enforcement-plan` — 단속계획 (SFR-06)
- `/dark-vessel` — 무등화 선박 탐지 (SFR-09)
- `/gear-detection` — 어구 탐지 (SFR-10)
- `/gear-collision` — 어구 정체성 충돌 (SFR-10, V030 — 동일 어구 이름 × 복수 MMSI 공존 감지)
- `/china-fishing` — 중국어선 탐지
- `/patrol-route` — 순찰경로 (SFR-07)
- `/fleet-optimization` — 함대 최적화 (SFR-08)

파일 보기

@ -1,252 +0,0 @@
# Mock 데이터 공유 현황 분석 및 통합 결과
> 최초 작성일: 2026-04-06
> 마지막 업데이트: 2026-04-06
> 대상: `kcg-ai-monitoring` 프론트엔드 코드베이스 전체 (31개 페이지)
> 상태: **통합 완료**
---
## 1. 선박 데이터 교차참조
현재 동일한 선박 데이터가 여러 컴포넌트에 독립적으로 하드코딩되어 있다. 각 파일마다 동일 선박의 속성(위험도, 위치, 상태 등)이 서로 다른 형식과 값으로 중복 정의되어 있어 데이터 일관성 문제가 발생한다.
| 선박명 | 등장 파일 수 | 파일 목록 |
|---|---|---|
| 鲁荣渔56555 | 7+ | Dashboard, MobileService, LiveMapView, MonitoringDashboard, EventList, EnforcementHistory, ChinaFishing |
| 浙甬渔60651 | 4 | Dashboard, LiveMapView, EventList, DarkVesselDetection |
| 冀黄港渔05001 | 6 | MobileService, LiveMapView, Dashboard, TransferDetection, EventList, GearDetection |
| 3001함 | 6+ | ShipAgent, MobileService, LiveMapView, Dashboard, PatrolRoute, FleetOptimization |
| 3009함 | 6+ | ShipAgent, MobileService, Dashboard, PatrolRoute, FleetOptimization, AIAlert |
| 미상선박-A | 5 | MobileService, Dashboard, LiveMapView, MonitoringDashboard, EventList |
### 문제점
- 하나의 선박이 평균 5~7개 파일에 중복 정의됨
- 선박 속성(이름, MMSI, 위치, 위험도, 상태)이 파일마다 미세하게 다를 수 있음
- 새 선박 추가/수정 시 모든 관련 파일을 일일이 찾아 수정해야 함
---
## 2. 위험도 스케일 불일치
동일한 선박의 위험도가 페이지마다 서로 다른 스케일로 표현되고 있다.
| 선박명 | Dashboard (risk) | DarkVesselDetection (risk) | MonitoringDashboard |
|---|---|---|---|
| 鲁荣渔56555 | **0.96** (0~1 스케일) | - | **CRITICAL** (레벨 문자열) |
| 浙甬渔60651 | **0.85** (0~1 스케일) | **94** (0~100 정수) | - |
| 미상선박-A | **0.94** (0~1 스케일) | **96** (0~100 정수) | - |
### 원인 분석
- Dashboard는 `risk: 0.96` 형식 (0~1 소수)
- DarkVesselDetection은 `risk: 96` 형식 (0~100 정수)
- MonitoringDashboard는 `'CRITICAL' | 'HIGH' | 'MEDIUM' | 'LOW'` 레벨 문자열
- LiveMapView는 `risk: 0.94` 형식 (0~1 소수)
- EventList는 레벨 문자열 (`AlertLevel`)
### 통합 방안
위험도를 **0~100 정수** 스케일로 통일하되, 레벨 문자열은 구간별 자동 매핑 유틸로 변환한다.
```
0~30: LOW | 31~60: MEDIUM | 61~85: HIGH | 86~100: CRITICAL
```
---
## 3. KPI 수치 중복
Dashboard와 MonitoringDashboard가 **완전히 동일한 KPI 수치**를 독립적으로 정의하고 있다.
| 지표 | Dashboard `KPI_DATA` | MonitoringDashboard `KPI` |
|---|---|---|
| 실시간 탐지 | 47 | 47 |
| EEZ 침범 | 18 | 18 |
| 다크베셀 | 12 | 12 |
| 불법환적 의심 | 8 | 8 |
| 추적 중 | 15 | 15 |
| 나포/검문(금일 단속) | 3 | 3 |
### 문제점
- 6개 KPI 수치가 두 파일에 100% 동일하게 하드코딩
- 수치 변경 시 양쪽 모두 수정해야 함
- Dashboard에는 `prev` 필드(전일 비교)가 추가로 있으나, Monitoring에는 없음
---
## 4. 이벤트 타임라인 중복
08:47~06:12 시계열 이벤트가 최소 4개 파일에 각각 정의되어 있다.
| 시각 | Dashboard | Monitoring | MobileService | EventList |
|---|---|---|---|---|
| 08:47 | EEZ 침범 (鲁荣渔56555) | EEZ 침범 (鲁荣渔56555 외 2척) | [긴급] EEZ 침범 탐지 | EVT-0001 EEZ 침범 |
| 08:32 | 다크베셀 출현 | 다크베셀 출현 | 다크베셀 출현 | EVT-0002 다크베셀 |
| 08:15 | 선단 밀집 경보 | 선단 밀집 경보 | - | EVT-0003 선단밀집 |
| 07:58 | 불법환적 의심 | 불법환적 의심 | 환적 의심 | EVT-0004 불법환적 |
| 07:41 | MMSI 변조 탐지 | MMSI 변조 탐지 | - | EVT-0005 MMSI 변조 |
| 07:23 | 함정 검문 완료 | 함정 검문 완료 | - | EVT-0006 검문 완료 |
| 06:12 | 속력 이상 탐지 | - | - | EVT-0010 속력 이상 |
### 문제점
- 동일 이벤트의 description이 파일마다 미세하게 다름 (예: "鲁荣渔56555" vs "鲁荣渔56555 외 2척")
- EventList에는 ID가 있으나(EVT-xxxx), 다른 파일에는 없음
- Dashboard에는 10개, Monitoring에는 6개, EventList에는 15개로 **건수도 불일치**
---
## 5. 환적 데이터 100% 중복
`TransferDetection.tsx``ChinaFishing.tsx`에 **TR-001~TR-003 환적 데이터가 완전히 동일**하게 정의되어 있다.
```
TransferDetection.tsx:
const transferData = [
{ id: 'TR-001', time: '2026-01-20 13:42:11', a: {name:'장저우8호'}, b: {name:'黑江9호'}, ... },
{ id: 'TR-002', time: '2026-01-20 11:15:33', ... },
{ id: 'TR-003', time: '2026-01-20 09:23:45', ... },
];
ChinaFishing.tsx:
const TRANSFER_DATA = [
{ id: 'TR-001', time: '2026-01-20 13:42:11', a: {name:'장저우8호'}, b: {name:'黑江9호'}, ... },
{ id: 'TR-002', time: '2026-01-20 11:15:33', ... },
{ id: 'TR-003', time: '2026-01-20 09:23:45', ... },
];
```
### 문제점
- 변수명만 다르고 (`transferData` vs `TRANSFER_DATA`) 데이터 구조와 값이 100% 동일
- 한쪽만 수정하면 다른 쪽과 불일치 발생
---
## 6. 함정 상태 불일치
동일 함정의 상태가 페이지마다 모순되는 경우가 확인되었다.
| 함정 | ShipAgent | Dashboard | PatrolRoute | FleetOptimization |
|---|---|---|---|---|
| 5001함 | **오프라인** (`status: '오프라인'`) | **가용** (PATROL_SHIPS에 대기로 표시) | **가용** (`status: '가용'`) | **가용** (`status: '가용'`) |
| 3009함 | **온라인** (동기화 중) | **검문 중** | **출동중** | **출동중** |
| 1503함 | **미배포** | - | - | **정비중** |
### 문제점
- 5001함이 ShipAgent에서는 오프라인이지만, Dashboard/PatrolRoute/FleetOptimization에서는 가용으로 표시됨 -- **직접적 모순**
- 3009함의 상태가 "온라인", "검문 중", "출동중"으로 파일마다 다름
- 실제 운영 시 혼란을 초래할 수 있는 시나리오 불일치
---
## 7. 현재 상태: 통합 완료
아래 분석에서 식별한 모든 중복/불일치 문제를 해소하기 위해, 7개 공유 Mock 모듈 + 7개 Zustand 스토어 체계로 통합이 **완료**되었다.
### 7.1 완료된 아키텍처: mock -> store -> page
```
┌─────────────────────────────────────────────────────────────────────────┐
│ src/data/mock/ (7개 공유 모듈) │
├───────────┬──────────┬──────────┬────────┬───────────┬────────┬────────┤
│ vessels │ patrols │ events │ kpi │ transfers │ gear │enforce-│
│ .ts │ .ts │ .ts │ .ts │ .ts │ .ts │ment.ts │
└─────┬─────┴─────┬────┴─────┬────┴───┬────┴─────┬────┴───┬────┴───┬────┘
│ │ │ │ │ │ │
▼ ▼ ▼ ▼ ▼ ▼ ▼
┌─────────────────────────────────────────────────────────────────────────┐
│ src/stores/ (7개 Zustand 스토어 + settingsStore) │
├───────────┬──────────┬──────────┬────────┬───────────┬────────┬────────┤
│ vessel │ patrol │ event │ kpi │ transfer │ gear │enforce-│
│ Store │ Store │ Store │ Store │ Store │ Store │mentStr │
└─────┬─────┴─────┬────┴─────┬────┴───┬────┴─────┬────┴───┬────┴───┬────┘
│ │ │ │ │ │ │
▼ ▼ ▼ ▼ ▼ ▼ ▼
┌─────────────────────────────────────────────────────────────────────────┐
│ src/features/*/ (페이지 컴포넌트) │
│ store.load() 호출 -> store에서 데이터 구독 -> 뷰 변환은 페이지 책임 │
└─────────────────────────────────────────────────────────────────────────┘
```
### 7.2 스토어별 소비 현황 (16개 페이지가 스토어 사용)
| 스토어 | 소비 페이지 |
|---|---|
| `useVesselStore` | Dashboard, LiveMapView, DarkVesselDetection, VesselDetail |
| `usePatrolStore` | Dashboard, PatrolRoute, FleetOptimization |
| `useEventStore` | Dashboard, MonitoringDashboard, LiveMapView, EventList, MobileService, AIAlert |
| `useKpiStore` | Dashboard, MonitoringDashboard, Statistics |
| `useTransferStore` | TransferDetection, ChinaFishing |
| `useGearStore` | GearDetection |
| `useEnforcementStore` | EnforcementPlan, EnforcementHistory |
### 7.3 페이지 전용 인라인 데이터 (미통합)
아래 페이지들은 도메인 특성상 공유 mock에 포함하지 않고 페이지 전용 인라인 데이터를 유지한다.
| 페이지 | 인라인 데이터 | 사유 |
|---|---|---|
| ChinaFishing | `COUNTERS_ROW1/2`, `VESSEL_LIST`, `MONTHLY_DATA`, `VTS_ITEMS` | 중국어선 전용 센서 카운터/통계 (다른 페이지에서 미사용) |
| VesselDetail | `VESSELS: VesselTrack[]` | 항적 데이터 구조가 `VesselData`와 다름 (주석으로 명시) |
| MLOpsPage | 실험/배포 데이터 | MLOps 전용 도메인 데이터 |
| MapControl | 훈련구역 데이터 | 해상사격 훈련구역 전용 |
| DataHub | 수신현황 데이터 | 데이터 허브 전용 모니터링 |
| AIModelManagement | 모델/규칙 데이터 | AI 모델 관리 전용 |
| AIAssistant | `SAMPLE_CONVERSATIONS` | 챗봇 샘플 대화 |
| LoginPage | `DEMO_ACCOUNTS` | 데모 인증 정보 |
| 기타 (AdminPanel, SystemConfig 등) | 각 페이지 전용 설정/관리 데이터 | 관리 도메인 특화 |
### 7.4 설계 원칙 (구현 완료)
1. **위험도 0~100 통일**: 모든 선박의 위험도를 0~100 정수로 통일. 레벨 문자열은 유틸 함수로 변환.
2. **단일 원천(Single Source of Truth)**: 각 데이터는 하나의 mock 모듈에서만 정의하고, 스토어를 통해 접근.
3. **Lazy Loading**: 스토어의 `load()` 메서드가 최초 호출 시 `import()`로 mock 데이터를 동적 로딩 (loaded 플래그로 중복 방지).
4. **뷰 변환은 페이지 책임**: mock 모듈/스토어는 원본 데이터만 제공하고, 화면별 가공(필터, 정렬, 포맷)은 각 페이지에서 수행.
### 7.5 Mock 모듈 상세 (참고용)
참고: 초기 분석에서 계획했던 `areas.ts`는 최종 구현 시 `enforcement.ts`(단속 이력 데이터)로 대체되었다.
해역/구역 데이터는 RiskMap, MapControl 등 각 페이지에서 전용 데이터로 관리한다.
| # | 모듈 파일 | 스토어 | 내용 |
|---|---|---|---|
| 1 | `data/mock/vessels.ts` | `vesselStore` | 중국어선 + 한국어선 + 미상선박 마스터 (`MOCK_VESSELS`, `MOCK_SUSPECTS`) |
| 2 | `data/mock/patrols.ts` | `patrolStore` | 경비함정 마스터 + 경로/시나리오/커버리지 |
| 3 | `data/mock/events.ts` | `eventStore` | 이벤트 타임라인 + 알림 데이터 |
| 4 | `data/mock/kpi.ts` | `kpiStore` | KPI 수치 + 월별 추이 |
| 5 | `data/mock/transfers.ts` | `transferStore` | 환적 데이터 (TR-001~003) |
| 6 | `data/mock/gear.ts` | `gearStore` | 어구 데이터 (불법어구 목록) |
| 7 | `data/mock/enforcement.ts` | `enforcementStore` | 단속 이력 + 단속 계획 데이터 |
---
## 8. 작업 완료 요약
| 모듈 | 상태 | 스토어 소비 페이지 수 |
|---|---|---|
| `vessels.ts` | **완료** | 4개 (useVesselStore) |
| `events.ts` | **완료** | 6개 (useEventStore) |
| `patrols.ts` | **완료** | 3개 (usePatrolStore) |
| `kpi.ts` | **완료** | 3개 (useKpiStore) |
| `transfers.ts` | **완료** | 2개 (useTransferStore) |
| `gear.ts` | **완료** | 1개 (useGearStore) |
| `enforcement.ts` | **완료** | 2개 (useEnforcementStore) |
### 실제 작업 결과
- Mock 모듈 생성: 7개 파일 (`src/data/mock/`)
- Zustand 스토어 생성: 7개 + 1개 설정용 (`src/stores/`)
- 기존 페이지 리팩토링: 16개 페이지에서 스토어 소비로 전환
- 나머지 15개 페이지: 도메인 특화 인라인 데이터 유지 (공유 필요성 없음)
---
## 9. 결론
위 1~6절에서 분석한 6개의 심각한 중복/불일치 문제(위험도 스케일, 함정 상태 모순, KPI 중복, 이벤트 불일치, 환적 100% 중복, 선박 교차참조)는 **7개 공유 mock 모듈 + 7개 Zustand 스토어** 도입으로 모두 해소되었다.
달성한 효과:
- **데이터 일관성**: Single Source of Truth로 불일치 원천 차단
- **유지보수성**: 데이터 변경 시 mock 모듈 1곳만 수정
- **확장성**: 신규 페이지 추가 시 기존 store import로 즉시 사용
- **코드 품질**: 중복 인라인 데이터 제거, 16개 페이지가 스토어 기반으로 전환
- **성능**: Zustand lazy loading으로 최초 접근 시에만 mock 데이터 로딩
1~6절의 분석 내용은 통합 전 문제 식별 기록으로 보존한다.

파일 보기

@ -1,194 +0,0 @@
# KCG AI Monitoring - 다음 단계 리팩토링 TODO
> 프론트엔드 UI 스캐폴딩 + 기반 인프라(상태관리, 지도 GPU, mock 데이터, CVA) 완료 상태. 백엔드 연동 및 운영 품질 확보를 위해 남은 항목을 순차적으로 진행한다.
---
## 1. ✅ 상태관리 도입 (Zustand 5.0) — COMPLETED
`zustand` 5.0.12 설치, `src/stores/`에 8개 독립 스토어 구현 완료.
- `vesselStore` — 선박 목록, 선택, 필터
- `patrolStore` — 순찰 경로/함정
- `eventStore` — 탐지/경보 이벤트
- `kpiStore` — KPI 메트릭, 추세
- `transferStore` — 전재(환적)
- `gearStore` — 어구 탐지
- `enforcementStore` — 단속 이력
- `settingsStore` — theme/language + localStorage 동기화, 지도 타일 자동 전환
> `AuthContext`는 유지 (인증은 Context API가 적합, 마이그레이션 불필요로 결정)
---
## 2. API 서비스 계층 (Axios 1.14) — 구조 완성, 실제 연동 대기
### 현재 상태
- `src/services/`에 7개 서비스 모듈 구현 (api, vessel, event, patrol, kpi, ws, index)
- `api.ts`: fetch 래퍼 (`apiGet`, `apiPost`) — 향후 Axios 교체 예정
- 각 서비스가 `data/mock/` 모듈에서 mock 데이터 반환 (실제 HTTP 호출 0건)
- `ws.ts`: STOMP WebSocket 스텁 존재, 미구현
### 남은 작업
- [ ] `axios` 1.14 설치 → `api.ts`의 fetch 래퍼를 Axios 인스턴스로 교체
- [ ] Axios 인터셉터:
- Request: Authorization 헤더 자동 주입
- Response: 401 → 로그인 리다이렉트, 500 → 에러 토스트
- [ ] `@tanstack/react-query` 5.x 설치 → TanStack Query Provider 추가
- [ ] 각 서비스의 mock 반환을 실제 API 호출로 교체
- [ ] 로딩 스켈레톤, 에러 바운더리 공통 컴포넌트
---
## 3. 실시간 인프라 (STOMP.js + SockJS) — 스텁 구조만 존재
### 현재 상태
- `services/ws.ts``connectWs` 스텁 함수 존재 (인터페이스 정의 완료)
- STOMP.js, SockJS 미설치 — 실제 WebSocket 연결 없음
- `useStoreLayerSync` hook으로 store→지도 실시간 파이프라인 준비 완료
### 남은 작업
- [ ] `@stomp/stompjs` + `sockjs-client` 설치
- [ ] `ws.ts` 스텁을 실제 STOMP 클라이언트로 구현
- [ ] 구독 채널 설계:
- `/topic/ais-positions` — 실시간 AIS 위치
- `/topic/alerts` — 경보/이벤트
- `/topic/detections` — 탐지 결과
- `/user/queue/notifications` — 개인 알림
- [ ] 재연결 로직 (지수 백오프)
- [ ] store → `useStoreLayerSync` → 지도 마커 실시간 업데이트 연결
- [ ] `eventStore`와 연동하여 알림 배너/뱃지 카운트 업데이트
---
## 4. ✅ 고급 지도 레이어 (deck.gl 9.2) — COMPLETED
`deck.gl` 9.2.11 + `@deck.gl/mapbox` 설치, MapLibre + deck.gl 인터리브 아키텍처 구현 완료.
- **BaseMap**: `forwardRef` + `memo`, `MapboxOverlay``useImperativeHandle`로 외부 노출
- **useMapLayers**: RAF 배치 레이어 업데이트, React 리렌더 0회
- **useStoreLayerSync**: Zustand store.subscribe → RAF → overlay.setProps (React 우회)
- **STATIC_LAYERS**: EEZ + NLL PathLayer 싱글턴 (GPU 1회 업로드)
- **createMarkerLayer**: ScatterplotLayer + transitions 보간 + DataFilterExtension
- **createRadiusLayer**: 반경 원 표시용 ScatterplotLayer
- 레거시 GeoJSON 레이어(`boundaries.ts`)는 하위 호환으로 유지
> 성능 목표 40만척+ GPU 렌더링 달성. TripsLayer/HexagonLayer/IconLayer는 실데이터 확보 후 추가 예정.
---
## 5. ✅ 더미 데이터 통합 — COMPLETED
`src/data/mock/`에 7개 공유 mock 모듈 구현 완료. TypeScript 인터페이스 정의 포함.
```
data/mock/
├── vessels.ts # VesselData — 선박 목록 (한국, 중국, 경비함)
├── events.ts # EventRecord, AlertRecord — 탐지/단속 이벤트
├── transfers.ts # 전재(환적) 데이터
├── patrols.ts # PatrolShip — 순찰 경로/함정
├── gear.ts # 어구 탐지 데이터
├── kpi.ts # KpiMetric, MonthlyTrend, ViolationType
└── enforcement.ts # 단속 이력 데이터
```
- `services/` 계층이 mock 모듈을 import하여 반환 → 향후 API 교체 시 서비스만 수정
- 인터페이스가 API 응답 타입 계약 역할 수행
---
## 6. i18n 실적용 — 구조 완성, 내부 텍스트 미적용
### 현재 상태
- 10 네임스페이스 리소스 완비: common, dashboard, detection, patrol, enforcement, statistics, ai, fieldOps, admin, auth
- ko/en 각 10파일 (총 20 JSON)
- `settingsStore.toggleLanguage()` + `localStorage` 동기화 구현 완료
- **적용 완료**: MainLayout 사이드바 메뉴명, 24개 페이지 제목, LoginPage
- **미적용**: 각 페이지 내부 텍스트 (카드 레이블, 테이블 헤더, 상태 텍스트 등) — 대부분 한국어 하드코딩 잔존
### 남은 작업
- [ ] 각 feature 페이지 내부 텍스트를 `useTranslation('namespace')` + `t()` 로 교체
- [ ] 날짜/숫자 포맷 로컬라이즈 (`Intl.DateTimeFormat`, `Intl.NumberFormat`)
- [ ] 누락 키 감지 자동화 (i18next missing key handler 또는 lint 규칙)
---
## 7. ✅ Tailwind 공통 스타일 모듈화 (CVA) — COMPLETED
`class-variance-authority` 0.7.1 설치, `src/lib/theme/variants.ts`에 3개 CVA 변형 구현 완료.
- **cardVariants**: default / elevated / inner / transparent — CSS 변수 기반 테마 반응
- **badgeVariants**: 8 intent (critical~cyan) x 4 size (xs~lg) — 150회+ 반복 패턴 통합
- **statusDotVariants**: 4 status (online/warning/danger/offline) x 3 size (sm/md/lg)
- `shared/components/ui/card.tsx`, `badge.tsx`에 CVA 적용 완료
- CSS 변수(`surface-raised`, `surface-overlay`, `border`) 참조로 Dark/Light 자동 반응
---
## 8. 코드 스플리팅 — 미착수
### 현재 상태
- **단일 번들 ~3.2MB** (모든 feature + deck.gl + MapLibre + ECharts 포함)
- `React.lazy` 미적용, 모든 31개 페이지가 동기 import
- 초기 로딩 시 사용하지 않는 페이지 코드까지 전부 다운로드
### 필요한 이유
- 초기 로딩 성능 개선 (FCP, LCP)
- 현장 모바일 환경 (LTE/3G)에서의 사용성 확보
- 번들 캐싱 효율 향상 (변경된 chunk만 재다운로드)
### 구현 계획
- [ ] `React.lazy` + `Suspense`로 feature 단위 동적 임포트:
```typescript
const Dashboard = lazy(() => import('@features/dashboard/Dashboard'));
const RiskMap = lazy(() => import('@features/risk-assessment/RiskMap'));
```
- [ ] `App.tsx` 라우트 전체를 lazy 컴포넌트로 교체
- [ ] 로딩 폴백 컴포넌트 (스켈레톤 또는 스피너) 공통화
- [ ] Vite `build.rollupOptions.output.manualChunks` 설정:
```typescript
manualChunks: {
'vendor-react': ['react', 'react-dom', 'react-router-dom'],
'vendor-map': ['maplibre-gl', 'deck.gl', '@deck.gl/mapbox'],
'vendor-chart': ['echarts'],
}
```
- [ ] 목표: 초기 번들 < 300KB (gzip), feature chunk < 100KB
- [ ] `vite-plugin-compression`으로 gzip/brotli 사전 압축 검토
---
## 9. Light 테마 하드코딩 정리
### 현재 상태
- Dark/Light 테마 전환 구조 완성 (CSS 변수 + `.light` 클래스 + settingsStore)
- 시맨틱 변수(`surface-raised`, `text-heading` 등) + CVA 변형은 정상 작동
- **문제**: 일부 alert/status 색상이 Tailwind 하드코딩 (`bg-red-500/20`, `text-red-400`, `border-red-500/30` 등)
- Dark에서는 자연스러우나, Light 전환 시 대비/가독성 부족
### 구현 계획
- [ ] 하드코딩 alert 색상을 CSS 변수 또는 CVA intent로 교체
- [ ] `badgeVariants`의 intent 색상도 CSS 변수 기반으로 전환 검토
- [ ] Light 모드 전용 대비 테스트 (WCAG AA 기준)
---
## 우선순위 및 의존관계
```
✅ 완료 ─────────────────────────────────────
[1. Zustand] [4. deck.gl] [5. mock 데이터] [7. CVA]
진행 중 / 남은 작업 ──────────────────────────
[6. i18n 내부 텍스트] ──┐
├──▶ [2. API 실제 연동] ──▶ [3. 실시간 STOMP]
[9. Light 테마 정리] ───┘
[8. 코드 스플리팅] ← 독립 작업, 언제든 착수 가능 (~3.2MB → 목표 <300KB)
```
### 권장 진행 순서
1. **Phase A (품질)**: i18n 내부 텍스트 적용 (6) + Light 테마 하드코딩 정리 (9) + 코드 스플리팅 (8)
2. **Phase B (연동)**: Axios 설치 + API 실제 연동 (2)
3. **Phase C (실시간)**: STOMP.js + SockJS 실시간 인프라 (3)

파일 보기

@ -1,436 +0,0 @@
# 페이지 역할표 및 업무 파이프라인
> 최초 작성일: 2026-04-06
> 마지막 업데이트: 2026-04-06
> 대상: `kcg-ai-monitoring` 프론트엔드 31개 페이지
---
## 0. 공통 아키텍처
### 디렉토리 구조
모든 페이지는 `src/features/` 아래 도메인별 디렉토리에 배치되어 있다.
```
src/features/
admin/ AccessControl, AdminPanel, DataHub, NoticeManagement, SystemConfig
ai-operations/ AIAssistant, AIModelManagement, MLOpsPage
auth/ LoginPage
dashboard/ Dashboard
detection/ ChinaFishing, DarkVesselDetection, GearDetection, GearIdentification
enforcement/ EnforcementHistory, EventList
field-ops/ AIAlert, MobileService, ShipAgent
monitoring/ MonitoringDashboard
patrol/ FleetOptimization, PatrolRoute
risk-assessment/ EnforcementPlan, RiskMap
statistics/ ExternalService, ReportManagement, Statistics
surveillance/ LiveMapView, MapControl
vessel/ TransferDetection, VesselDetail
```
### 데이터 흐름
모든 공유 데이터는 **mock -> store -> page** 패턴으로 흐른다.
```
src/data/mock/*.ts --> src/stores/*Store.ts --> src/features/*/*.tsx
(7개 공유 모듈) (7개 Zustand 스토어) (16개 페이지가 스토어 소비)
```
- 스토어는 `load()` 호출 시 `import()`로 mock 데이터를 lazy loading
- 도메인 특화 데이터는 페이지 내 인라인으로 유지 (MLOps, MapControl, DataHub 등)
- 상세 매핑은 `docs/data-sharing-analysis.md` 참조
### 지도 렌더링
지도가 필요한 11개 페이지는 공통 `src/lib/map/` 인프라를 사용한다.
- **deck.gl** 기반 렌더링 (`BaseMap.tsx`)
- **`useMapLayers`** 훅: 페이지별 동적 레이어 구성
- **`STATIC_LAYERS`**: EEZ/KDLZ 등 정적 레이어를 상수로 분리하여 zero rerender 보장
- 사용 페이지: Dashboard, LiveMapView, MapControl, EnforcementPlan, PatrolRoute, FleetOptimization, GearDetection, DarkVesselDetection, RiskMap, VesselDetail, MobileService
### 다국어 (i18n)
- `react-i18next` 기반, 24개 페이지 + MainLayout + LoginPage에 i18n 적용
- 지원 언어: 한국어 (ko), 영어 (en)
- 페이지 타이틀, 주요 UI 라벨이 번역 키로 관리됨
### 테마
- `settingsStore`에서 dark/light 테마 전환 지원
- 기본값: dark (해양 감시 시스템 특성상)
- `localStorage`에 선택 유지, CSS 클래스 토글 방식
---
## 1. 31개 페이지 역할표
### 1.1 인증/관리 (4개)
| SFR | 화면명 | 경로 | 사용자 | 핵심 기능 | 입력 | 출력/액션 | 업스트림 | 다운스트림 |
|---|---|---|---|---|---|---|---|---|
| SFR-01 | LoginPage | `/login` | 전체 | SSO/GPKI/비밀번호 인증, 5회 실패 잠금 | ID/PW, 인증 방식 선택 | 세션 발급, 역할 부여 | - | 모든 페이지 (인증 게이트) |
| SFR-01 | AccessControl | `/access-control` | 관리자 | RBAC 권한 관리, 감사 로그 | 역할/사용자/권한 설정 | 권한 변경, 감사 기록 | LoginPage | 전체 시스템 접근 제어 |
| SFR-02 | SystemConfig | `/system-config` | 관리자 | 공통코드 기준정보 관리 (해역52/어종578/어업59/선박186) | 코드 검색/필터 | 코드 조회, 설정 변경 | AccessControl | 탐지/분석 엔진 기준데이터 |
| SFR-02 | NoticeManagement | `/notices` | 관리자 | 시스템 공지(배너/팝업/토스트), 역할별 대상 설정 | 공지 작성, 기간/대상 설정 | 배너/팝업 노출 | AccessControl | 모든 페이지 (NotificationBanner) |
### 1.2 데이터 수집/연계 (1개)
| SFR | 화면명 | 경로 | 사용자 | 핵심 기능 | 입력 | 출력/액션 | 업스트림 | 다운스트림 |
|---|---|---|---|---|---|---|---|---|
| SFR-03 | DataHub | `/data-hub` | 관리자 | 통합데이터 허브 — 선박신호 수신 현황 히트맵, 연계 채널 모니터링 | 수신 소스 선택 | 수신률 조회, 연계 상태 확인 | 외부 센서 (VTS, AIS, V-PASS 등) | 탐지 파이프라인 전체 |
### 1.3 AI 모델/운영 (3개)
| SFR | 화면명 | 경로 | 사용자 | 핵심 기능 | 입력 | 출력/액션 | 업스트림 | 다운스트림 |
|---|---|---|---|---|---|---|---|---|
| SFR-04 | AIModelManagement | `/ai-model` | 분석관 | 모델 레지스트리, 탐지 규칙, 피처 엔지니어링, 학습 파이프라인, 7대 탐지엔진 | 모델 버전/규칙/피처 설정 | 모델 배포, 성능 리포트 | DataHub (학습 데이터) | DarkVessel, GearDetection, TransferDetection 등 탐지 엔진 |
| SFR-18/19 | MLOpsPage | `/mlops` | 분석관/관리자 | MLOps/LLMOps 운영 대시보드 (실험, 배포, API Playground, LLM 테스트) | 실험 템플릿, HPS 설정 | 실험 결과, 모델 배포 | AIModelManagement | AIAssistant, 탐지 엔진 |
| SFR-20 | AIAssistant | `/ai-assistant` | 상황실/분석관 | 자연어 Q&A 의사결정 지원 (법령 조회, 대응 절차 안내) | 자연어 질의 | 답변 + 법령 참조 | MLOpsPage (LLM 모델) | 작전 의사결정 |
### 1.4 탐지 (4개)
| SFR | 화면명 | 경로 | 사용자 | 핵심 기능 | 입력 | 출력/액션 | 업스트림 | 다운스트림 |
|---|---|---|---|---|---|---|---|---|
| SFR-09 | DarkVesselDetection | `/dark-vessel` | 분석관 | AIS 조작/위장/Dark Vessel 패턴 탐지 (6가지 패턴), 지도+테이블 | AIS 데이터 스트림 | 의심 선박 목록, 위험도, 라벨 분류 | DataHub (AIS/레이더) | RiskMap, LiveMapView, EventList |
| SFR-10 | GearDetection | `/gear-detection` | 분석관 | 불법 어망/어구 탐지 및 관리, 허가 상태 판정 | 어구 센서/영상 | 어구 목록, 불법 판정 결과 | DataHub (센서) | RiskMap, EnforcementPlan |
| - | GearIdentification | `features/detection/` | 분석관 | 어구 국적 판별 (중국/한국/불확실), GB/T 5147 기준 | 어구 물리적 특성 입력 | 판별 결과 (국적, 신뢰도, 경보등급) | GearDetection | EnforcementHistory |
| - | ChinaFishing | `/china-fishing` | 분석관/상황실 | 중국어선 통합 감시 (센서 카운터, 특이운항, 월별 통계, 환적 탐지, VTS 연계) | 센서 데이터 융합 | 감시 현황, 환적 의심 목록 | DataHub, DarkVessel | RiskMap, EnforcementPlan |
### 1.5 환적 탐지 (1개)
| SFR | 화면명 | 경로 | 사용자 | 핵심 기능 | 입력 | 출력/액션 | 업스트림 | 다운스트림 |
|---|---|---|---|---|---|---|---|---|
| - | TransferDetection | `features/vessel/` | 분석관 | 선박 간 근접 접촉 및 환적 의심 행위 분석 (거리/시간/속도 기준) | AIS 궤적 분석 | 환적 이벤트 목록, 의심도 점수 | DataHub, DarkVessel | EventList, EnforcementPlan |
### 1.6 위험도 평가/계획 (2개)
| SFR | 화면명 | 경로 | 사용자 | 핵심 기능 | 입력 | 출력/액션 | 업스트림 | 다운스트림 |
|---|---|---|---|---|---|---|---|---|
| SFR-05 | RiskMap | `/risk-map` | 분석관/상황실 | 격자 기반 불법조업 위험도 지도 + MTIS 해양사고 통계 연계 | 탐지 결과, 사고 통계 | 히트맵, 해역별 위험도, 사고 통계 차트 | DarkVessel, GearDetection, ChinaFishing | EnforcementPlan, PatrolRoute |
| SFR-06 | EnforcementPlan | `/enforcement-plan` | 상황실 | 단속 계획 수립, 경보 연계, 우선지역 예보 | 위험도 데이터, 가용 함정 | 단속 계획 테이블, 지도 표시 | RiskMap | PatrolRoute, FleetOptimization |
### 1.7 순찰/함대 (2개)
| SFR | 화면명 | 경로 | 사용자 | 핵심 기능 | 입력 | 출력/액션 | 업스트림 | 다운스트림 |
|---|---|---|---|---|---|---|---|---|
| SFR-07 | PatrolRoute | `/patrol-route` | 상황실 | AI 단일 함정 순찰 경로 추천 (웨이포인트, 거리/시간/연료 산출) | 함정 선택, 구역 조건 | 추천 경로, 웨이포인트 목록 | EnforcementPlan, RiskMap | 함정 출동 (ShipAgent) |
| SFR-08 | FleetOptimization | `/fleet-optimization` | 상황실 | 다함정 협력형 경로 최적화 (커버리지 시뮬레이션, 승인 워크플로) | 함대 목록, 구역 조건 | 최적화 결과, 커버리지 비교 | EnforcementPlan, PatrolRoute | 함정 출동 (ShipAgent) |
### 1.8 감시/지도 (2개)
| SFR | 화면명 | 경로 | 사용자 | 핵심 기능 | 입력 | 출력/액션 | 업스트림 | 다운스트림 |
|---|---|---|---|---|---|---|---|---|
| - | LiveMapView | `/events` | 상황실 | 실시간 해역 감시 지도 (AIS 선박 + 이벤트 경보 + 아군 함정) | 실시간 AIS/이벤트 스트림 | 지도 마커, 이벤트 카드, 위험도 바 | 탐지 엔진 전체 | EventList, AIAlert |
| - | MapControl | `/map-control` | 상황실/관리자 | 해역 통제 관리 (해상사격 훈련구역도 No.462, 군/해경 구역) | 구역 데이터 | 훈련구역 지도, 상태 테이블 | 국립해양조사원 데이터 | LiveMapView (레이어) |
### 1.9 대시보드/모니터링 (2개)
| SFR | 화면명 | 경로 | 사용자 | 핵심 기능 | 입력 | 출력/액션 | 업스트림 | 다운스트림 |
|---|---|---|---|---|---|---|---|---|
| - | Dashboard | `/dashboard` | 전체 | 종합 상황판 (KPI, 타임라인, 위험선박 TOP8, 함정 현황, 해역 위험도, 시간대별 탐지 추이) | 전 시스템 데이터 집계 | 한눈에 보는 현황 | 탐지/순찰/이벤트 전체 | 각 상세 페이지로 드릴다운 |
| SFR-12 | MonitoringDashboard | `/monitoring` | 상황실 | 모니터링 및 경보 현황판 (KPI, 24시간 추이, 탐지 유형 분포, 실시간 이벤트) | 경보/탐지 데이터 | 경보 현황 대시보드 | 탐지 엔진, EventList | AIAlert, EnforcementPlan |
### 1.10 이벤트/이력 (2개)
| SFR | 화면명 | 경로 | 사용자 | 핵심 기능 | 입력 | 출력/액션 | 업스트림 | 다운스트림 |
|---|---|---|---|---|---|---|---|---|
| - | EventList | `/event-list` | 상황실/분석관 | 이벤트 전체 목록 (검색/정렬/페이징/엑셀/출력), 15건+ 이벤트 | 필터 조건 | 이벤트 테이블, 엑셀 내보내기 | 탐지 엔진, LiveMapView | EnforcementHistory, ReportManagement |
| SFR-11 | EnforcementHistory | `/enforcement-history` | 분석관 | 단속/탐지 이력 관리 (AI 매칭 검증 포함) | 검색 조건 | 이력 테이블, AI 일치 여부 | EventList, 현장 단속 | ReportManagement, Statistics |
### 1.11 현장 대응 (3개)
| SFR | 화면명 | 경로 | 사용자 | 핵심 기능 | 입력 | 출력/액션 | 업스트림 | 다운스트림 |
|---|---|---|---|---|---|---|---|---|
| SFR-15 | MobileService | `/mobile-service` | 현장 단속요원 | 모바일 앱 프리뷰 (위험도/의심선박/경로추천/경보, 푸시 설정) | 모바일 위치, 푸시 설정 | 경보 수신, 지도 조회 | AIAlert, LiveMapView | 현장 단속 수행 |
| SFR-16 | ShipAgent | `/ship-agent` | 현장 단속요원 | 함정용 Agent 관리 (배포/동기화 상태, 버전 관리) | 함정 Agent 설치 | Agent 상태 조회, 동기화 | PatrolRoute, FleetOptimization | 현장 단속 수행 |
| SFR-17 | AIAlert | `/ai-alert` | 상황실/현장 | AI 탐지 알림 자동 발송 (함정/관제요원 대상, 탐지시각/좌표/유형/신뢰도 포함) | 탐지 이벤트 트리거 | 알림 발송, 수신 확인 | MonitoringDashboard, EventList | MobileService, ShipAgent |
### 1.12 통계/외부연계/보고 (3개)
| SFR | 화면명 | 경로 | 사용자 | 핵심 기능 | 입력 | 출력/액션 | 업스트림 | 다운스트림 |
|---|---|---|---|---|---|---|---|---|
| SFR-13 | Statistics | `/statistics` | 상황실/분석관 | 통계/지표/성과 분석 (월별 추이, 위반유형, KPI 달성률) | 기간/유형 필터 | 차트, KPI 테이블, 보고서 | EnforcementHistory, EventList | 외부 보고, 전략 수립 |
| SFR-14 | ExternalService | `/external-service` | 관리자/외부 | 외부 서비스 제공 (해수부/수협/기상청 API/파일 연계, 비식별/익명화 정책) | 서비스 설정 | API 호출 수, 연계 상태 | Statistics, 탐지 결과 | 외부기관 |
| - | ReportManagement | `/reports` | 분석관/상황실 | 증거 관리 및 보고서 생성 (사건별 자동 패키징) | 사건 선택, 증거 파일 업로드 | 보고서 PDF, 증거 패키지 | EnforcementHistory, EventList | 검찰/외부기관 |
### 1.13 선박 상세 (1개)
| SFR | 화면명 | 경로 | 사용자 | 핵심 기능 | 입력 | 출력/액션 | 업스트림 | 다운스트림 |
|---|---|---|---|---|---|---|---|---|
| - | VesselDetail | `/vessel/:id` | 분석관/상황실 | 선박 상세 정보 (AIS 데이터, 항적, 입항 이력, 선원 정보, 비허가 선박 목록) | 선박 ID/MMSI | 상세 프로필, 지도 항적 | LiveMapView, DarkVessel, EventList | EnforcementPlan, ReportManagement |
### 1.14 시스템 관리 (1개)
| SFR | 화면명 | 경로 | 사용자 | 핵심 기능 | 입력 | 출력/액션 | 업스트림 | 다운스트림 |
|---|---|---|---|---|---|---|---|---|
| - | AdminPanel | `/admin` | 관리자 | 시스템 인프라 관리 (서버 상태, CPU/메모리/디스크 모니터링) | - | 서버 상태 대시보드 | - | 시스템 안정성 보장 |
---
## 2. 업무 파이프라인 (4개)
### 2.1 탐지 파이프라인
불법 조업을 탐지하고 실시간 감시하여 현장 작전까지 연결하는 핵심 파이프라인.
```
AIS/레이더/위성 신호
┌─────────┐
│ DataHub │ ← 통합데이터 허브 (VTS, AIS, V-PASS, E-Nav 수집)
└────┬────┘
┌──────────────────────────────────────────────┐
│ AI 탐지 엔진 (AIModelManagement 관리) │
│ │
│ DarkVesselDetection ─ AIS 조작/위장/소실 │
│ GearDetection ─────── 불법 어구 탐지 │
│ ChinaFishing ──────── 중국어선 통합 감시 │
│ TransferDetection ─── 환적 행위 탐지 │
│ GearIdentification ── 어구 국적 판별 │
└──────────────┬───────────────────────────────┘
┌──────────┐ ┌───────────────────┐
│ RiskMap │─────▶│ LiveMapView │ ← 실시간 지도 감시
└────┬─────┘ │ MonitoringDashboard│ ← 경보 현황판
│ └───────────────────┘
┌──────────────────┐
│ EnforcementPlan │ ← 단속 우선지역 예보
└────────┬─────────┘
┌──────────────┐ ┌───────────────────┐
│ PatrolRoute │─────▶│ FleetOptimization │ ← 다함정 최적화
└──────┬───────┘ └─────────┬─────────┘
│ │
▼ ▼
┌──────────┐
│ AIAlert │ ← 함정/관제 자동 알림 발송
└────┬─────┘
현장 작전 (MobileService, ShipAgent)
```
### 2.2 대응 파이프라인
AI 알림 수신 후 현장 단속, 이력 기록, 보고서 생성까지의 대응 프로세스.
```
┌──────────┐
│ AIAlert │ ← AI 탐지 알림 자동 발송
└────┬─────┘
┌──────────────────────────────────┐
│ 현장 대응 │
│ │
│ MobileService ── 모바일 경보 수신│
│ ShipAgent ────── 함정 Agent 연동 │
└──────────────┬───────────────────┘
현장 단속 수행
(정선/검문/나포/퇴거)
┌──────────────────────┐
│ EnforcementHistory │ ← 단속 이력 등록, AI 매칭 검증
└──────────┬───────────┘
┌──────────────────────┐
│ ReportManagement │ ← 증거 패키징, 보고서 생성
└──────────┬───────────┘
검찰/외부기관 (ExternalService 통해 연계)
```
### 2.3 분석 파이프라인
축적된 데이터를 분석하여 전략적 의사결정을 지원하는 파이프라인.
```
┌─────────────┐
│ Statistics │ ← 월별 추이, 위반유형, KPI 달성률
└──────┬──────┘
┌──────────┐
│ RiskMap │ ← 격자 위험도 + MTIS 해양사고 통계
└────┬─────┘
┌──────────────┐
│ VesselDetail │ ← 개별 선박 심층 분석 (항적, 이력)
└──────┬───────┘
┌──────────────┐
│ AIAssistant │ ← 자연어 Q&A (법령 조회, 대응 절차)
└──────┬───────┘
전략 수립 (순찰 패턴, 탐지 규칙 조정)
```
### 2.4 관리 파이프라인
시스템 접근 제어, 환경 설정, 데이터 관리, 인프라 모니터링 파이프라인.
```
┌────────────────┐
│ AccessControl │ ← RBAC 역할/권한 설정
└───────┬────────┘
┌────────────┐
│ LoginPage │ ← SSO/GPKI/비밀번호 인증
└──────┬─────┘
┌──────────────────────────────────────┐
│ 시스템 설정/관리 │
│ │
│ SystemConfig ──── 공통코드/환경설정 │
│ NoticeManagement ── 공지/배너/팝업 │
│ DataHub ────────── 데이터 수집 관리 │
│ AdminPanel ────── 서버/인프라 모니터 │
└──────────────────────────────────────┘
```
---
## 3. 사용자 역할별 페이지 접근 매트릭스
시스템에 정의된 5개 역할(LoginPage의 `DEMO_ACCOUNTS` 및 AccessControl의 `ROLES` 기반)에 대한 페이지 접근 권한.
### 3.1 역할 정의
| 역할 | 코드 | 설명 | 인원(시뮬) |
|---|---|---|---|
| 시스템 관리자 | `ADMIN` | 전체 시스템 관리 권한 | 3명 |
| 상황실 운영자 | `OPERATOR` | 상황판, 통계, 경보 운영 | 12명 |
| 분석 담당자 | `ANALYST` | AI 모델, 통계, 항적 분석 | 8명 |
| 현장 단속요원 | `FIELD` | 함정 Agent, 모바일 대응 | 45명 |
| 유관기관 열람자 | `VIEWER` | 공유 대시보드 열람 | 6명 |
### 3.2 접근 매트릭스
| 페이지 | ADMIN | OPERATOR | ANALYST | FIELD | VIEWER |
|---|---|---|---|---|---|
| **인증/관리** | | | | | |
| LoginPage | O | O | O | O | O |
| AccessControl | O | - | - | - | - |
| SystemConfig | O | - | - | - | - |
| NoticeManagement | O | - | - | - | - |
| AdminPanel | O | - | - | - | - |
| **데이터/AI** | | | | | |
| DataHub | O | - | - | - | - |
| AIModelManagement | O | - | O | - | - |
| MLOpsPage | O | - | O | - | - |
| AIAssistant | O | O | O | - | - |
| **탐지** | | | | | |
| DarkVesselDetection | O | - | O | - | - |
| GearDetection | O | - | O | - | - |
| ChinaFishing | O | O | O | - | - |
| TransferDetection | O | - | O | - | - |
| **위험도/계획** | | | | | |
| RiskMap | O | O | O | - | - |
| EnforcementPlan | O | O | - | - | - |
| **순찰** | | | | | |
| PatrolRoute | O | O | - | - | - |
| FleetOptimization | O | O | - | - | - |
| **감시/지도** | | | | | |
| LiveMapView | O | O | O | - | - |
| MapControl | O | O | - | - | - |
| **대시보드** | | | | | |
| Dashboard | O | O | O | O | O |
| MonitoringDashboard | O | O | - | - | - |
| **이벤트/이력** | | | | | |
| EventList | O | O | O | O | - |
| EnforcementHistory | O | - | O | - | - |
| **현장 대응** | | | | | |
| MobileService | O | - | - | O | - |
| ShipAgent | O | - | - | O | - |
| AIAlert | O | O | - | O | - |
| **통계/보고** | | | | | |
| Statistics | O | O | O | - | - |
| ExternalService | O | - | - | - | O |
| ReportManagement | O | O | O | - | - |
| **선박 상세** | | | | | |
| VesselDetail | O | O | O | - | - |
### 3.3 역할별 요약
| 역할 | 접근 가능 페이지 | 페이지 수 |
|---|---|---|
| **시스템 관리자** (ADMIN) | 전체 페이지 | 31 |
| **상황실 운영자** (OPERATOR) | Dashboard, MonitoringDashboard, LiveMapView, MapControl, EventList, EnforcementPlan, PatrolRoute, FleetOptimization, ChinaFishing, RiskMap, Statistics, ReportManagement, AIAssistant, AIAlert, VesselDetail | 15 |
| **분석 담당자** (ANALYST) | Dashboard, DarkVesselDetection, GearDetection, ChinaFishing, TransferDetection, RiskMap, LiveMapView, EventList, EnforcementHistory, Statistics, ReportManagement, VesselDetail, AIAssistant, AIModelManagement, MLOpsPage | 15 |
| **현장 단속요원** (FIELD) | Dashboard, MobileService, ShipAgent, AIAlert, EventList | 5 |
| **유관기관 열람자** (VIEWER) | Dashboard, ExternalService | 2 |
---
## 4. 페이지 간 데이터 흐름 요약
```
┌──────────────────┐
│ LoginPage │
│ (인증 게이트) │
└────────┬─────────┘
┌────────────────────┬┴──────────────────┐
▼ ▼ ▼
┌──────────────┐ ┌─────────────────┐ ┌─────────────┐
│ 관리 파이프라인│ │ 탐지 파이프라인 │ │ 현장 대응 │
│ │ │ │ │ │
│ AccessControl│ │ DataHub │ │ MobileSvc │
│ SystemConfig │ │ ↓ │ │ ShipAgent │
│ NoticeManage │ │ AI탐지엔진 │ │ AIAlert │
│ DataHub │ │ (DV/Gear/CN/TR)│ └──────┬──────┘
│ AdminPanel │ │ ↓ │ │
└──────────────┘ │ RiskMap │ │
│ ↓ │ ▼
│ EnforcementPlan │ ┌──────────────┐
│ ↓ │ │ 대응 파이프라인│
│ PatrolRoute │ │ │
│ FleetOptim │ │ Enforcement │
│ ↓ │ │ History │
│ LiveMapView │ │ ReportManage │
│ Monitoring │ │ ExternalSvc │
└────────┬────────┘ └──────────────┘
┌─────────────────┐
│ 분석 파이프라인 │
│ │
│ Statistics │
│ VesselDetail │
│ AIAssistant │
└─────────────────┘
```
---
## 5. 미할당 SFR 참고
현재 라우트에서 확인되는 SFR 번호 기준, 아래 기능은 기존 페이지에 통합되어 있다:
- **Dashboard**: SFR 번호 미부여, 종합 상황판 (기존 유지)
- **LiveMapView**: SFR 번호 미부여, 실시간 감시 지도
- **EventList**: SFR-02 공통 컴포넌트 적용 대상으로 분류
- **MapControl**: SFR 번호 미부여, 해역 통제 관리
- **VesselDetail**: SFR 번호 미부여, 선박 상세
- **ReportManagement**: SFR 번호 미부여, 증거/보고서 관리
- **AdminPanel**: SFR 번호 미부여, 인프라 관리
- **GearIdentification**: ChinaFishing 내 서브 컴포넌트

250
docs/prediction-analysis.md Normal file
파일 보기

@ -0,0 +1,250 @@
# Prediction 모듈 심층 분석 — 구조·방향 리뷰
**대상:** `prediction/` (Python 3.11+, FastAPI, APScheduler, 59 `.py` 파일)
**작성일:** 2026-04-17
**작성 관점:** opus 4.7 독립 리뷰 — 정밀도 튜닝이 아닌 **방향성·코드 구조**
**전제:** 프로토타입·데모 단계. 정밀도 미흡은 인지된 상태.
---
## TL;DR — 3줄 요약
1. **뼈대는 튼튼하다.** 레이어 분리(algorithms/pipeline/output/db/cache), 순수함수 위주 알고리즘, 카테고리별 dedup 윈도우 분리까지 프로토타입치고는 일관된 설계.
2. **약한 고리는 오케스트레이터.** [scheduler.py run_analysis_cycle()](../prediction/scheduler.py) 한 함수가 700+ 라인, 지역 try/except + `logger.warning` 로 흡수된 실패가 많아 "어디서 깨졌는지 조용히 묻힌다". 상태 누적(모듈 전역 `_transship_pair_history`)도 여기 묶여 있음.
3. **커버리지 매트릭스는 4/4 이지만 UI 비대칭.** prediction 이 생산하는 결과 중 `ILLEGAL_FISHING_PATTERN` 이벤트·환적 의심은 DB·백엔드까지 도달하지만 전용 detection UI 가 없다. prediction 품질 개선과 무관하게 운영자가 쓸 수 없는 상태.
**권고 최우선 3가지** — 신규 알고리즘 추가보다 아래가 선행:
- **P1:** 사이클 스테이지 단위 에러 경계(`_stage(...)` 유틸)로 교체해 실패 스테이지 명시 로깅 + 부분 실패 시에도 후속 단계 진행
- **P1:** 하드코딩 임계값(MID prefix, 커버리지 박스, SOG band, 11 pattern 점수) 을 `correlation_param_models` 패턴처럼 DB/config 로 외부화
- **P1:** 환적 전용 + ILLEGAL_FISHING_PATTERN 전용 프론트 페이지 추가 — 이미 DB·API 는 있음
---
## 1. 아키텍처 레이어 — 책임과 결합도
```
prediction/
├── config.py Pydantic Settings + qualified_table() — SSOT 설정
├── scheduler.py APScheduler + run_analysis_cycle() (단일 엔트리)
├── main.py FastAPI app + /health /status /chat 등
├── fleet_tracker.py 상태 보유 (선단 레지스트리, 어구 정체성)
├── time_bucket.py 안전 지연 12분 + backfill 3 버킷
├── algorithms/ 17개 모듈 — 순수함수 중심
├── pipeline/ 8개 모듈 — 7단계 분류 파이프라인
├── output/ 5개 모듈 — event/violation/kpi/stats/alert
├── db/ 4개 모듈 — snpdb / kcgdb / signal_api / partition_manager
├── cache/ vessel_store.py — 24h sliding window 인메모리
├── chat/ Ollama + RAG 스텁
├── models/ result.py — AnalysisResult dataclass
├── data/ monitoring zones JSON 정적 설정
└── tests/ time_bucket / gear_parent_episode / gear_parent_inference 3종
```
| 레이어 | 책임 | 결합도 평가 |
|---|---|---|
| `config.py` | 환경 + SQL identifier 검증 | ✅ SSOT, `qualified_table()` 로 스키마 주입 공격 방지 |
| `algorithms/` | 탐지 로직 (순수) | ✅ 대부분 `df, params -> dict/tuple`. 상호 의존 적음 |
| `pipeline/` | 7단계 sequential | ✅ `orchestrator.ChineseFishingVesselPipeline.run()` 이 DF 를 그대로 파이프 |
| `output/` | 룰 엔진 + DB write | ✅ 룰을 lambda 리스트(event_generator.RULES)로 선언적 관리 |
| `db/` | Connection pool + SQL | ⚠️ `kcgdb.upsert_results(results)` 가 한 트랜잭션에 전부 묶임 (파티션 unique index 활용은 적절) |
| `cache/vessel_store.py` | 전역 싱글턴, 24h 궤적 인메모리 | ⚠️ 모듈 싱글턴 → 테스트 시 mock 어려움 |
| `fleet_tracker.py` | 레지스트리·어구 정체성 상태 | ⚠️ 싱글턴 + 모듈 전역 캐시 |
| `scheduler.py` | 전체 오케스트레이션 | ❌ **700+ 라인 모놀리식** — 아래 §2 상세 |
---
## 2. 5분 사이클 시퀀스 — [scheduler.py:80-804](../prediction/scheduler.py#L80-L804)
사이클 전체가 **한 함수** 안에서 9단계로 진행된다.
| 단계 | 라인 | 역할 | 실패 처리 |
|---|---|---|---|
| 1. 증분 로드 | [97-106](../prediction/scheduler.py#L97-L106) | `snpdb.fetch_incremental()` → vessel_store merge | 전체 try/except 포함 |
| 2. 정적 보강 | [108-112](../prediction/scheduler.py#L108-L112) | signal-batch API 호출 | 전체 try/except |
| 3. 대상 선별 | [114-128](../prediction/scheduler.py#L114-L128) | SOG/COG 계산 + 0건 시 조기 return | ✅ 조기 반환 |
| 4. 파이프라인 | [122-128](../prediction/scheduler.py#L122-L128) | `ChineseFishingVesselPipeline.run()` | 전체 try/except |
| 5. 선단 분석 | [131-177](../prediction/scheduler.py#L131-L177) | fleet_tracker + gear_identity 충돌 감지 | ⚠️ 내부 try/except 로 warning, 전진 |
| 5.5. 어구 그룹·상관·부모 추론 | [181-229](../prediction/scheduler.py#L181-L229) | polygon_builder + gear_correlation | ⚠️ 내부 try/except, 결과 없이 진행 |
| 5.9. 쌍끌이 후보·판정 | [231-303](../prediction/scheduler.py#L231-L303) | pair_trawl STRONG/PROBABLE/SUSPECT | ⚠️ 내부 try/except |
| 6. 개별 선박 분석 | [305-515](../prediction/scheduler.py#L305-L515) | AnalysisResult 생성 (파이프라인 통과자) | 루프 내 continue |
| 6.5. 경량 분석 | [523-682](../prediction/scheduler.py#L523-L682) | 파이프라인 미통과 중국 MID — `compute_lightweight_risk_score` | 루프 내 try/except |
| 7. 환적 의심 | [685-713](../prediction/scheduler.py#L685-L713) | `detect_transshipment` + `_transship_pair_history` 누적 | 전체 try/except |
| 8. DB upsert | [716-717](../prediction/scheduler.py#L716-L717) | `kcgdb.upsert_results()` + `cleanup_old(48h)` | 전체 try/except |
| 9. 출력 모듈 | [720-745](../prediction/scheduler.py#L720-L745) | violation_classifier → event_generator → kpi_writer → aggregate_hourly/daily → alert_dispatcher | ⚠️ **5개 단계를 한 try/except 로 묶음** → 어디서 실패했는지 단일 warning 으로 흡수 |
| 10. 채팅 컨텍스트 캐싱 | [748-791](../prediction/scheduler.py#L748-L791) | Redis | 전체 try/except |
### 구조적 관찰
- **전체 try/except 는 있다** ([97](../prediction/scheduler.py#L97) ~ [803](../prediction/scheduler.py#L803)) — 치명 실패가 다음 사이클을 막지는 않음
- **그러나 내부 스테이지가 너무 무겁다.** 9번째 출력 단계가 5개 모듈을 한 덩어리로 묶어 `logger.warning('output modules failed (non-fatal): %s', e)` 로 흡수. 어느 모듈이 깨졌는지 디버깅하려면 stacktrace 를 `logger.exception` 으로 바꿔야 함
- **Lazy import** 가 스테이지마다 반복 (`from output.event_generator import ...` 등). 시작 시간 단축에는 도움이지만 import 오류가 **첫 사이클 실행 시점에만 드러남** — 배포 후 5분 지연 발견 경험 다수
### 권고 (사이클 구조 재정비)
```python
def _stage(name: str, fn, *args, required=False, **kwargs):
t0 = time.time()
try:
result = fn(*args, **kwargs)
logger.info('stage %s ok in %.2fs', name, time.time() - t0)
return result
except Exception as e:
logger.exception('stage %s failed: %s', name, e)
if required:
raise
return None
```
- 각 스테이지를 `_stage('pair_detection', _run_pair_detection, ...)` 로 감싸면 실패 스테이지 명시 로깅 + stacktrace + 부분 실패 허용 정책을 일관화.
- 9번 단계의 5개 모듈은 각각 별도 `_stage(...)` 호출로 쪼갤 것.
---
## 3. 알고리즘 카탈로그 (17 모듈 × 담당 도메인)
| 파일 | 주 역할 | 입력 | 출력 | 주요 상수 |
|---|---|---|---|---|
| [dark_vessel.py](../prediction/algorithms/dark_vessel.py) | AIS gap + 11 패턴 점수화 | 선박 DF(timestamp, lat, lon, sog, cog) | (score 0~100, patterns[], tier) | GAP_SUSPICIOUS=6000s, VIOLATION=86400s, KR_COVERAGE box |
| [spoofing.py](../prediction/algorithms/spoofing.py) | 텔레포트·극속·BD09 오프셋 | 선박 DF | spoofing_score 0~1 | EXTREME_SPEED=50kn (주석 기준), fishing max=25kn |
| [risk.py](../prediction/algorithms/risk.py) | 종합 risk + 경량 risk 2종 | DF, zone, is_permitted, 외부 점수 | (risk 0~100, level) | tier: 70+=CRITICAL, 50+=HIGH, 30+=MEDIUM |
| [fishing_pattern.py](../prediction/algorithms/fishing_pattern.py) | UCAF/UCFT gear SOG band | DF, gear | (ucaf, ucft) | PT 2.5~4.5, OT 2.0~4.0, GN 0.5~2.5 |
| [transshipment.py](../prediction/algorithms/transshipment.py) | 5단계 필터 파이프라인 | DF targets, pair_history, zone_fn | list of dict | PROXIMITY ~220m, RENDEZVOUS 90min, WATCH 제외 |
| [location.py](../prediction/algorithms/location.py) | zone 분류, haversine_nm, BD09 | (lat, lon) | zone, dist_nm | 12/24 NM 밴드 |
| [gear_correlation.py](../prediction/algorithms/gear_correlation.py) | 멀티모델 EMA + streak | vessel_store, gear_groups, conn | UPDATE gear_correlation_scores | α_base=0.30, polygon=0.70 |
| [gear_identity.py](../prediction/algorithms/gear_identity.py) | 공존 쌍 추출 (V030/PR #73) | gear_signals | collisions[] | CRITICAL_DIST=50km, COEXIST=3회 |
| [gear_parent_inference.py](../prediction/algorithms/gear_parent_inference.py) | 어구 → 모선 assignment | gear_groups + tracks | parent 후보 + confidence | 2-pass: direct-match → candidates |
| [gear_parent_episode.py](../prediction/algorithms/gear_parent_episode.py) | 에피소드 delineation (first_seen~last_seen) | gear_signals 시계열 | episodes[] | gap tolerance |
| [gear_violation.py](../prediction/algorithms/gear_violation.py) | G-01~G-06 통합 판정 (DAR-03) | DF + zone + pair_result + permits | g_codes[], evidence, score | G-06=20pts, G-02=18pts, G-01=15pts |
| [gear_name_rules.py](../prediction/algorithms/gear_name_rules.py) | 어구 이름 정규표현식 | string | parent_code (Optional) | regex set |
| [pair_trawl.py](../prediction/algorithms/pair_trawl.py) | 쌍끌이 tier 분류 | DF×2, 6h | (pair_detected, tier, pair_type) | PROXIMITY=0.27NM, MIN_SYNC=2h |
| [track_similarity.py](../prediction/algorithms/track_similarity.py) | DTW 궤적 유사도 | DF×2 | 0~1 | - |
| [fleet.py](../prediction/algorithms/fleet.py) | leader/follower/independent | DF, tracker | role | - |
| [polygon_builder.py](../prediction/algorithms/polygon_builder.py) | gear group convex hull | vessel_store, companies | 스냅샷[] | 시간버킷 |
| [vessel_type_mapping.py](../prediction/algorithms/vessel_type_mapping.py) | fishery_code → vessel_type 폴백 | fishery_code | 'TRAWL'/'PT'/... | - |
**관찰:**
- 대부분 **순수 함수** → 재사용·단위테스트 용이. 단 `gear_correlation.run_gear_correlation``conn` 을 받아 DB 를 직접 UPDATE 함 (알고리즘 + I/O 혼재)
- **상수가 모듈 상단에 모여 있는 것은 좋으나 config 외부화는 안 됨**. 현장 운영자가 임계값을 바꾸려면 코드 배포 필요
---
## 4. 4대 도메인 커버리지 매트릭스
| 도메인 | 담당 파일 | 접근 방식 | 구조적 강점 | 구조적 공백 |
|---|---|---|---|---|
| **Dark Vessel** | `dark_vessel.py` + `risk.py` + `compute_dark_suspicion` | gap(100분 임계) + 11 패턴 점수 + 4-tier (CRITICAL≥70) | 패턴 분리(P1~P11) + 한국 커버리지 감점(50pt)으로 자연 gap 구분 | MID/커버리지 박스 하드코딩. 감점 로직은 탐지 누락 위험(coverage box 바깥=자연 gap 가정) — 중국 EEZ 해역 근접 공해에서 의도적 OFF 가 감점 맞을 수 있음 |
| **Gear Tracking** | `gear_correlation` + `gear_identity` + `gear_parent_inference` + `polygon_builder` | 어구 이름 regex → 공존/교체 분기 + 멀티모델 EMA + 모선 추론 + 폴리곤 | 공존(GEAR_IDENTITY_COLLISION)을 1순위 증거로 재정의 (V030). SAVEPOINT 로 PK 충돌 격리 | 어구 신호는 **이름 regex 의존**. 이름이 GEAR_PATTERN 에 맞지 않으면 파이프라인 전체 진입 실패 |
| **Chinese Fishing** | `pipeline/` 7단계 + MID 412/413/414 필터 | preprocess → behavior → resample → feature → classify → cluster → seasonal + rule-based 분류 | 룰 + 군집으로 해석 가능 | MID prefix 하드코딩 2곳 중복. 전체 인구(55k)대비 500척만 파이프라인 통과 — 경량 분석(§6)으로 보완하나 여전히 <10% |
| **Illegal Pattern** | `gear_violation` (G-01~G-06) + `event_generator.RULES` (15+ 룰) + `transshipment` 5단계 | 임계값 기반 독립 룰 × 카테고리별 dedup 윈도우 | 룰이 lambda 리스트로 선언적 | **UI 미노출** — DB `prediction_events` 카테고리 `ILLEGAL_*` 는 생산되나 전용 detection UI 없음. 운영자는 EventList(`/event-list`) 에서만 조회. 환적도 동일 문제 |
---
## 5. 코드 구조 평가 (6축)
| 축 | 평가 | 근거 |
|---|---|---|
| **관심사 분리** | B+ | algorithms / pipeline / output / db 레이어는 깔끔. 단 scheduler 는 오케스트레이터가 아니라 **메가 함수** |
| **재사용성** | A- | 17 알고리즘 모듈 중 ~15개가 순수함수. `run_gear_correlation` 만 conn 혼재 |
| **테스트 가능성** | C+ | unit test 3개만 (time_bucket / gear_parent_episode / gear_parent_inference). `vessel_store` / `fleet_tracker` 싱글턴 → integration test 어려움 |
| **에러 격리** | C | 사이클 전체 try/except + 내부 지역 try/except 혼재. 출력 5모듈이 한 덩어리 → 실패 지점 특정 불가 |
| **동시성** | A- | `ThreadedConnectionPool(1,5)`, `max_instances=1` 스케줄러 — 단일 프로세스 가정 하에서 안전 |
| **설정 가능성** | C- | 임계값 대부분 파일 상수. `correlation_param_models` 패턴만 DB 기반 (예외) |
### 주목할 잘된 점
- **Dedup 윈도우 카테고리별 차등** ([event_generator.py:26-39](../prediction/output/event_generator.py#L26-L39)) — 5분 boundary 집단 만료를 피하기 위해 33/67/89/127/131/181/367분 등 **의도적으로 5의 배수 회피**. 룰 기반 탐지의 대표적인 "튜닝 knob" 이 코드에 명시.
- **gear_identity 공존/교체 분기** ([fleet_tracker.py](../prediction/fleet_tracker.py) 트랜잭션 설계) — SAVEPOINT 로 부분 실패를 사이클 전체 abort 와 분리. 이전 13h 공백 사고의 재발 방지 설계가 구조에 반영됨.
- **Lightweight path** — 파이프라인 통과 못한 중국 MID 를 경량 경로로 계속 커버 ([scheduler.py:523-682](../prediction/scheduler.py#L523-L682)). "정밀 vs 커버리지" 를 두 경로로 나누는 의사결정 자체는 탁월.
### 구조적 채무
- **환경 분기 부재**: `config.py` 에 dev/prod 분기가 없음. `.env` 파일 하나에 의존. 로컬 실행 시 운영 DB 를 건드리는 위험 ([config.py:8-22](../prediction/config.py#L8-L22))
- **상태 있는 모듈 전역 변수**: `_transship_pair_history`, `_last_run`, `_scheduler` ([scheduler.py:16-26](../prediction/scheduler.py#L16-L26)). 테스트 격리 어렵고, 재시작 시 pair 누적 상태 증발
- **DB 쓰기 산재**: `kcgdb.upsert_results` / `save_group_snapshots` / `gear_correlation UPDATE` / `gear_identity UPSERT` / `prediction_events INSERT` 가 서로 다른 트랜잭션. 한 사이클 원자성 X — 의도적일 수 있으나 명시 설계 문서 없음
---
## 6. 방향성 진단 — 프로토타입 → MVP → 운영
### 지금 강점
- **룰 기반 탐지를 탄탄히 다져둔 토대** — 임계값이 드러나 있고 dedup 설계가 명시적. 향후 ML overlay 를 얹을 때 "어디에 얹을지" 가 명확 (dark_suspicion score, transship score, gear_violation score 가 모두 연속값으로 산출)
- **운영자 의사결정 통합 설계** — V030 GEAR_IDENTITY_COLLISION 에서 status(OPEN/REVIEWED/CONFIRMED_ILLEGAL/FALSE_POSITIVE) 가 severity 재계산을 억제하는 패턴 — 사람 loop back 이 설계된 유일한 자리. 다른 도메인에도 이 패턴 확장 가능
### 지금 약점
- **ML 부재** — sklearn/torch 없음. 2026 기준 프로토타입으로는 적절하나, sequence anomaly (dark gap 의 시계열 반복 패턴) 나 behavioral classifier 는 룰만으론 한계
- **하드코딩 지대**: MID prefix(4개소), KR coverage box, SOG band, 11 패턴 점수, 5단계 transship 임계 — 모두 "이 프로젝트에서 튜닝해야 할 핫스팟" 인데 DB/config 분리 안 됨
- **UI 비대칭**: `prediction_events.category='ILLEGAL_FISHING_PATTERN'` 이 생산되지만 전용 UI 없음. 환적도 동일. 결과적으로 **prediction 이 만드는 가치의 일부가 운영자에게 도달하지 못함**
- **테스트 빈곤**: 17 알고리즘 중 3개만 유닛테스트. 사이클 단위 integration test 전혀 없음 — 사이클 회귀가 항상 운영 로그로만 드러남 (13h 공백 사고가 대표 사례)
---
## 7. 구조적 개선 제안 (우선순위별)
### P1 — 지금 해야 할 것 (운영 안정성)
1. **사이클 스테이지 단위 에러 경계**`_stage(name, fn, required=False)` 유틸로 9번 출력 5모듈을 쪼갤 것. `logger.exception` 으로 stacktrace 보존. `required=True``fetch_incremental` 같은 fatal 에만 적용 → 실패 시 조기 반환
2. **임계값 외부화**`correlation_param_models` 패턴을 확장해 `detection_params` 테이블 신설 (algo_name, param_key, value, active_from, active_to). 배포 없이 해상도 튜닝 가능. 운영자 권한으로 접근 시 감사 로그
3. **ILLEGAL_FISHING_PATTERN 전용 페이지** + **환적 전용 페이지** — 백엔드 API·DB 는 이미 존재. 프론트만 GearCollisionDetection 패턴으로 추가 (`PageContainer` + `DataTable` + `Badge intent`)
4. **사이클 부분 원자성 명시** — DB 쓰기 경계 문서화 (어디까지가 한 트랜잭션인지). 최소한 [architecture.md](architecture.md) 또는 신설 `docs/prediction-transactions.md` 에 다이어그램
### P2 — 다음 (품질 확보)
5. **알고리즘 유닛테스트 커버리지** — 17 모듈 중 최소 10 개 (dark_vessel 11 패턴 / transshipment 5단계 / gear_violation 6 G-code / spoofing / risk) 에 fixture 기반 테스트. `tests/fixtures/` 에 AIS DF CSV 샘플
6. **DB fixture integration test** — testcontainers-python 으로 PostgreSQL 띄워 한 사이클 실행 + 결과 테이블 assert. CI 에서 돌릴 수 있도록 데이터 10 척 x 1h 정도 경량
7. **`vessel_store` / `fleet_tracker` 의존성 주입 개편** — 모듈 싱글턴 → `AnalysisContext` dataclass 로 명시 주입. 테스트 mock 가능
8. **MID prefix·커버리지 box 를 `monitoring_zones` JSON 연장** — 이미 `data/monitoring_zones.json` 이 있음. 동일 포맷으로 `mid_prefixes.json` / `kr_coverage.json` 추가
### P3 — 중기 (가치 확장)
9. **ML overlay 타겟 설정** — dark_suspicion score (11 패턴 합산) 은 classifier training target 으로 최적. GCN/Transformer 로 **"gap 시퀀스가 의도적인가"** 를 학습. 룰 유지 + 게이트만 ML 로 대체 (shadow mode 로 비교)
10. **correlation 파라미터 MLOps 연동**`correlation_param_models` 를 MLflow 로 실험 기록 → 성능 좋은 모델 자동 active 전환
11. **AIS 벤치마크 데이터셋** — 한중어업협정 906척 중 과거 단속 이력 있는 선박을 positive label. 현재 매칭률 53%+ 이므로 샘플 확보 가능. tier 별 precision/recall 산출
### P4 — 장기 (스케일)
12. **multi-process / async** — APScheduler + 단일 스레드 한계. 현 55k 선박 / 2.3M points / 110초 사이클에서 8k 중국 증가 + 한국 확장 시 5분 주기 내 완료 불가 예측. asyncio + ray / dask 로 스테이지 병렬
13. **Event bus 분리** — 지금은 `prediction_events` INSERT 가 동기. outbox 패턴으로 비동기 분리 시 백엔드/프론트 실시간 push 기반 (WebSocket) 로 진화 가능
---
## 8. 부록 — 임계값 전수표 (외부화 우선순위)
| 위치 | 상수 | 현재값 | P1 외부화 | 비고 |
|---|---|---|---|---|
| [scheduler.py:28](../prediction/scheduler.py#L28) | `_KR_DOMESTIC_PREFIXES` | `('440','441')` | ✅ | 한국 MID |
| [scheduler.py:140,247](../prediction/scheduler.py#L140) | 중국 MID prefix | `'412' '413' '414'` 하드코딩 2곳 | ✅ | `mid_prefixes.json` |
| [scheduler.py:256](../prediction/scheduler.py#L256) | pair pool SOG band | `1.5 <= mean_sog <= 5.0` | ✅ | 조업 속력대 |
| [dark_vessel.py:6-8](../prediction/algorithms/dark_vessel.py#L6-L8) | GAP 임계 3종 | 6000/10800/86400s | ✅ | 100분/3h/24h |
| [dark_vessel.py:11-12](../prediction/algorithms/dark_vessel.py#L11-L12) | `_KR_COVERAGE_LAT/LON` | 32.0~39.5 / 124.0~132.0 | ✅ | AIS 수신 박스 |
| [dark_vessel.py:257-359](../prediction/algorithms/dark_vessel.py#L257-L359) | 11 패턴 점수 P1~P11 | 10~30 pt 분산 | ⚠️ | 탐지 정책 튜닝 대상 |
| [event_generator.py:26-39](../prediction/output/event_generator.py#L26-L39) | DEDUP_WINDOWS 12 카테고리 | 33~367분 | ⚠️ | 이미 의도적 |
| [config.py:25-37](../prediction/config.py#L25-L37) | 파이프라인 주기 등 | 5/24/60/30/12/3 | ✅ env | `.env` 로 이미 가능 |
| [transshipment.py](../prediction/algorithms/transshipment.py) | PROXIMITY / RENDEZVOUS | 0.002deg / 90min | ✅ | 환적 민감도 |
| [pair_trawl.py](../prediction/algorithms/pair_trawl.py) | PROXIMITY / SOG_Δ / COG_Δ / MIN_SYNC | 0.27NM / 0.5kn / 10° / 2h | ⚠️ | tier 재분류 기준 |
**P1:** 배포 없이 튜닝 가능해야 할 것
**⚠️:** 튜닝 자체가 탐지 정책 변경 → 릴리즈 노트 필요
---
## 9. 관련 파일 인덱스
- 오케스트레이터: [scheduler.py](../prediction/scheduler.py)
- 설정: [config.py](../prediction/config.py)
- 상태 컴포넌트: [fleet_tracker.py](../prediction/fleet_tracker.py), [cache/vessel_store.py](../prediction/cache/vessel_store.py)
- 알고리즘: [algorithms/](../prediction/algorithms/)
- 파이프라인: [pipeline/orchestrator.py](../prediction/pipeline/orchestrator.py)
- 출력: [output/event_generator.py](../prediction/output/event_generator.py), [output/violation_classifier.py](../prediction/output/violation_classifier.py), [output/kpi_writer.py](../prediction/output/kpi_writer.py), [output/stats_aggregator.py](../prediction/output/stats_aggregator.py), [output/alert_dispatcher.py](../prediction/output/alert_dispatcher.py)
- DB: [db/kcgdb.py](../prediction/db/kcgdb.py), [db/snpdb.py](../prediction/db/snpdb.py), [db/partition_manager.py](../prediction/db/partition_manager.py)
연관 운영 문서:
- [architecture.md](architecture.md) — 프론트엔드 아키텍처
- [sfr-traceability.md](sfr-traceability.md) — SFR 매트릭스
- [system-flow-guide.md](system-flow-guide.md) — system-flow 노드 명세
- `backend/README.md` — 백엔드 구성 (V030 + PR #79 hotfix 요구사항 명시)
---
## 변경 이력
| 일자 | 내용 |
|---|---|
| 2026-04-17 | 초판 — opus 4.7 독립 리뷰. 구조/방향 중심 + 우선순위별 개선 제안 |

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다. Load Diff

파일 보기

@ -1,7 +1,8 @@
# SFR 요구사항별 화면 사용 가이드
> **문서 작성일:** 2026-04-06
> **시스템 버전:** v0.1.0 (프로토타입)
> **최종 업데이트:** 2026-04-17 (2026-04-17 릴리즈 기준)
> **시스템 버전:** 운영 배포 (rocky-211 + redis-211)
> **다국어:** 한국어/영어 전환 지원 (헤더 우측 EN/한국어 버튼)
> **테마:** 다크/라이트 전환 지원 (헤더 우측 해/달 아이콘 버튼)
@ -11,7 +12,12 @@
이 문서는 **KCG AI 모니터링 시스템**의 각 SFR(소프트웨어 기능 요구사항)이 화면에서 어떻게 구현되어 있는지를 **비개발자**(일반 사용자, 사업 PM, 산출물 작성자)가 이해할 수 있도록 정리한 가이드입니다.
현재 시스템은 **프로토타입 단계(v0.1.0)**로, 모든 SFR의 UI가 완성되어 있으나 백엔드 서버 연동은 아직 이루어지지 않았습니다. 화면에 표시되는 데이터는 시연용 샘플 데이터입니다.
### 시스템 현황 (2026-04-17 기준)
- **프런트엔드·백엔드·분석엔진(prediction) 운영 배포 완료** — 자체 JWT 인증 + 트리 기반 RBAC + 감사 로그 + 65+ API
- **AI 분석 엔진(prediction)**: 5분 주기로 AIS 원천 데이터(snpdb)를 분석하여 결과를 `kcgaidb` 에 자동 저장 (14 알고리즘 + DAR-03 G-01~G-06)
- **실시간 연동 화면**: Dashboard / MonitoringDashboard / ChinaFishing / DarkVesselDetection / GearDetection / EnforcementHistory / EventList / AIAlert / Statistics / AccessControl / PermissionsPanel / Audit 등 **15+ 화면이 실 API + prediction 결과를 실시간으로 표시**
- **Mock 화면**: DataHub / AIModelManagement / RiskMap / PatrolRoute / FleetOptimization / ExternalService / ShipAgent / MLOpsPage / AIAssistant 는 UI 완성, 백엔드/AI 엔진 연동은 단계적 추가 중
- **자세한 추적 매트릭스**: `docs/sfr-traceability.md` v3.0 참조
---
@ -55,17 +61,18 @@
- 역할별 데모 계정 선택 (ADMIN, OPERATOR, ANALYST, FIELD, VIEWER)
- 로그인 후 역할에 따른 메뉴 접근 제어
**구현 완료:**
- ✅ 로그인 화면 UI 및 데모 계정 5종 로그인 기능
- ✅ 역할 기반 세션 유지 및 메뉴 접근 제어
**구현 완료 (2026-04-17 기준):**
- ✅ 로그인 화면 UI + 자체 ID/PW 인증 + JWT 쿠키 세션 + 역할별 데모 계정 5종 실 로그인
- ✅ 비밀번호 정책(9자 이상 영문+숫자+특수) + 5회 실패 30분 잠금 + BCrypt 해시
- ✅ 트리 기반 RBAC (47 리소스 노드, Level 0 13개 + Level 1 32개, 5 operation) + Caffeine 10분 TTL
- ✅ 모든 로그인 시도 감사 로그 저장 및 조회 (로그인 이력 화면)
- ✅ 역할 기반 세션 유지 및 메뉴 접근 제어 (사이드바/라우트 가드)
**향후 구현 예정:**
- 🔲 SSO(Single Sign-On) 연동
**향후 구현 예정 (기업 환경 연동):**
- 🔲 SSO(해양경찰 통합인증) 연동
- 🔲 GPKI(정부 공인인증서) 인증 연동
- 🔲 실제 사용자 DB 연동 및 비밀번호 암호화
**보완 필요:**
- ⚠️ 현재 데모 계정은 하드코딩되어 있으며, 운영 환경에서는 실제 인증 체계로 대체 필요
- 🔲 공무원증 기반 인증 연동
- 🔲 인사 시스템 연동 역할 자동 부여
---
@ -83,16 +90,17 @@
- 역할별 접근 가능 메뉴 및 기능 권한 설정
- 사용자 목록 조회 및 역할 할당
**구현 완료:**
- ✅ RBAC 5역할 체계 UI 및 역할별 권한 매트릭스 표시
- ✅ 권한 설정 화면 레이아웃 및 인터랙션
**구현 완료 (2026-04-17 기준):**
- ✅ 트리 기반 RBAC 실 운영 — 47 리소스 노드 × 5 operation (READ/CREATE/UPDATE/DELETE/EXPORT) × 다중 역할 OR 합집합
- ✅ 역할별 권한 매트릭스 시각화 (셀 클릭 Y → N → 상속 사이클)
- ✅ 부모 READ 거부 시 자식 강제 거부, 상속 표시
- ✅ 역할 CRUD (admin:role-management) + 권한 매트릭스 갱신 (admin:permission-management)
- ✅ 사용자-역할 할당 다이얼로그 (admin:user-management)
- ✅ 모든 권한 변경은 `auth_audit_log` 에 자동 기록 (ROLE_CREATE/UPDATE/DELETE/PERM_UPDATE/USER_ROLE_ASSIGN)
**향후 구현 예정:**
- 🔲 실제 사용자 DB 연동을 통한 권한 CRUD
- 🔲 감사 로그(권한 변경 이력) 기록
**보완 필요:**
- ⚠️ 현재 화면의 데이터는 샘플이며 실제 저장/반영되지 않음
- 🔲 권한 변경 이력 UI (auth_audit_log 조회는 현재 별도 화면)
- 🔲 역할 템플릿 복제 기능
---
@ -369,17 +377,18 @@ AIS(선박자동식별장치) 신호를 의도적으로 끈 의심 선박(Dark V
- 의심 선박 상세 프로필 및 이동 궤적 조회
- 위험도 등급별 분류 표시
**구현 완료:**
- ✅ 의심 선박 7척 목록/지도 시각화
- ✅ 5가지 행동 패턴 분석 결과 UI
**구현 완료 (2026-04-17 기준):**
- ✅ **AI 분석 엔진(prediction) 5분 주기 실시간 탐지 결과 표시** — snpdb AIS 원천 데이터 기반
- ✅ Dark Vessel 11패턴 기반 0~100점 연속 점수 + 4단계 tier(CRITICAL≥70 / HIGH≥50 / WATCH≥30 / NONE)
- ✅ DarkDetailPanel — 선박 선택 시 ScoreBreakdown으로 P1~P11 각 패턴별 기여도 표시
- ✅ 지도 기반 실시간 위치 + tier별 색상 구분 (라이트/다크 모드 대응)
- ✅ 최근 1시간 / 중국 선박(MMSI 412*) 필터, MMSI/선박명/패턴 검색
- ✅ 특이운항 미니맵 (24h 궤적 + DARK/SPOOFING/TRANSSHIP/GEAR_VIOLATION/HIGH_RISK 구간 병합 하이라이트)
**향후 구현 예정:**
- 🔲 AI Dark Vessel 탐지 엔진 연동
- 🔲 실시간 AIS 데이터 분석 연동
- 🔲 spoofing_score 산출 재설계 (중국 MID 412 선박 전원 0 수렴 이슈, BD-09 필터 + teleport 25kn 임계 재검토)
- 🔲 SAR(위성영상) 기반 탐지 연동
**보완 필요:**
- ⚠️ 현재 탐지 결과는 샘플 데이터이며, AI 탐지 엔진 연동 후 실시간 탐지 결과로 교체 필요
- 🔲 과거 이력 차트 (현재는 최근 24h 중심)
---
@ -398,16 +407,17 @@ AIS(선박자동식별장치) 신호를 의도적으로 끈 의심 선박(Dark V
- 해역별 중국 어선 밀집도 분석
- 시계열 활동 패턴 분석
**구현 완료:**
- ✅ 중국 어선 분석 종합 대시보드 UI
- ✅ 지도 기반 활동 현황 시각화
**구현 완료 (2026-04-17 기준):**
- ✅ **3개 탭(AI 감시 대시보드 / 환적접촉탐지 / 어구·어망 판별) 전부 실데이터 연동**`/api/analysis/*` 경유, MMSI prefix `412` 고정
- ✅ 중국 선박 전체 분석 결과 실시간 그리드 (최근 1h, 위험도순 상위 200건)
- ✅ 특이운항 판별 — riskScore ≥ 40 상위 목록 + 선박 클릭 시 24h 궤적 미니맵 + 판별 구간 패널
- ✅ 해역별 통항량 + 안전도 분석 (종합 위험/안전 지수) + 위험도 도넛
- ✅ 자동탐지 결과(어구 판별 탭) row 클릭 시 상단 입력 폼 자동 프리필
**향후 구현 예정:**
- 🔲 AI 탐지 엔진 연동 (Dark Vessel + 어구 탐지 통합)
- 🔲 실시간 데이터 기반 분석 갱신
**보완 필요:**
- ⚠️ 현재 분석 데이터는 샘플이며, 실제 탐지 엔진 연동 필요
- 🔲 관심영역 / VIIRS 위성영상 / 기상 예보 / VTS연계 (현재 "데모 데이터" 배지)
- 🔲 비허가 / 제재 / 관심 선박 탭 데이터 소스 연동 (현재 "준비중" 배지)
- 🔲 월별 집계 API 연동 (현재 통계 탭 "준비중")
---
@ -426,17 +436,48 @@ AIS(선박자동식별장치) 신호를 의도적으로 끈 의심 선박(Dark V
- 탐지 결과 상세 정보 (위치, 크기, 어구 유형, 위험도)
- 탐지 이미지 확인
**구현 완료:**
- ✅ 어구 6건 탐지 결과 목록/지도 UI
- ✅ 어구 식별 결정트리 시각화
**구현 완료 (2026-04-17 기준):**
- ✅ **DAR-03 G-01~G-06 실시간 탐지 결과** — prediction 5분 주기 + 한중어업협정 906척 레지스트리(V029) 매칭 53%+
- ✅ G코드별 탐지: G-01(수역-어구 불일치) / G-02(금어기) / G-03(미등록 어구) / G-04(MMSI cycling) / G-05(고정어구 drift) / G-06(쌍끌이 — STRONG/PROBABLE/SUSPECT tier)
- ✅ 어구 그룹 지도 (ZONE_I~IV 폴리곤 + GeoJsonLayer + IconLayer) + 세부 필터 패널(해역/판정/위험도/모선 상태/허가/멤버 수) + localStorage 영속화
- ✅ GearDetailPanel — 후보 클릭 → 점수 근거(관측 7종 + 보정 3종) + 모선 확정/제외 버튼
- ✅ 24h 궤적 리플레이 (GearReplayController + TripsLayer, SPEED_FACTOR=2880, 24h→30s)
- ✅ 어구/어망 판별 화면 — 허가코드/어구물리특성/발견위치 입력 → 국적 판별(한/중/미확인) + 판별 근거·경고·AI 탐지 Rule·교차 검증 파이프라인
**향후 구현 예정:**
- 🔲 AI 어구 탐지 모델 연동 (영상 분석 기반)
- 🔲 실시간 CCTV/SAR 영상 분석 연동
- 🔲 탐지 결과 자동 분류 및 알림
- 🔲 영상(CCTV/SAR) 기반 어구 자동 분류
- 🔲 한·중 어구 5종 구조 비교 이미지 라이브러리 확장
**보완 필요:**
- ⚠️ 현재 탐지 결과는 샘플 데이터이며, AI 탐지 모델 연동 후 실제 탐지 결과로 교체 필요
---
### 어구 정체성 충돌 (V030, 2026-04-17 추가)
**메뉴 위치:** 탐지/분석 > 어구 정체성 충돌
**URL:** `/gear-collision`
**접근 권한:** ADMIN, OPERATOR, ANALYST, FIELD, VIEWER (READ) / ADMIN, OPERATOR (UPDATE)
**화면 설명:**
동일한 어구 이름이 서로 다른 MMSI 로 같은 5분 사이클에 동시 송출되는 공존 케이스를 탐지해
어구 복제/스푸핑 증거로 기록·분류하는 화면입니다. 이전에는 "MMSI 교체"로 오해해 덮어쓰는 바람에
`gear_correlation_scores_pkey` 제약 충돌로 prediction 사이클 전체가 실패하던 이슈(PR #73)를
"공존 패턴"으로 재정의하면서 신설되었습니다.
**주요 기능:**
- 충돌 쌍(name, MMSI 저/고) 목록 + 공존 누적 횟수 + 양 위치 최대 거리 + severity tier
- 운영자 분류 워크플로우: OPEN → REVIEWED / CONFIRMED_ILLEGAL / FALSE_POSITIVE (REOPEN 가능)
- severity 자동 산정: CRITICAL(거리≥50km OR 공존≥3회) / HIGH(거리≥10km OR 공존≥2회) / MEDIUM / LOW
- 상세 패널 — 양 MMSI 최근 궤적, 어구 이름 파싱(선박명+어구코드), 매칭된 모선(fleet_vessels)
**구현 완료 (V030 / PR #73, 2026-04-17):**
- ✅ `gear_identity_collisions` 테이블 + UNIQUE(name, mmsi_lo, mmsi_hi) 제약 + 5 인덱스
- ✅ prediction `algorithms/gear_identity.py` 순수함수 + `fleet_tracker.track_gear_identity()` 공존/교체 분기
- ✅ 백엔드 4 엔드포인트 `/api/analysis/gear-collisions` + `GEAR_COLLISION_RESOLVE` @Auditable
- ✅ 프론트 페이지 `GearCollisionDetection.tsx` (DataTable + KPI 5장 + SidePanel + 상태/심각도 필터)
- ✅ 이벤트 허브 연동 — HIGH/CRITICAL 은 `prediction_events` 에 자동 등록 (dedup 367분)
**향후 구현 예정:**
- 🔲 FALSE_POSITIVE 반복 쌍 자동 화이트리스트 (v2 학습 피드백 루프)
- 🔲 KPI 테이블 (`prediction_stats_*`) 에 severity 별 집계 반영
---
@ -455,17 +496,17 @@ AIS(선박자동식별장치) 신호를 의도적으로 끈 의심 선박(Dark V
- 이력 상세 정보 조회 및 검색/필터
- 이력 데이터 엑셀 내보내기
**구현 완료:**
- ✅ 단속 이력 6건 목록/상세 UI
- ✅ AI 매칭 검증 결과 표시
**구현 완료 (2026-04-17 기준):**
- ✅ **실시간 이벤트 조회**`/api/events` 페이징/필터/확인(ACK)/상태 변경
- ✅ **단속 이력 CRUD**`/api/enforcement/records` (GET/POST/PATCH) + ENF-yyyyMMdd-NNNN UID 자동 발급
- ✅ 이벤트 발생 → 확인 → 단속 등록 → 오탐 처리 워크플로우 (액션 버튼 4종)
- ✅ 모든 쓰기 액션 `auth_audit_log` 자동 기록 (ENFORCEMENT_CREATE / ENFORCEMENT_UPDATE / ACK_EVENT / UPDATE_EVENT_STATUS)
- ✅ KPI 카운트 (CRITICAL/HIGH/MEDIUM/LOW) + 엑셀 내보내기 + 출력
- ✅ 단속 완료 시 prediction_events.status 자동 RESOLVED 갱신
**향후 구현 예정:**
- 🔲 단속 이력 DB 연동 (조회/등록/수정)
- 🔲 AI 매칭 검증 엔진 연동
- 🔲 탐지-단속 연계 자동 분석
**보완 필요:**
- ⚠️ 현재 이력 데이터는 샘플이며, DB 연동 후 실제 단속 데이터로 교체 필요
- 🔲 증거 파일(사진/영상) 업로드 서버 연동
- 🔲 AI 매칭 검증 정량 지표 (탐지↔단속 confusion matrix)
---
@ -487,17 +528,15 @@ AIS(선박자동식별장치) 신호를 의도적으로 끈 의심 선박(Dark V
- 함정 배치 현황 요약
- 실시간 경보 알림 표시
**구현 완료:**
- ✅ KPI 카드 + 히트맵 + 타임라인 + 함정 현황 통합 대시보드 UI
- ✅ 반응형 레이아웃 (화면 크기에 따른 자동 배치)
**구현 완료 (2026-04-17 기준):**
- ✅ **실시간 KPI 카드**`/api/stats/kpi` 연동, prediction 5분 주기 결과 기반
- ✅ 실시간 상황 타임라인 — 최근 `prediction_events` 스트림 (긴급/경고 카운트 실시간)
- ✅ 함정 배치 현황 + 경보 알림 + 순찰 현황 통합
- ✅ 라이트/다크 모드 반응형 (2026-04-17 PR #C 하드코딩 색상 제거)
**향후 구현 예정:**
- 🔲 실시간 데이터 연동 (WebSocket 등)
- 🔲 KPI 수치 실시간 갱신
- 🔲 히트맵/타임라인 실시간 업데이트
**보완 필요:**
- ⚠️ 현재 모든 수치는 샘플 데이터이며, 실시간 연동 후 정확한 운영 데이터로 교체 필요
- 🔲 WebSocket 기반 실시간 push (현재는 주기 polling)
- 🔲 맞춤형 대시보드 레이아웃 (드래그/리사이즈)
---
@ -516,17 +555,15 @@ AIS(선박자동식별장치) 신호를 의도적으로 끈 의심 선박(Dark V
- 경보 처리(확인/대응/종결) 워크플로우
- 경보 발생 이력 조회
**구현 완료:**
- ✅ 경보 등급별 현황판 UI
- ✅ 경보 목록/상세 조회 화면
**구현 완료 (2026-04-17 기준):**
- ✅ **실시간 경보 수신**`/api/events` + `/api/alerts` 실 API 연동, prediction event_generator 4룰 기반
- ✅ 경보 등급별(CRITICAL/HIGH/MEDIUM/LOW) 현황 + KPI 카운트
- ✅ 경보 처리 워크플로우 — 확인(ACK) → 단속 등록 → 오탐 처리 (DB 저장 + `auth_audit_log` 기록)
- ✅ 시스템 상태 패널 (백엔드/AI 분석 엔진/DB 상태 실시간 표시, 30초 자동 갱신)
**향후 구현 예정:**
- 🔲 실시간 경보 수신 연동
- 🔲 경보 처리 워크플로우 DB 연동
- 🔲 경보 자동 에스컬레이션
**보완 필요:**
- ⚠️ 현재 경보 데이터는 샘플이며, 실시간 연동 후 실제 경보 데이터로 교체 필요
- 🔲 경보 자동 에스컬레이션 정책
- 🔲 경보 룰 커스터마이즈 UI
---
@ -545,17 +582,15 @@ AIS(선박자동식별장치) 신호를 의도적으로 끈 의심 선박(Dark V
- 선박/이벤트 클릭 시 상세 정보 팝업
- 지도 확대/축소 및 해역 필터링
**구현 완료:**
- ✅ LiveMap 기반 실시간 감시 지도 UI
- ✅ 선박/이벤트 마커 및 팝업 인터랙션
**구현 완료 (2026-04-17 기준):**
- ✅ **실시간 선박 위치 + 이벤트 마커** — prediction 5분 주기 분석 결과(`vessel_analysis_results.lat/lon`) + `prediction_events` 기반
- ✅ MapLibre GL 5 + deck.gl 9 GPU 렌더링 (40만척+ 지원)
- ✅ 위험도별 마커 opacity/radius 차등 (2026-04-17 `ALERT_LEVEL_MARKER_OPACITY/RADIUS` 헬퍼 적용)
- ✅ 이벤트 상세 패널 + 고위험 사건 실시간 알림 (LIVE 표시)
**향후 구현 예정:**
- 🔲 실시간 AIS/VMS 데이터 연동
- 🔲 WebSocket 기반 실시간 위치 업데이트
- 🔲 이벤트 발생 시 자동 지도 포커스 이동
**보완 필요:**
- ⚠️ 현재 선박 위치는 샘플 데이터이며, 실시간 데이터 연동 필요
- 🔲 WebSocket 기반 실시간 push (현재는 주기 갱신)
- 🔲 SAR 위성영상 오버레이
---
@ -601,17 +636,15 @@ AIS(선박자동식별장치) 신호를 의도적으로 끈 의심 선박(Dark V
- 기간별/해역별/유형별 필터링
- 통계 데이터 엑셀 내보내기 및 인쇄
**구현 완료:**
- ✅ 월별 추이 차트 및 KPI 5개 대시보드 UI
- ✅ 필터링 및 엑셀 내보내기/인쇄 기능
**구현 완료 (2026-04-17 기준):**
- ✅ **실시간 통계 데이터**`/api/stats/monthly|daily|hourly` 연동, prediction `stats_aggregator` 집계 결과 기반
- ✅ 월별/일별/시간별 추이 그래프 (ECharts, KST 기준)
- ✅ 해역별/유형별 필터링 + 엑셀 내보내기/인쇄
- ✅ 감사·보안 통계 — `/api/admin/stats/audit|access|login` (2026-04-17 AdminStatsService 계층 분리)
**향후 구현 예정:**
- 🔲 통계 데이터 DB 연동
- 🔲 실제 운영 데이터 기반 KPI 자동 산출
- 🔲 맞춤형 보고서 생성 기능
**보완 필요:**
- ⚠️ 현재 KPI 수치(정확도 93.2%, 오탐율 7.8% 등)는 샘플 데이터이며, 실제 운영 데이터 기반으로 교체 필요
- 🔲 보고서 자동 생성 (PDF/HWP) — 현재는 UI만
- 🔲 맞춤형 지표 대시보드 설정
---
@ -743,17 +776,15 @@ AI가 분석한 결과를 기반으로 관련 담당자에게 알림을 발송
- 알림 수신자 설정 및 발송
- 알림 전송 결과(성공/실패) 확인
**구현 완료:**
- ✅ 알림 5건 전송 현황 UI
- ✅ 알림 유형별 분류 및 상세 조회
**구현 완료 (2026-04-17 기준):**
- ✅ **AI 알림 이력 실 API 조회**`/api/alerts` 연동 (2026-04-17 AlertService 계층 분리)
- ✅ prediction `alert_dispatcher` 모듈이 event_generator 결과 기반으로 `prediction_alerts` 테이블에 자동 기록
- ✅ 알림 유형별 분류 + DataTable 검색/정렬/페이징/엑셀 내보내기
**향후 구현 예정:**
- 🔲 실제 알림 발송 기능 구현 (SMS, 이메일, Push 등)
- 🔲 AI 분석 결과 기반 자동 알림 트리거
- 🔲 알림 발송 이력 DB 연동
**보완 필요:**
- ⚠️ 현재 알림은 실제 발송되지 않으며, 발송 채널(SMS/이메일/Push) 연동 필요
- 🔲 실제 SMS/푸시 발송 게이트웨이 연동 (현재는 DB 기록만)
- 🔲 알림 템플릿 엔진
- 🔲 수신자 그룹 관리
---
@ -857,15 +888,27 @@ AI에게 질문하고 답변을 받을 수 있는 대화형(채팅) 인터페이
---
## 부록: 현재 시스템 상태 요약
## 부록: 현재 시스템 상태 요약 (2026-04-17 기준)
| 항목 | 상태 |
|------|------|
| UI 구현 | 모든 SFR 완료 |
| 백엔드 연동 | 미구현 (전체) |
| 데이터 | 시연용 샘플 데이터 |
| 인증 체계 | 데모 계정 5종 (SSO/GPKI 미연동) |
| 실시간 기능 | 미구현 (WebSocket 등 미연동) |
| AI 모델 | 미연동 (탐지/예측/최적화 등) |
| 외부 시스템 | 미연동 (GICOMS, MTIS 등) |
| 모바일 앱 | 웹 시뮬레이션만 제공 (네이티브 앱 미개발) |
| **백엔드 연동** | **15+ 화면 실 API 연동 완료** (Auth/RBAC/Audit/Events/Alerts/Enforcement/Stats/Analysis/Master 등 65+ API) |
| **AI 분석 엔진 (prediction)** | **운영 중** — 5분 주기로 snpdb 분석 → kcgaidb 저장, 14 알고리즘 + DAR-03 G-01~G-06 |
| **데이터** | 실 AIS 원천(snpdb) + prediction 분석 결과 + 자체 DB 저장 데이터 (일부 화면은 여전히 Mock) |
| **인증 체계** | 자체 ID/PW + JWT + 트리 기반 RBAC + 5회 실패 잠금 (SSO/GPKI 미연동) |
| **실시간 기능** | prediction 5분 주기 + 프론트 30초 폴링 (WebSocket push 미도입) |
| **AI 모델** | Dark Vessel 11패턴 / DAR-03 G-01~G-06 / 환적 5단계 / 경량 risk 등 14종 운영 중 (일부 모델은 Mock 계획 단계) |
| **외부 시스템** | snpdb / gc-signal-batch 연동 완료. 유관기관 OpenAPI(GICOMS/MTIS 등)는 미연동 |
| **디자인 시스템** | `design-system.html` 쇼케이스 SSOT 전영역 준수, 라이트/다크 모드 완전 대응 |
| **다국어** | 한/영 alert/confirm/aria-label 전수 치환 완료 (JSX placeholder 35건은 후속 과제) |
| **모바일 앱** | 웹 시뮬레이션만 제공 (PWA/네이티브 앱 미개발) |
---
## 변경 이력
| 일자 | 내용 |
|------|------|
| 2026-04-06 | 초기 작성 (프론트엔드 프로토타입 v0.1.0 기준) |
| 2026-04-17 | 헤더 + SFR-01/02/09/10/11/12/13/17 주요 섹션 업데이트. 실 API 연동 / prediction 운영 상태 / 2026-04-17 PR #A/#B/#C 반영 |

파일 보기

@ -6,10 +6,15 @@ KCG AI Monitoring 시스템 워크플로우 플로우차트 뷰어 사용법.
`/system-flow.html`은 snpdb 5분 원천 궤적 수집부터 prediction 분석, 이벤트 생성, 운영자 의사결정까지 시스템 전체 데이터 흐름을 노드/엣지로 시각화한 **개발 단계 활용 페이지**입니다.
- 102개 노드 + 133개 엣지 (v1.0.0 기준)
- 115개 노드 + 133개 엣지 (manifest 현재 상태, `meta.json` 은 아직 v1.0.0/2026-04-07 로 미갱신)
- 메인 SPA(`/`)와 완전 분리된 별도 React 앱
- 메뉴/링크 노출 없음 — 직접 URL 접근만
> ⚠️ **V030 미반영 경고**: 2026-04-17 V030 로 추가된 GEAR_IDENTITY_COLLISION 파이프라인 (
> `algo.gear_identity_collision`, `storage.gear_identity_collisions`, `api.gear_collisions_*`,
> `ui.gear_collision`, `decision.gear_collision_resolve`) 노드가 아직 manifest 에 등록되지
> 않았다. 다음 `/version` 릴리즈 시 매니페스트 동기화 필요.
## 접근 URL
- **운영**: https://kcg-ai-monitoring.gc-si.dev/system-flow.html

Binary file not shown.

파일 보기

@ -0,0 +1,12 @@
<!doctype html>
<html lang="ko" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>KCG AI Monitoring — Design System</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/designSystemMain.tsx"></script>
</body>
</html>

파일 보기

@ -11,6 +11,7 @@
"@deck.gl/mapbox": "^9.2.11",
"@xyflow/react": "^12.10.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"deck.gl": "^9.2.11",
"echarts": "^6.0.0",
"i18next": "^26.0.3",
@ -20,6 +21,7 @@
"react-dom": "^19.2.4",
"react-i18next": "^17.0.2",
"react-router-dom": "^7.12.0",
"tailwind-merge": "^3.5.0",
"zustand": "^5.0.12"
},
"devDependencies": {
@ -2824,7 +2826,7 @@
},
"node_modules/clsx": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"resolved": "https://nexus.gc-si.dev/repository/npm-public/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"license": "MIT",
"engines": {
@ -4907,6 +4909,16 @@
"kdbush": "^4.0.2"
}
},
"node_modules/tailwind-merge": {
"version": "3.5.0",
"resolved": "https://nexus.gc-si.dev/repository/npm-public/tailwind-merge/-/tailwind-merge-3.5.0.tgz",
"integrity": "sha512-I8K9wewnVDkL1NTGoqWmVEIlUcB9gFriAEkXkfCjX5ib8ezGxtR3xD7iZIxrfArjEsH7F1CHD4RFUtxefdqV/A==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/dcastil"
}
},
"node_modules/tailwindcss": {
"version": "4.2.2",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.2.2.tgz",

파일 보기

@ -15,6 +15,7 @@
"@deck.gl/mapbox": "^9.2.11",
"@xyflow/react": "^12.10.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"deck.gl": "^9.2.11",
"echarts": "^6.0.0",
"i18next": "^26.0.3",
@ -24,6 +25,7 @@
"react-dom": "^19.2.4",
"react-i18next": "^17.0.2",
"react-router-dom": "^7.12.0",
"tailwind-merge": "^3.5.0",
"zustand": "^5.0.12"
},
"devDependencies": {

Binary file not shown.

After

Width:  |  Height:  |  크기: 72 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 51 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 69 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 67 KiB

Binary file not shown.

After

Width:  |  Height:  |  크기: 54 KiB

파일 보기

@ -1,41 +1,13 @@
import { Suspense, useMemo, lazy } from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { AuthProvider, useAuth } from '@/app/auth/AuthContext';
import { MainLayout } from '@/app/layout/MainLayout';
import { LoginPage } from '@features/auth';
/* SFR-01 */ import { AccessControl } from '@features/admin';
/* SFR-02 */ import { SystemConfig, NoticeManagement } from '@features/admin';
/* SFR-03 */ import { DataHub } from '@features/admin';
/* SFR-04 */ import { AIModelManagement } from '@features/ai-operations';
/* SFR-05 */ import { RiskMap } from '@features/risk-assessment';
/* SFR-06 */ import { EnforcementPlan } from '@features/risk-assessment';
/* SFR-07 */ import { PatrolRoute } from '@features/patrol';
/* SFR-08 */ import { FleetOptimization } from '@features/patrol';
/* SFR-09 */ import { DarkVesselDetection } from '@features/detection';
/* SFR-10 */ import { GearDetection } from '@features/detection';
/* SFR-11 */ import { EnforcementHistory } from '@features/enforcement';
/* SFR-12 */ import { MonitoringDashboard } from '@features/monitoring';
/* SFR-13 */ import { Statistics } from '@features/statistics';
/* SFR-14 */ import { ExternalService } from '@features/statistics';
/* SFR-15 */ import { MobileService } from '@features/field-ops';
/* SFR-16 */ import { ShipAgent } from '@features/field-ops';
/* SFR-17 */ import { AIAlert } from '@features/field-ops';
/* SFR-18+19 */ import { MLOpsPage } from '@features/ai-operations';
/* SFR-20 */ import { AIAssistant } from '@features/ai-operations';
/* 기존 */ import { Dashboard } from '@features/dashboard';
import { LiveMapView, MapControl } from '@features/surveillance';
import { EventList } from '@features/enforcement';
import { VesselDetail } from '@features/vessel';
import { ChinaFishing } from '@features/detection';
import { ReportManagement } from '@features/statistics';
import { AdminPanel } from '@features/admin';
// Phase 4: 모선 워크플로우
import { ParentReview } from '@features/parent-inference/ParentReview';
import { ParentExclusion } from '@features/parent-inference/ParentExclusion';
import { LabelSession } from '@features/parent-inference/LabelSession';
// Phase 4: 관리자 로그
import { AuditLogs } from '@features/admin/AuditLogs';
import { AccessLogs } from '@features/admin/AccessLogs';
import { LoginHistoryView } from '@features/admin/LoginHistoryView';
import { useMenuStore } from '@stores/menuStore';
import { COMPONENT_REGISTRY } from '@/app/componentRegistry';
// 권한 노드 없는 드릴다운 라우트 (인증만 체크)
const VesselDetail = lazy(() => import('@features/vessel').then((m) => ({ default: m.VesselDetail })));
/**
* .
@ -68,65 +40,66 @@ function ProtectedRoute({
return <>{children}</>;
}
function LoadingFallback() {
return (
<div className="flex items-center justify-center min-h-[40vh]">
<div className="text-sm text-hint"> ...</div>
</div>
);
}
/**
* DB menu_config Route .
* React Router v6는 <Routes> <Route> .
*/
function useDynamicRoutes() {
const items = useMenuStore((s) => s.items);
const routableItems = useMemo(
() => items.filter((i) => i.menuType === 'ITEM' && i.urlPath),
[items],
);
return routableItems.map((item) => {
const Comp = item.componentKey ? COMPONENT_REGISTRY[item.componentKey] : null;
if (!Comp || !item.urlPath) return null;
const path = item.urlPath.replace(/^\//, '');
return (
<Route
key={item.menuCd}
path={path}
element={
<ProtectedRoute resource={item.rsrcCd ?? undefined}>
<Suspense fallback={<LoadingFallback />}>
<Comp />
</Suspense>
</ProtectedRoute>
}
/>
);
});
}
function AppRoutes() {
const dynamicRoutes = useDynamicRoutes();
return (
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/" element={<ProtectedRoute><MainLayout /></ProtectedRoute>}>
<Route index element={<Navigate to="/dashboard" replace />} />
{dynamicRoutes}
{/* 드릴다운 전용 라우트 — 메뉴/권한 노드 없음, 인증만 체크 */}
<Route path="vessel/:id" element={<Suspense fallback={<LoadingFallback />}><VesselDetail /></Suspense>} />
</Route>
</Routes>
);
}
export default function App() {
return (
<BrowserRouter>
<AuthProvider>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/" element={<ProtectedRoute><MainLayout /></ProtectedRoute>}>
<Route index element={<Navigate to="/dashboard" replace />} />
{/* SFR-12 대시보드 */}
<Route path="dashboard" element={<ProtectedRoute resource="dashboard"><Dashboard /></ProtectedRoute>} />
<Route path="monitoring" element={<ProtectedRoute resource="monitoring"><MonitoringDashboard /></ProtectedRoute>} />
{/* SFR-05~06 위험도·단속계획 */}
<Route path="risk-map" element={<ProtectedRoute resource="risk-assessment:risk-map"><RiskMap /></ProtectedRoute>} />
<Route path="enforcement-plan" element={<ProtectedRoute resource="risk-assessment:enforcement-plan"><EnforcementPlan /></ProtectedRoute>} />
{/* SFR-09~10 탐지 */}
<Route path="dark-vessel" element={<ProtectedRoute resource="detection:dark-vessel"><DarkVesselDetection /></ProtectedRoute>} />
<Route path="gear-detection" element={<ProtectedRoute resource="detection:gear-detection"><GearDetection /></ProtectedRoute>} />
<Route path="china-fishing" element={<ProtectedRoute resource="detection:china-fishing"><ChinaFishing /></ProtectedRoute>} />
{/* SFR-07~08 순찰경로 */}
<Route path="patrol-route" element={<ProtectedRoute resource="patrol:patrol-route"><PatrolRoute /></ProtectedRoute>} />
<Route path="fleet-optimization" element={<ProtectedRoute resource="patrol:fleet-optimization"><FleetOptimization /></ProtectedRoute>} />
{/* SFR-11 이력 */}
<Route path="enforcement-history" element={<ProtectedRoute resource="enforcement:enforcement-history"><EnforcementHistory /></ProtectedRoute>} />
<Route path="event-list" element={<ProtectedRoute resource="enforcement:event-list"><EventList /></ProtectedRoute>} />
{/* SFR-15~17 현장 대응 */}
<Route path="mobile-service" element={<ProtectedRoute resource="field-ops:mobile-service"><MobileService /></ProtectedRoute>} />
<Route path="ship-agent" element={<ProtectedRoute resource="field-ops:ship-agent"><ShipAgent /></ProtectedRoute>} />
<Route path="ai-alert" element={<ProtectedRoute resource="field-ops:ai-alert"><AIAlert /></ProtectedRoute>} />
{/* SFR-13~14 통계·외부연계 */}
<Route path="statistics" element={<ProtectedRoute resource="statistics:statistics"><Statistics /></ProtectedRoute>} />
<Route path="external-service" element={<ProtectedRoute resource="statistics:external-service"><ExternalService /></ProtectedRoute>} />
<Route path="reports" element={<ProtectedRoute resource="statistics:statistics"><ReportManagement /></ProtectedRoute>} />
{/* SFR-04 AI 모델 */}
<Route path="ai-model" element={<ProtectedRoute resource="ai-operations:ai-model"><AIModelManagement /></ProtectedRoute>} />
{/* SFR-18~20 AI 운영 */}
<Route path="mlops" element={<ProtectedRoute resource="ai-operations:mlops"><MLOpsPage /></ProtectedRoute>} />
<Route path="ai-assistant" element={<ProtectedRoute resource="ai-operations:ai-assistant"><AIAssistant /></ProtectedRoute>} />
{/* SFR-03 데이터허브 */}
<Route path="data-hub" element={<ProtectedRoute resource="admin:system-config"><DataHub /></ProtectedRoute>} />
{/* SFR-02 환경설정 */}
<Route path="system-config" element={<ProtectedRoute resource="admin:system-config"><SystemConfig /></ProtectedRoute>} />
<Route path="notices" element={<ProtectedRoute resource="admin"><NoticeManagement /></ProtectedRoute>} />
{/* SFR-01 권한·시스템 */}
<Route path="access-control" element={<ProtectedRoute resource="admin:permission-management"><AccessControl /></ProtectedRoute>} />
<Route path="admin" element={<ProtectedRoute resource="admin"><AdminPanel /></ProtectedRoute>} />
{/* Phase 4: 관리자 로그 */}
<Route path="admin/audit-logs" element={<ProtectedRoute resource="admin:audit-logs"><AuditLogs /></ProtectedRoute>} />
<Route path="admin/access-logs" element={<ProtectedRoute resource="admin:access-logs"><AccessLogs /></ProtectedRoute>} />
<Route path="admin/login-history" element={<ProtectedRoute resource="admin:login-history"><LoginHistoryView /></ProtectedRoute>} />
{/* Phase 4: 모선 워크플로우 */}
<Route path="parent-inference/review" element={<ProtectedRoute resource="parent-inference-workflow:parent-review"><ParentReview /></ProtectedRoute>} />
<Route path="parent-inference/exclusion" element={<ProtectedRoute resource="parent-inference-workflow:parent-exclusion"><ParentExclusion /></ProtectedRoute>} />
<Route path="parent-inference/label-session" element={<ProtectedRoute resource="parent-inference-workflow:label-session"><LabelSession /></ProtectedRoute>} />
{/* 기존 유지 */}
<Route path="events" element={<ProtectedRoute resource="surveillance:live-map"><LiveMapView /></ProtectedRoute>} />
<Route path="map-control" element={<ProtectedRoute resource="surveillance:map-control"><MapControl /></ProtectedRoute>} />
<Route path="vessel/:id" element={<ProtectedRoute resource="vessel:vessel-detail"><VesselDetail /></ProtectedRoute>} />
</Route>
</Routes>
<AppRoutes />
</AuthProvider>
</BrowserRouter>
);

파일 보기

@ -1,5 +1,6 @@
import { createContext, useContext, useState, useEffect, useCallback, type ReactNode } from 'react';
import { fetchMe, loginApi, logoutApi, LoginError, type BackendUser } from '@/services/authApi';
import { fetchMe, loginApi, logoutApi, LoginError, type BackendUser, type MenuConfigItem } from '@/services/authApi';
import { useMenuStore } from '@stores/menuStore';
/*
* SFR-01: 시스템
@ -33,46 +34,6 @@ export interface AuthUser {
// ─── 세션 타임아웃 (30분) ──────────────────
const SESSION_TIMEOUT = 30 * 60 * 1000;
// 경로 → 권한 리소스 매핑 (ProtectedRoute용)
const PATH_TO_RESOURCE: Record<string, string> = {
'/dashboard': 'dashboard',
'/monitoring': 'monitoring',
'/events': 'surveillance:live-map',
'/map-control': 'surveillance:map-control',
'/dark-vessel': 'detection:dark-vessel',
'/gear-detection': 'detection:gear-detection',
'/china-fishing': 'detection:china-fishing',
'/vessel': 'vessel',
'/risk-map': 'risk-assessment:risk-map',
'/enforcement-plan': 'risk-assessment:enforcement-plan',
'/patrol-route': 'patrol:patrol-route',
'/fleet-optimization': 'patrol:fleet-optimization',
'/enforcement-history': 'enforcement:enforcement-history',
'/event-list': 'enforcement:event-list',
'/mobile-service': 'field-ops:mobile-service',
'/ship-agent': 'field-ops:ship-agent',
'/ai-alert': 'field-ops:ai-alert',
'/ai-assistant': 'ai-operations:ai-assistant',
'/ai-model': 'ai-operations:ai-model',
'/mlops': 'ai-operations:mlops',
'/statistics': 'statistics:statistics',
'/external-service': 'statistics:external-service',
'/admin/audit-logs': 'admin:audit-logs',
'/admin/access-logs': 'admin:access-logs',
'/admin/login-history': 'admin:login-history',
'/admin': 'admin',
'/access-control': 'admin:permission-management',
'/system-config': 'admin:system-config',
'/notices': 'admin',
'/reports': 'statistics:statistics',
'/data-hub': 'admin:system-config',
// 모선 워크플로우
'/parent-inference/review': 'parent-inference-workflow:parent-review',
'/parent-inference/exclusion': 'parent-inference-workflow:parent-exclusion',
'/parent-inference/label-session': 'parent-inference-workflow:label-session',
'/parent-inference': 'parent-inference-workflow',
};
interface AuthContextType {
user: AuthUser | null;
loading: boolean;
@ -132,7 +93,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
let alive = true;
fetchMe()
.then((b) => {
if (alive && b) setUser(backendToAuthUser(b));
if (alive && b) {
setUser(backendToAuthUser(b));
if (b.menuConfig) useMenuStore.getState().setMenuConfig(b.menuConfig);
}
})
.finally(() => {
if (alive) setLoading(false);
@ -174,6 +138,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
try {
const b = await loginApi(account, password);
setUser(backendToAuthUser(b));
if (b.menuConfig) useMenuStore.getState().setMenuConfig(b.menuConfig);
setLastActivity(Date.now());
} catch (e) {
if (e instanceof LoginError) throw e;
@ -186,6 +151,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
await logoutApi();
} finally {
setUser(null);
useMenuStore.getState().clear();
}
}, []);
@ -200,10 +166,9 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const hasAccess = useCallback(
(path: string) => {
if (!user) return false;
// 경로의 첫 세그먼트로 매핑
const matched = Object.keys(PATH_TO_RESOURCE).find((p) => path.startsWith(p));
if (!matched) return true; // 매핑 없는 경로는 허용 (안전한 기본값으로 변경 가능)
const resource = PATH_TO_RESOURCE[matched];
// DB menu_config 기반 longest-match (PATH_TO_RESOURCE 대체)
const resource = useMenuStore.getState().getResourceForPath(path);
if (!resource) return true;
return hasPermission(resource, 'READ');
},
[user, hasPermission],

파일 보기

@ -0,0 +1,163 @@
import { lazy, type ComponentType } from 'react';
type LazyComponent = React.LazyExoticComponent<ComponentType<unknown>>;
/**
* DB menu_config.component_key lazy-loaded React .
* 1 .
*/
export const COMPONENT_REGISTRY: Record<string, LazyComponent> = {
// ── 상황판·감시 ──
'features/dashboard/Dashboard': lazy(() =>
import('@features/dashboard/Dashboard').then((m) => ({ default: m.Dashboard })),
),
'features/monitoring/MonitoringDashboard': lazy(() =>
import('@features/monitoring/MonitoringDashboard').then((m) => ({
default: m.MonitoringDashboard,
})),
),
'features/surveillance/LiveMapView': lazy(() =>
import('@features/surveillance').then((m) => ({ default: m.LiveMapView })),
),
'features/surveillance/MapControl': lazy(() =>
import('@features/surveillance').then((m) => ({ default: m.MapControl })),
),
// ── 위험도·단속 ──
'features/risk-assessment/RiskMap': lazy(() =>
import('@features/risk-assessment').then((m) => ({ default: m.RiskMap })),
),
'features/risk-assessment/EnforcementPlan': lazy(() =>
import('@features/risk-assessment').then((m) => ({ default: m.EnforcementPlan })),
),
// ── 탐지 ──
'features/detection/DarkVesselDetection': lazy(() =>
import('@features/detection').then((m) => ({ default: m.DarkVesselDetection })),
),
'features/detection/GearDetection': lazy(() =>
import('@features/detection').then((m) => ({ default: m.GearDetection })),
),
'features/detection/ChinaFishing': lazy(() =>
import('@features/detection').then((m) => ({ default: m.ChinaFishing })),
),
'features/detection/GearCollisionDetection': lazy(() =>
import('@features/detection').then((m) => ({ default: m.GearCollisionDetection })),
),
'features/detection/IllegalFishingPattern': lazy(() =>
import('@features/detection').then((m) => ({ default: m.IllegalFishingPattern })),
),
'features/detection/TransshipmentDetection': lazy(() =>
import('@features/detection').then((m) => ({ default: m.TransshipmentDetection })),
),
// ── 단속·이벤트 ──
'features/enforcement/EnforcementHistory': lazy(() =>
import('@features/enforcement').then((m) => ({ default: m.EnforcementHistory })),
),
'features/enforcement/EventList': lazy(() =>
import('@features/enforcement').then((m) => ({ default: m.EventList })),
),
// ── 통계 ──
'features/statistics/Statistics': lazy(() =>
import('@features/statistics').then((m) => ({ default: m.Statistics })),
),
'features/statistics/ReportManagement': lazy(() =>
import('@features/statistics').then((m) => ({ default: m.ReportManagement })),
),
'features/statistics/ExternalService': lazy(() =>
import('@features/statistics').then((m) => ({ default: m.ExternalService })),
),
// ── 순찰 ──
'features/patrol/PatrolRoute': lazy(() =>
import('@features/patrol').then((m) => ({ default: m.PatrolRoute })),
),
'features/patrol/FleetOptimization': lazy(() =>
import('@features/patrol').then((m) => ({ default: m.FleetOptimization })),
),
// ── 현장작전 ──
'features/field-ops/AIAlert': lazy(() =>
import('@features/field-ops').then((m) => ({ default: m.AIAlert })),
),
'features/field-ops/MobileService': lazy(() =>
import('@features/field-ops').then((m) => ({ default: m.MobileService })),
),
'features/field-ops/ShipAgent': lazy(() =>
import('@features/field-ops').then((m) => ({ default: m.ShipAgent })),
),
// ── AI 운영 ──
'features/ai-operations/AIModelManagement': lazy(() =>
import('@features/ai-operations').then((m) => ({ default: m.AIModelManagement })),
),
'features/ai-operations/MLOpsPage': lazy(() =>
import('@features/ai-operations').then((m) => ({ default: m.MLOpsPage })),
),
'features/ai-operations/LGCNSMLOpsPage': lazy(() =>
import('@features/ai-operations').then((m) => ({ default: m.LGCNSMLOpsPage })),
),
'features/ai-operations/LLMOpsPage': lazy(() =>
import('@features/ai-operations').then((m) => ({ default: m.LLMOpsPage })),
),
'features/ai-operations/AIAssistant': lazy(() =>
import('@features/ai-operations').then((m) => ({ default: m.AIAssistant })),
),
// ── 관리 ──
'features/admin/AdminPanel': lazy(() =>
import('@features/admin').then((m) => ({ default: m.AdminPanel })),
),
'features/admin/SystemConfig': lazy(() =>
import('@features/admin').then((m) => ({ default: m.SystemConfig })),
),
'features/admin/DataHub': lazy(() =>
import('@features/admin').then((m) => ({ default: m.DataHub })),
),
'features/admin/AccessControl': lazy(() =>
import('@features/admin').then((m) => ({ default: m.AccessControl })),
),
'features/admin/NoticeManagement': lazy(() =>
import('@features/admin').then((m) => ({ default: m.NoticeManagement })),
),
'features/admin/AuditLogs': lazy(() =>
import('@features/admin/AuditLogs').then((m) => ({ default: m.AuditLogs })),
),
'features/admin/AccessLogs': lazy(() =>
import('@features/admin/AccessLogs').then((m) => ({ default: m.AccessLogs })),
),
'features/admin/LoginHistoryView': lazy(() =>
import('@features/admin/LoginHistoryView').then((m) => ({
default: m.LoginHistoryView,
})),
),
'features/admin/AISecurityPage': lazy(() =>
import('@features/admin').then((m) => ({ default: m.AISecurityPage })),
),
'features/admin/AIAgentSecurityPage': lazy(() =>
import('@features/admin').then((m) => ({ default: m.AIAgentSecurityPage })),
),
'features/admin/DataRetentionPolicy': lazy(() =>
import('@features/admin').then((m) => ({ default: m.DataRetentionPolicy })),
),
'features/admin/DataModelVerification': lazy(() =>
import('@features/admin').then((m) => ({ default: m.DataModelVerification })),
),
'features/admin/PerformanceMonitoring': lazy(() =>
import('@features/admin').then((m) => ({ default: m.PerformanceMonitoring })),
),
// ── 모선 워크플로우 ──
'features/parent-inference/ParentReview': lazy(() =>
import('@features/parent-inference/ParentReview').then((m) => ({
default: m.ParentReview,
})),
),
'features/parent-inference/ParentExclusion': lazy(() =>
import('@features/parent-inference/ParentExclusion').then((m) => ({
default: m.ParentExclusion,
})),
),
'features/parent-inference/LabelSession': lazy(() =>
import('@features/parent-inference/LabelSession').then((m) => ({
default: m.LabelSession,
})),
),
// ── 선박 (숨김 라우트) ──
'features/vessel/VesselDetail': lazy(() =>
import('@features/vessel').then((m) => ({ default: m.VesselDetail })),
),
};

파일 보기

@ -0,0 +1,54 @@
import {
LayoutDashboard, Activity, Radar, Map, Layers, Shield,
EyeOff, Anchor, Ship, FileText, List, BarChart3,
Navigation, Users, Send, Smartphone, Monitor,
GitBranch, CheckSquare, Ban, Tag, Settings,
Brain, Cpu, MessageSquare, Database, Wifi, Globe,
Fingerprint, Megaphone, ScrollText, History, KeyRound,
type LucideIcon,
} from 'lucide-react';
/**
* DB icon Lucide React .
* .
*/
const ICON_MAP: Record<string, LucideIcon> = {
LayoutDashboard,
Activity,
Radar,
Map,
Layers,
Shield,
EyeOff,
Anchor,
Ship,
FileText,
List,
BarChart3,
Navigation,
Users,
Send,
Smartphone,
Monitor,
GitBranch,
CheckSquare,
Ban,
Tag,
Settings,
Brain,
Cpu,
MessageSquare,
Database,
Wifi,
Globe,
Fingerprint,
Megaphone,
ScrollText,
History,
KeyRound,
};
export function resolveIcon(name: string | null): LucideIcon | null {
if (!name) return null;
return ICON_MAP[name] ?? null;
}

파일 보기

@ -2,18 +2,16 @@ import { useState, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { Outlet, NavLink, useNavigate, useLocation } from 'react-router-dom';
import {
LayoutDashboard, Map, List, Ship, Anchor, Radar,
FileText, Settings, LogOut, ChevronLeft, ChevronRight,
Shield, Bell, Search, Fingerprint, Clock, Lock, Database, Megaphone, Layers,
Download, FileSpreadsheet, Printer, Wifi, Brain, Activity,
ChevronsLeft, ChevronsRight,
Navigation, Users, EyeOff, BarChart3, Globe,
Smartphone, Monitor, Send, Cpu, MessageSquare,
GitBranch, CheckSquare, Ban, Tag, ScrollText, History, KeyRound,
LogOut, ChevronLeft, ChevronRight,
Shield, Bell, Search, Clock, Lock,
Download, FileSpreadsheet, Printer,
} from 'lucide-react';
import { useAuth, type UserRole } from '@/app/auth/AuthContext';
import { useAuth } from '@/app/auth/AuthContext';
import { getRoleColorHex } from '@shared/constants/userRoles';
import { NotificationBanner, NotificationPopup, type SystemNotice } from '@shared/components/common/NotificationBanner';
import { useSettingsStore } from '@stores/settingsStore';
import { useMenuStore, getMenuLabel, type MenuConfigItem } from '@stores/menuStore';
import { resolveIcon } from '@/app/iconRegistry';
/*
* SFR-01 :
@ -27,13 +25,6 @@ import { useSettingsStore } from '@stores/settingsStore';
* - 하단: 페이지네이션
*/
const ROLE_COLORS: Record<UserRole, string> = {
ADMIN: 'text-red-400',
OPERATOR: 'text-blue-400',
ANALYST: 'text-purple-400',
FIELD: 'text-green-400',
VIEWER: 'text-yellow-400',
};
const AUTH_METHOD_LABELS: Record<string, string> = {
password: 'ID/PW',
@ -41,113 +32,12 @@ const AUTH_METHOD_LABELS: Record<string, string> = {
sso: 'SSO',
};
interface NavItem { to: string; icon: React.ElementType; labelKey: string; }
interface NavGroup { groupKey: string; icon: React.ElementType; items: NavItem[]; }
type NavEntry = NavItem | NavGroup;
const isGroup = (entry: NavEntry): entry is NavGroup => 'groupKey' in entry;
const NAV_ENTRIES: NavEntry[] = [
// ── 상황판·감시 ──
{ to: '/dashboard', icon: LayoutDashboard, labelKey: 'nav.dashboard' },
{ to: '/monitoring', icon: Activity, labelKey: 'nav.monitoring' },
{ to: '/events', icon: Radar, labelKey: 'nav.eventList' },
{ to: '/map-control', icon: Map, labelKey: 'nav.riskMap' },
// ── 위험도·단속 ──
{ to: '/risk-map', icon: Layers, labelKey: 'nav.riskMap' },
{ to: '/enforcement-plan', icon: Shield, labelKey: 'nav.enforcementPlan' },
// ── 탐지 ──
{ to: '/dark-vessel', icon: EyeOff, labelKey: 'nav.darkVessel' },
{ to: '/gear-detection', icon: Anchor, labelKey: 'nav.gearDetection' },
{ to: '/china-fishing', icon: Ship, labelKey: 'nav.chinaFishing' },
// ── 이력·통계 ──
{ to: '/enforcement-history', icon: FileText, labelKey: 'nav.enforcementHistory' },
{ to: '/event-list', icon: List, labelKey: 'nav.eventList' },
{ to: '/statistics', icon: BarChart3, labelKey: 'nav.statistics' },
{ to: '/reports', icon: FileText, labelKey: 'nav.reports' },
// ── 함정용 (그룹) ──
{
groupKey: 'group.fieldOps', icon: Ship,
items: [
{ to: '/patrol-route', icon: Navigation, labelKey: 'nav.patrolRoute' },
{ to: '/fleet-optimization', icon: Users, labelKey: 'nav.fleetOptimization' },
{ to: '/ai-alert', icon: Send, labelKey: 'nav.aiAlert' },
{ to: '/mobile-service', icon: Smartphone, labelKey: 'nav.mobileService' },
{ to: '/ship-agent', icon: Monitor, labelKey: 'nav.shipAgent' },
],
},
// ── 모선 워크플로우 (운영자 의사결정, 그룹) ──
{
groupKey: 'group.parentInference', icon: GitBranch,
items: [
{ to: '/parent-inference/review', icon: CheckSquare, labelKey: 'nav.parentReview' },
{ to: '/parent-inference/exclusion', icon: Ban, labelKey: 'nav.parentExclusion' },
{ to: '/parent-inference/label-session', icon: Tag, labelKey: 'nav.labelSession' },
],
},
// ── 관리자 (그룹) ──
{
groupKey: 'group.admin', icon: Settings,
items: [
{ to: '/ai-model', icon: Brain, labelKey: 'nav.aiModel' },
{ to: '/mlops', icon: Cpu, labelKey: 'nav.mlops' },
{ to: '/ai-assistant', icon: MessageSquare, labelKey: 'nav.aiAssistant' },
{ to: '/external-service', icon: Globe, labelKey: 'nav.externalService' },
{ to: '/data-hub', icon: Wifi, labelKey: 'nav.dataHub' },
{ to: '/system-config', icon: Database, labelKey: 'nav.systemConfig' },
{ to: '/notices', icon: Megaphone, labelKey: 'nav.notices' },
{ to: '/admin', icon: Settings, labelKey: 'nav.admin' },
{ to: '/access-control', icon: Fingerprint, labelKey: 'nav.accessControl' },
{ to: '/admin/audit-logs', icon: ScrollText, labelKey: 'nav.auditLogs' },
{ to: '/admin/access-logs', icon: History, labelKey: 'nav.accessLogs' },
{ to: '/admin/login-history', icon: KeyRound, labelKey: 'nav.loginHistory' },
],
},
];
// getPageLabel용 flat 목록
const NAV_ITEMS = NAV_ENTRIES.flatMap(e => isGroup(e) ? e.items : [e]);
function formatRemaining(seconds: number) {
const m = Math.floor(seconds / 60);
const s = seconds % 60;
return `${m}:${String(s).padStart(2, '0')}`;
}
// ─── 공통 페이지네이션 (간소형) ─────────────
function PagePagination({ page, totalPages, onPageChange }: {
page: number; totalPages: number; onPageChange: (p: number) => void;
}) {
if (totalPages <= 1) return null;
const range: number[] = [];
const maxVis = 5;
let s = Math.max(0, page - Math.floor(maxVis / 2));
const e = Math.min(totalPages - 1, s + maxVis - 1);
if (e - s < maxVis - 1) s = Math.max(0, e - maxVis + 1);
for (let i = s; i <= e; i++) range.push(i);
const btnCls = "p-1 rounded text-hint hover:text-heading hover:bg-surface-overlay disabled:opacity-30 disabled:cursor-not-allowed transition-colors";
return (
<div className="flex items-center justify-center gap-1">
<button onClick={() => onPageChange(0)} disabled={page === 0} className={btnCls}><ChevronsLeft className="w-3.5 h-3.5" /></button>
<button onClick={() => onPageChange(page - 1)} disabled={page === 0} className={btnCls}><ChevronLeft className="w-3.5 h-3.5" /></button>
{range.map((p) => (
<button
key={p}
onClick={() => onPageChange(p)}
className={`min-w-[22px] h-5 px-1 rounded text-[10px] font-medium transition-colors ${
p === page ? 'bg-blue-600 text-heading' : 'text-muted-foreground hover:text-heading hover:bg-surface-overlay'
}`}
>{p + 1}</button>
))}
<button onClick={() => onPageChange(page + 1)} disabled={page >= totalPages - 1} className={btnCls}><ChevronRight className="w-3.5 h-3.5" /></button>
<button onClick={() => onPageChange(totalPages - 1)} disabled={page >= totalPages - 1} className={btnCls}><ChevronsRight className="w-3.5 h-3.5" /></button>
<span className="text-[9px] text-hint ml-2">{page + 1} / {totalPages}</span>
</div>
);
}
export function MainLayout() {
const { t } = useTranslation('common');
const { theme, toggleTheme, language, toggleLanguage } = useSettingsStore();
@ -156,43 +46,18 @@ export function MainLayout() {
const location = useLocation();
const { user, logout, hasAccess, sessionRemaining } = useAuth();
const contentRef = useRef<HTMLDivElement>(null);
const { getTopLevelEntries, getChildren } = useMenuStore();
// getPageLabel: 현재 라우트에서 페이지명 가져오기 (i18n)
// getPageLabel: DB 메뉴에서 현재 라우트 페이지명 (DB labels 기반)
const getPageLabel = (pathname: string): string => {
const item = NAV_ITEMS.find((n) => pathname.startsWith(n.to));
return item ? t(item.labelKey) : '';
const allItems = useMenuStore.getState().items.filter((i) => i.menuType === 'ITEM' && i.urlPath);
const item = allItems.find((n) => pathname.startsWith(n.urlPath!));
return item ? getMenuLabel(item, language) : '';
};
// 공통 검색
const [pageSearch, setPageSearch] = useState('');
// 공통 스크롤 페이징 (페이지 단위 스크롤)
const [scrollPage, setScrollPage] = useState(0);
const scrollPageSize = 800; // px per page
const handleScrollPageChange = (p: number) => {
setScrollPage(p);
if (contentRef.current) {
contentRef.current.scrollTo({ top: p * scrollPageSize, behavior: 'smooth' });
}
};
// 스크롤 이벤트로 현재 페이지 추적
const handleScroll = () => {
if (contentRef.current) {
const { scrollTop, scrollHeight, clientHeight } = contentRef.current;
const totalScrollPages = Math.max(1, Math.ceil((scrollHeight - clientHeight) / scrollPageSize) + 1);
const currentPage = Math.min(Math.floor(scrollTop / scrollPageSize), totalScrollPages - 1);
setScrollPage(currentPage);
}
};
const getTotalScrollPages = () => {
if (!contentRef.current) return 1;
const { scrollHeight, clientHeight } = contentRef.current;
return Math.max(1, Math.ceil((scrollHeight - clientHeight) / scrollPageSize) + 1);
};
// 인쇄
const handlePrint = () => {
const el = contentRef.current;
@ -257,7 +122,7 @@ export function MainLayout() {
});
// RBAC
const roleColor = user ? ROLE_COLORS[user.role] : null;
const roleColor = user ? getRoleColorHex(user.role) : null;
const isSessionWarning = sessionRemaining <= 5 * 60;
// SFR-02: 공통알림 데이터
@ -310,7 +175,7 @@ export function MainLayout() {
<div className="mx-2 mt-2 px-3 py-2 rounded-lg bg-surface-overlay border border-border">
<div className="flex items-center gap-1.5">
<Lock className="w-3 h-3 text-hint" />
<span className={`text-[9px] font-bold whitespace-nowrap overflow-hidden text-ellipsis ${roleColor}`}>{t(`role.${user.role}`)}</span>
<span className="text-[9px] font-bold whitespace-nowrap overflow-hidden text-ellipsis" style={{ color: roleColor ?? undefined }}>{t(`role.${user.role}`)}</span>
</div>
<div className="text-[8px] text-hint mt-0.5">
{t('layout.auth')} {AUTH_METHOD_LABELS[user.authMethod] || user.authMethod}
@ -318,64 +183,31 @@ export function MainLayout() {
</div>
)}
{/* 네비게이션 — RBAC 기반 필터링 + 그룹 메뉴 */}
{/* 네비게이션 — DB menu_config 기반 동적 렌더링 + RBAC 필터 */}
<nav className="flex-1 overflow-y-auto py-2 px-2 space-y-0.5">
{NAV_ENTRIES.map((entry) => {
if (isGroup(entry)) {
// 그룹 내 RBAC 필터링
const groupItems = entry.items.filter((item) => hasAccess(item.to));
if (groupItems.length === 0) return null;
const GroupIcon = entry.icon;
const isAnyActive = groupItems.some((item) => location.pathname.startsWith(item.to));
{getTopLevelEntries().map((entry) => {
if (entry.menuType === 'GROUP') {
return (
<div key={entry.groupKey}>
{/* 그룹 헤더 */}
<button
onClick={() => toggleGroup(entry.groupKey)}
className={`flex items-center gap-2.5 px-3 py-2 rounded-lg text-[12px] font-medium w-full transition-colors ${
isAnyActive || openGroups.has(entry.groupKey)
? 'text-foreground bg-surface-overlay'
: 'text-hint hover:bg-surface-overlay hover:text-label'
}`}
>
<GroupIcon className="w-4 h-4 shrink-0" />
{!collapsed && (
<>
<span className="flex-1 text-left whitespace-nowrap overflow-hidden text-ellipsis">{t(entry.groupKey)}</span>
<ChevronRight className={`w-3 h-3 shrink-0 transition-transform ${openGroups.has(entry.groupKey) || isAnyActive ? 'rotate-90' : ''}`} />
</>
)}
</button>
{/* 그룹 하위 메뉴 */}
{(openGroups.has(entry.groupKey) || isAnyActive) && (
<div className={`mt-0.5 space-y-0.5 ${collapsed ? '' : 'ml-3 pl-2 border-l border-border'}`}>
{groupItems.map((item) => (
<NavLink
key={item.to}
to={item.to}
className={({ isActive }) =>
`flex items-center gap-2 px-2.5 py-1.5 rounded-lg text-[11px] font-medium transition-colors ${
isActive
? 'bg-blue-600/15 text-blue-400 border border-blue-500/20'
: 'text-muted-foreground hover:bg-surface-overlay hover:text-foreground border border-transparent'
}`
}
>
<item.icon className="w-3.5 h-3.5 shrink-0" />
{!collapsed && <span className="whitespace-nowrap overflow-hidden text-ellipsis">{t(item.labelKey)}</span>}
</NavLink>
))}
</div>
)}
</div>
<GroupMenu
key={entry.menuCd}
group={entry}
children={getChildren(entry.menuCd)}
collapsed={collapsed}
hasAccess={hasAccess}
openGroups={openGroups}
toggleGroup={toggleGroup}
location={location}
language={language}
/>
);
}
// 일반 메뉴 아이템
if (!hasAccess(entry.to)) return null;
// 일반 ITEM
if (!entry.urlPath || !hasAccess(entry.urlPath)) return null;
const Icon = resolveIcon(entry.icon);
return (
<NavLink
key={entry.to}
to={entry.to}
key={entry.menuCd}
to={entry.urlPath}
className={({ isActive }) =>
`flex items-center gap-2.5 px-3 py-2 rounded-lg text-[12px] font-medium transition-colors ${
isActive
@ -384,8 +216,8 @@ export function MainLayout() {
}`
}
>
<entry.icon className="w-4 h-4 shrink-0" />
{!collapsed && <span className="whitespace-nowrap overflow-hidden text-ellipsis">{t(entry.labelKey)}</span>}
{Icon && <Icon className="w-4 h-4 shrink-0" />}
{!collapsed && <span className="whitespace-nowrap overflow-hidden text-ellipsis">{getMenuLabel(entry, language)}</span>}
</NavLink>
);
})}
@ -429,7 +261,7 @@ export function MainLayout() {
<div className="flex items-center gap-3">
<div className="relative">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-hint" />
<input
<input aria-label={t('layout.searchPlaceholder')}
type="text"
placeholder={t('layout.searchPlaceholder')}
className="w-56 bg-surface-overlay border border-border rounded-lg pl-8 pr-3 py-1.5 text-[11px] text-heading placeholder:text-hint focus:outline-none focus:border-blue-500/40"
@ -442,7 +274,7 @@ export function MainLayout() {
<div className="w-1.5 h-1.5 rounded-full bg-red-500 animate-pulse" />
<span className="text-[10px] text-red-400 font-bold whitespace-nowrap">{t('layout.alertCount', { count: 3 })}</span>
</div>
<button className="relative p-1.5 rounded-lg hover:bg-surface-overlay text-muted-foreground hover:text-heading transition-colors">
<button type="button" aria-label={t('layout.notifications', { defaultValue: '알림' })} className="relative p-1.5 rounded-lg hover:bg-surface-overlay text-muted-foreground hover:text-heading transition-colors">
<Bell className="w-4 h-4" />
<span className="absolute top-0.5 right-0.5 w-2 h-2 bg-red-500 rounded-full" />
</button>
@ -450,8 +282,9 @@ export function MainLayout() {
{/* 언어 토글 */}
<button
onClick={toggleLanguage}
aria-label={t('aria.languageToggle')}
className="px-2 py-1 rounded-lg text-[10px] font-bold bg-surface-overlay border border-border text-label hover:text-heading transition-colors whitespace-nowrap"
title={language === 'ko' ? 'Switch to English' : '한국어로 전환'}
title={language === 'ko' ? t('message.switchToEnglish') : t('message.switchToKorean')}
>
{language === 'ko' ? 'EN' : '한국어'}
</button>
@ -485,7 +318,7 @@ export function MainLayout() {
<div className="text-[8px] text-hint">{user.org}</div>
</div>
{roleColor && (
<span className={`text-[8px] font-bold px-1.5 py-0.5 rounded whitespace-nowrap ${roleColor} bg-white/[0.04]`}>
<span className="text-[8px] font-bold px-1.5 py-0.5 rounded whitespace-nowrap bg-white/[0.04]" style={{ color: roleColor }}>
{user.role}
</span>
)}
@ -506,6 +339,7 @@ export function MainLayout() {
<div className="relative flex items-center">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3 h-3 text-hint pointer-events-none" />
<input
aria-label={t('aria.searchInPage')}
value={pageSearch}
onChange={(e) => setPageSearch(e.target.value)}
onKeyDown={(e) => {
@ -522,7 +356,7 @@ export function MainLayout() {
(window as unknown as { find: (s: string) => boolean }).find?.(pageSearch);
}
}}
className="flex items-center gap-1 px-2.5 py-1 rounded-r-md text-[9px] bg-blue-600 hover:bg-blue-500 text-heading font-medium border border-blue-600 transition-colors"
className="flex items-center gap-1 px-2.5 py-1 rounded-r-md text-[9px] bg-blue-400 hover:bg-blue-300 text-on-bright font-medium border border-blue-600 transition-colors"
>
<Search className="w-3 h-3" />
{t('action.search')}
@ -559,19 +393,9 @@ export function MainLayout() {
<main
ref={contentRef}
className="flex-1 overflow-auto"
onScroll={handleScroll}
>
<Outlet />
</main>
{/* SFR-02: 공통 페이지네이션 (하단) */}
<div className="shrink-0 border-t border-border bg-background/60 px-4 py-1">
<PagePagination
page={scrollPage}
totalPages={getTotalScrollPages()}
onPageChange={handleScrollPageChange}
/>
</div>
</div>
{/* SFR-02: 공통알림 팝업 */}
@ -579,3 +403,88 @@ export function MainLayout() {
</div>
);
}
/* ─── 그룹 메뉴 서브 컴포넌트 (DB 기반) ─── */
function GroupMenu({
group,
children,
collapsed,
hasAccess,
openGroups,
toggleGroup,
location,
language,
}: {
group: MenuConfigItem;
children: MenuConfigItem[];
collapsed: boolean;
hasAccess: (path: string) => boolean;
openGroups: Set<string>;
toggleGroup: (name: string) => void;
location: { pathname: string };
language: string;
}) {
const navItems = children.filter((c) => c.menuType === 'ITEM' && c.urlPath);
const accessibleItems = navItems.filter((c) => hasAccess(c.urlPath!));
if (accessibleItems.length === 0) return null;
const GroupIcon = resolveIcon(group.icon);
const isAnyActive = accessibleItems.some((c) => location.pathname.startsWith(c.urlPath!));
const isOpen = openGroups.has(group.menuCd) || isAnyActive;
return (
<div>
<button
type="button"
onClick={() => toggleGroup(group.menuCd)}
className={`flex items-center gap-2.5 px-3 py-2 rounded-lg text-[12px] font-medium w-full transition-colors ${
isOpen ? 'text-foreground bg-surface-overlay' : 'text-hint hover:bg-surface-overlay hover:text-label'
}`}
>
{GroupIcon && <GroupIcon className="w-4 h-4 shrink-0" />}
{!collapsed && (
<>
<span className="flex-1 text-left whitespace-nowrap overflow-hidden text-ellipsis">
{getMenuLabel(group, language)}
</span>
<ChevronRight className={`w-3 h-3 shrink-0 transition-transform ${isOpen ? 'rotate-90' : ''}`} />
</>
)}
</button>
{isOpen && (
<div className={`mt-0.5 space-y-0.5 ${collapsed ? '' : 'ml-3 pl-2 border-l border-border'}`}>
{children.map((child) => {
if (child.menuType === 'DIVIDER') {
if (collapsed) return null;
return (
<div key={child.menuCd} className="pt-2 pb-0.5 px-2.5">
<span className="text-[8px] font-bold text-hint uppercase tracking-wider">{child.dividerLabel}</span>
</div>
);
}
if (!child.urlPath || !hasAccess(child.urlPath)) return null;
const ChildIcon = resolveIcon(child.icon);
return (
<NavLink
key={child.menuCd}
to={child.urlPath}
className={({ isActive }) =>
`flex items-center gap-2 px-2.5 py-1.5 rounded-lg text-[11px] font-medium transition-colors ${
isActive
? 'bg-blue-600/15 text-blue-400 border border-blue-500/20'
: 'text-muted-foreground hover:bg-surface-overlay hover:text-foreground border border-transparent'
}`
}
>
{ChildIcon && <ChildIcon className="w-3.5 h-3.5 shrink-0" />}
{!collapsed && (
<span className="whitespace-nowrap overflow-hidden text-ellipsis">{getMenuLabel(child, language)}</span>
)}
</NavLink>
);
})}
</div>
)}
</div>
);
}

파일 보기

@ -0,0 +1,140 @@
/* 디자인 쇼케이스 전용 스타일 */
.ds-shell {
display: flex;
flex-direction: column;
height: 100vh;
background: var(--background, #0b1220);
color: var(--foreground, #e2e8f0);
}
.ds-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem 1.5rem;
border-bottom: 1px solid rgb(51 65 85 / 0.5);
flex-shrink: 0;
background: var(--surface-overlay, rgb(15 23 42 / 0.6));
-webkit-backdrop-filter: blur(8px);
backdrop-filter: blur(8px);
}
.ds-body {
display: flex;
flex: 1;
min-height: 0;
}
.ds-nav {
width: 220px;
flex-shrink: 0;
padding: 1rem 0.75rem;
border-right: 1px solid rgb(51 65 85 / 0.5);
overflow-y: auto;
position: sticky;
top: 0;
}
.ds-main {
flex: 1;
overflow-y: auto;
padding: 2rem 2.5rem 6rem;
scroll-behavior: smooth;
}
.ds-main section {
margin-bottom: 4rem;
}
/* 추적 ID 시스템 */
.trk-item {
position: relative;
transition: outline-color 0.2s;
}
.trk-copyable {
cursor: pointer;
}
.trk-copyable:hover {
outline: 1px dashed rgb(59 130 246 / 0.5);
outline-offset: 4px;
}
.trk-active {
outline: 2px solid rgb(59 130 246);
outline-offset: 4px;
animation: trk-pulse 1.2s ease-out;
will-change: opacity;
}
.trk-item[data-copied='true'] {
outline: 2px solid rgb(34 197 94) !important;
outline-offset: 4px;
}
.trk-item[data-copied='true']::after {
content: '복사됨 ✓';
position: absolute;
top: -1.5rem;
left: 0;
font-size: 0.625rem;
color: rgb(34 197 94);
background: rgb(34 197 94 / 0.15);
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
pointer-events: none;
z-index: 10;
}
@keyframes trk-pulse {
0% {
opacity: 1;
}
50% {
opacity: 0.35;
}
100% {
opacity: 1;
}
}
/* 쇼케이스 그리드 */
.ds-grid {
display: grid;
gap: 0.75rem;
}
.ds-grid-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.ds-grid-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.ds-grid-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }
.ds-grid-6 { grid-template-columns: repeat(6, minmax(0, 1fr)); }
.ds-sample {
padding: 1rem;
background: var(--surface-raised, rgb(30 41 59 / 0.4));
border: 1px solid rgb(51 65 85 / 0.4);
border-radius: 0.5rem;
}
.ds-sample-label {
font-size: 0.625rem;
color: var(--text-hint, rgb(148 163 184));
font-family: ui-monospace, monospace;
margin-top: 0.5rem;
word-break: break-all;
}
.ds-code {
display: block;
padding: 0.75rem 1rem;
background: rgb(15 23 42 / 0.7);
border: 1px solid rgb(51 65 85 / 0.4);
border-radius: 0.375rem;
font-family: ui-monospace, monospace;
font-size: 0.75rem;
color: rgb(203 213 225);
white-space: pre;
overflow-x: auto;
}

파일 보기

@ -0,0 +1,192 @@
import { useEffect, useState } from 'react';
import { TrkProvider, useTrk } from './lib/TrkContext';
import { IntroSection } from './sections/IntroSection';
import { TokenSection } from './sections/TokenSection';
import { TypographySection } from './sections/TypographySection';
import { BadgeSection } from './sections/BadgeSection';
import { ButtonSection } from './sections/ButtonSection';
import { FormSection } from './sections/FormSection';
import { CardSectionShowcase } from './sections/CardSection';
import { LayoutSection } from './sections/LayoutSection';
import { CatalogSection } from './sections/CatalogSection';
import { GuideSection } from './sections/GuideSection';
import './DesignSystemApp.css';
interface NavItem {
id: string;
label: string;
anchor: string;
}
const NAV_ITEMS: NavItem[] = [
{ id: 'intro', label: '1. 소개', anchor: 'TRK-SEC-intro' },
{ id: 'token', label: '2. 테마 · 토큰', anchor: 'TRK-SEC-token' },
{ id: 'typography', label: '3. 타이포그래피', anchor: 'TRK-SEC-typography' },
{ id: 'badge', label: '4. Badge', anchor: 'TRK-SEC-badge' },
{ id: 'button', label: '5. Button', anchor: 'TRK-SEC-button' },
{ id: 'form', label: '6. Form', anchor: 'TRK-SEC-form' },
{ id: 'card', label: '7. Card / Section', anchor: 'TRK-SEC-card' },
{ id: 'layout', label: '8. Layout', anchor: 'TRK-SEC-layout' },
{ id: 'catalog', label: '9. 분류 카탈로그', anchor: 'TRK-SEC-catalog' },
{ id: 'guide', label: '10. 예외 / 가이드', anchor: 'TRK-SEC-guide' },
];
function DesignSystemShell() {
const { copyMode, setCopyMode } = useTrk();
const [theme, setTheme] = useState<'dark' | 'light'>('dark');
const [activeNav, setActiveNav] = useState<string>('intro');
// 테마 토글
useEffect(() => {
const root = document.documentElement;
root.classList.remove('dark', 'light');
root.classList.add(theme);
}, [theme]);
// 단축키: 'a' 입력 시 테마 전환 (input/textarea 등 편집 중에는 무시)
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key !== 'a' && e.key !== 'A') return;
if (e.ctrlKey || e.metaKey || e.altKey) return;
const target = e.target as HTMLElement | null;
const tag = target?.tagName;
if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT' || target?.isContentEditable) {
return;
}
setTheme((prev) => (prev === 'dark' ? 'light' : 'dark'));
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}, []);
// 스크롤 감지로 현재 네비 하이라이트
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
const id = entry.target.getAttribute('data-section-id');
if (id) setActiveNav(id);
}
}
},
{ rootMargin: '-40% 0px -55% 0px', threshold: 0 },
);
const sections = document.querySelectorAll('[data-section-id]');
sections.forEach((s) => observer.observe(s));
return () => observer.disconnect();
}, []);
const scrollTo = (anchor: string) => {
const el = document.querySelector(`[data-trk="${anchor}"]`);
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
};
return (
<div className="ds-shell">
{/* 고정 헤더 */}
<header className="ds-header">
<div className="flex items-center gap-3">
<h1 className="text-base font-bold text-heading">KCG Design System</h1>
<code className="text-[10px] text-hint font-mono">v0.1.0 · </code>
</div>
<div className="flex items-center gap-3">
<label className="flex items-center gap-1.5 cursor-pointer text-xs text-label">
<input
type="checkbox"
checked={copyMode}
onChange={(e) => setCopyMode(e.target.checked)}
className="accent-blue-500"
/>
ID
</label>
<button
type="button"
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
title="단축키: A"
className="px-3 py-1 rounded-md border border-slate-600/40 text-xs text-label hover:bg-slate-700/30 flex items-center gap-1.5"
>
<span>{theme === 'dark' ? '☾ Dark' : '☀ Light'}</span>
<kbd className="text-[9px] font-mono text-hint border border-slate-600/40 rounded px-1 py-0">A</kbd>
</button>
</div>
</header>
<div className="ds-body">
{/* 좌측 네비 */}
<nav className="ds-nav">
<div className="text-[10px] text-hint uppercase mb-2 tracking-wider">Sections</div>
<ul className="space-y-0.5">
{NAV_ITEMS.map((item) => (
<li key={item.id}>
<button
type="button"
onClick={() => scrollTo(item.anchor)}
className={`w-full text-left px-2 py-1.5 rounded text-xs transition-colors ${
activeNav === item.id
? 'bg-blue-500/15 text-blue-400 border-l-2 border-blue-500'
: 'text-label hover:bg-slate-700/20'
}`}
>
{item.label}
</button>
</li>
))}
</ul>
<div className="mt-6 pt-4 border-t border-slate-700/40 text-[10px] text-hint space-y-1">
<div>
<strong className="text-label"> ID </strong>
</div>
<code className="block font-mono">TRK-&lt;&gt;-&lt;&gt;</code>
<div className="mt-2">
: <code className="font-mono">#trk=ID</code>
</div>
</div>
</nav>
{/* 우측 컨텐츠 */}
<main className="ds-main">
<section data-section-id="intro">
<IntroSection />
</section>
<section data-section-id="token">
<TokenSection />
</section>
<section data-section-id="typography">
<TypographySection />
</section>
<section data-section-id="badge">
<BadgeSection />
</section>
<section data-section-id="button">
<ButtonSection />
</section>
<section data-section-id="form">
<FormSection />
</section>
<section data-section-id="card">
<CardSectionShowcase />
</section>
<section data-section-id="layout">
<LayoutSection />
</section>
<section data-section-id="catalog">
<CatalogSection />
</section>
<section data-section-id="guide">
<GuideSection />
</section>
</main>
</div>
</div>
);
}
export function DesignSystemApp() {
return (
<TrkProvider>
<DesignSystemShell />
</TrkProvider>
);
}

파일 보기

@ -0,0 +1,71 @@
import { type ReactNode, type CSSProperties, type MouseEvent } from 'react';
import { useTrk } from './TrkContext';
/**
* ID
* ID를 :
* 1. ID
* 2. "ID 복사 모드"
* 3. URL hash (#trk=TRK-BADGE-critical-sm)
*/
interface TrkProps {
id: string;
children: ReactNode;
className?: string;
style?: CSSProperties;
/** 인라인 요소로 렌더할지 여부 (기본: block) */
inline?: boolean;
}
export function Trk({ id, children, className = '', style, inline = false }: TrkProps) {
const { copyMode, activeId } = useTrk();
const isActive = activeId === id;
const handleClick = async (e: MouseEvent) => {
if (!copyMode) return;
e.preventDefault();
e.stopPropagation();
try {
await navigator.clipboard.writeText(id);
const el = e.currentTarget as HTMLElement;
el.dataset.copied = 'true';
setTimeout(() => delete el.dataset.copied, 800);
} catch {
// clipboard API 미지원 시 무시
}
};
const Wrapper = inline ? 'span' : 'div';
return (
<Wrapper
data-trk={id}
className={`trk-item ${isActive ? 'trk-active' : ''} ${copyMode ? 'trk-copyable' : ''} ${className}`}
style={style}
title={id}
onClick={handleClick}
>
{children}
</Wrapper>
);
}
export function TrkSectionHeader({
id,
title,
description,
}: {
id: string;
title: string;
description?: string;
}) {
return (
<div data-trk={id} className="mb-4">
<div className="flex items-baseline gap-2">
<h2 className="text-xl font-bold text-heading">{title}</h2>
<code className="text-[10px] text-hint font-mono">{id}</code>
</div>
{description && <p className="text-xs text-hint mt-1">{description}</p>}
</div>
);
}

파일 보기

@ -0,0 +1,50 @@
import { useContext, createContext, useState, useEffect, type ReactNode } from 'react';
interface TrkContextValue {
copyMode: boolean;
setCopyMode: (v: boolean) => void;
activeId: string | null;
setActiveId: (id: string | null) => void;
}
// eslint-disable-next-line react-refresh/only-export-components
export const TrkContext = createContext<TrkContextValue | null>(null);
export function TrkProvider({ children }: { children: ReactNode }) {
const [copyMode, setCopyMode] = useState(false);
const [activeId, setActiveId] = useState<string | null>(null);
// 딥링크 처리: #trk=TRK-BADGE-critical-sm
useEffect(() => {
const applyHash = () => {
const hash = window.location.hash;
const match = hash.match(/#trk=([\w-]+)/);
if (match) {
const id = match[1];
setActiveId(id);
setTimeout(() => {
const el = document.querySelector(`[data-trk="${id}"]`);
if (el) {
el.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
}, 50);
}
};
applyHash();
window.addEventListener('hashchange', applyHash);
return () => window.removeEventListener('hashchange', applyHash);
}, []);
return (
<TrkContext.Provider value={{ copyMode, setCopyMode, activeId, setActiveId }}>
{children}
</TrkContext.Provider>
);
}
// eslint-disable-next-line react-refresh/only-export-components
export function useTrk() {
const ctx = useContext(TrkContext);
if (!ctx) throw new Error('useTrk must be used within TrkProvider');
return ctx;
}

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