Phase 4-1: 운영자 워크플로우 백엔드 (자체 DB)
- ParentResolution / ParentReviewLog / CandidateExclusion / LabelSession 엔티티
- Repository 4종 + DTO 5종
- ParentInferenceWorkflowService (HYBRID 패턴):
- review (CONFIRM/REJECT/RESET) - parent-inference-workflow:parent-review (UPDATE)
- excludeForGroup - parent-inference-workflow:parent-exclusion (CREATE)
- excludeGlobal - parent-inference-workflow:exclusion-management (CREATE) [admin]
- releaseExclusion (UPDATE)
- createLabelSession / cancelLabelSession (CREATE/UPDATE)
- ParentInferenceWorkflowController: @RequirePermission으로 권한 강제
- 모든 액션에 @Auditable AOP → audit_log + review_log 동시 기록
Phase 4-2: PermTreeController + AdminLogController
- GET /api/perm-tree (모든 사용자) - 메뉴/사이드바 구성용
- GET /api/roles (admin:role-management) - 역할+권한 매트릭스
- GET /api/admin/audit-logs / access-logs / login-history
Phase 4-3: iran 백엔드 프록시 (stub)
- IranBackendClient: RestClient 기반, 호출 실패 시 null 반환 (graceful)
- VesselAnalysisProxyController: serviceAvailable=false 응답
- PredictionProxyController: DISCONNECTED 응답
- Phase 5에서 iran 백엔드 실 연결 시 코드 변경 최소
Phase 4-4: 프론트엔드 services
- parentInferenceApi.ts: 모선 워크플로우 22개 함수
- adminApi.ts: 감사로그/접근이력/로그인이력/권한트리/역할 조회
Phase 4-5: 사이드바 권한 필터링 + ProtectedRoute 권한 가드
- AuthContext.PATH_TO_RESOURCE에 신규 경로 매핑 추가
- ProtectedRoute에 resource/operation prop 추가
→ 권한 거부 시 403 페이지 표시
- 모든 라우트에 권한 리소스 명시
- MainLayout 사이드바: parent-inference-workflow + admin 로그 메뉴 추가
- 사이드바 hasAccess 필터링 (이전부터 구현됨, 신규 메뉴에도 자동 적용)
Phase 4-6: 신규 페이지 3종
- ParentReview.tsx: 모선 확정/거부/리셋 + 신규 등록 폼
- ParentExclusion.tsx: GROUP/GLOBAL 제외 등록 + 해제
- LabelSession.tsx: 학습 세션 생성/취소
- AuditLogs.tsx: 감사 로그 조회
- AccessLogs.tsx: 접근 이력 조회
- LoginHistoryView.tsx: 로그인 이력 조회
Phase 4-7: i18n 키 + 라우터 등록
- 한국어/영어 nav.* + group.* 키 추가
- App.tsx에 12개 신규 라우트 등록 + 권한 가드 적용
Phase 4-8: 검증 완료
- 백엔드 컴파일/기동 성공
- 프론트엔드 빌드 성공 (475ms)
- E2E 시나리오:
- operator 로그인 → CONFIRM 확정 → MANUAL_CONFIRMED 갱신
- operator GROUP 제외 → 성공
- operator GLOBAL 제외 → 403 FORBIDDEN (권한 없음)
- operator 학습 세션 생성 → ACTIVE
- admin GLOBAL 제외 → 성공
- 감사 로그 자동 기록: REVIEW_PARENT/EXCLUDE_CANDIDATE_GROUP/
LABEL_PARENT_CREATE/EXCLUDE_CANDIDATE_GLOBAL 등 14건
- 권한 트리 RBAC + AOP 정상 동작 확인
설계 핵심:
- 운영자 의사결정만 자체 DB에 저장 (HYBRID)
- iran 백엔드 데이터는 향후 Phase 5에서 합쳐서 표시
- @RequirePermission + @Auditable로 모든 액션 권한 + 감사 자동화
- 데모 계정으로 완전한 워크플로우 시연 가능
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
186 lines
5.7 KiB
TypeScript
186 lines
5.7 KiB
TypeScript
/**
|
|
* 모선 워크플로우 API 클라이언트.
|
|
* - 후보/리뷰: 자체 백엔드 (자체 DB의 운영자 결정)
|
|
* - 향후: iran 백엔드의 후보 데이터와 조합 (HYBRID)
|
|
*/
|
|
|
|
const API_BASE = import.meta.env.VITE_API_URL ?? '/api';
|
|
|
|
export interface ParentResolution {
|
|
id: number;
|
|
groupKey: string;
|
|
subClusterId: number;
|
|
status: 'UNRESOLVED' | 'MANUAL_CONFIRMED' | 'REVIEW_REQUIRED';
|
|
selectedParentMmsi: string | null;
|
|
rejectedCandidateMmsi: string | null;
|
|
approvedBy: string | null;
|
|
approvedAt: string | null;
|
|
rejectedAt: string | null;
|
|
manualComment: string | null;
|
|
createdAt: string;
|
|
updatedAt: string;
|
|
}
|
|
|
|
export interface CandidateExclusion {
|
|
id: number;
|
|
scopeType: 'GROUP' | 'GLOBAL';
|
|
groupKey: string | null;
|
|
subClusterId: number | null;
|
|
excludedMmsi: string;
|
|
reason: string | null;
|
|
actor: string | null;
|
|
actorAcnt: string | null;
|
|
createdAt: string;
|
|
releasedAt: string | null;
|
|
releasedByAcnt: string | null;
|
|
}
|
|
|
|
export interface LabelSession {
|
|
id: number;
|
|
groupKey: string;
|
|
subClusterId: number;
|
|
labelParentMmsi: string;
|
|
status: 'ACTIVE' | 'CANCELLED' | 'COMPLETED';
|
|
activeFrom: string;
|
|
activeUntil: string | null;
|
|
createdByAcnt: string | null;
|
|
cancelledAt: string | null;
|
|
cancelReason: string | null;
|
|
createdAt: string;
|
|
}
|
|
|
|
export interface ReviewLog {
|
|
id: number;
|
|
groupKey: string;
|
|
subClusterId: number | null;
|
|
action: string;
|
|
selectedParentMmsi: string | null;
|
|
actorAcnt: string | null;
|
|
comment: string | null;
|
|
createdAt: string;
|
|
}
|
|
|
|
export interface PageResponse<T> {
|
|
content: T[];
|
|
totalElements: number;
|
|
totalPages: number;
|
|
number: number;
|
|
size: number;
|
|
}
|
|
|
|
async function apiRequest<T>(path: string, init?: RequestInit): Promise<T> {
|
|
const res = await fetch(`${API_BASE}${path}`, {
|
|
credentials: 'include',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
...init,
|
|
});
|
|
if (!res.ok) {
|
|
let errBody = '';
|
|
try { errBody = await res.text(); } catch { /* ignore */ }
|
|
throw new Error(`API ${res.status}: ${path} ${errBody}`);
|
|
}
|
|
return res.json();
|
|
}
|
|
|
|
// ============================================================================
|
|
// Resolution
|
|
// ============================================================================
|
|
|
|
export function fetchReviewList(status?: string, page = 0, size = 20) {
|
|
const qs = new URLSearchParams();
|
|
if (status) qs.set('status', status);
|
|
qs.set('page', String(page));
|
|
qs.set('size', String(size));
|
|
return apiRequest<PageResponse<ParentResolution>>(`/parent-inference/review?${qs}`);
|
|
}
|
|
|
|
export function reviewParent(
|
|
groupKey: string,
|
|
subClusterId: number,
|
|
payload: { action: 'CONFIRM' | 'REJECT' | 'RESET'; selectedParentMmsi?: string; comment?: string },
|
|
) {
|
|
return apiRequest<ParentResolution>(
|
|
`/parent-inference/groups/${encodeURIComponent(groupKey)}/${subClusterId}/review`,
|
|
{ method: 'POST', body: JSON.stringify(payload) },
|
|
);
|
|
}
|
|
|
|
// ============================================================================
|
|
// Exclusions
|
|
// ============================================================================
|
|
|
|
export function fetchExclusions(scopeType?: 'GROUP' | 'GLOBAL', page = 0, size = 20) {
|
|
const qs = new URLSearchParams();
|
|
if (scopeType) qs.set('scopeType', scopeType);
|
|
qs.set('page', String(page));
|
|
qs.set('size', String(size));
|
|
return apiRequest<PageResponse<CandidateExclusion>>(`/parent-inference/exclusions?${qs}`);
|
|
}
|
|
|
|
export function excludeForGroup(
|
|
groupKey: string,
|
|
subClusterId: number,
|
|
payload: { excludedMmsi: string; reason?: string },
|
|
) {
|
|
return apiRequest<CandidateExclusion>(
|
|
`/parent-inference/groups/${encodeURIComponent(groupKey)}/${subClusterId}/exclusions`,
|
|
{ method: 'POST', body: JSON.stringify(payload) },
|
|
);
|
|
}
|
|
|
|
export function excludeGlobal(payload: { excludedMmsi: string; reason?: string }) {
|
|
return apiRequest<CandidateExclusion>(`/parent-inference/exclusions/global`, {
|
|
method: 'POST',
|
|
body: JSON.stringify(payload),
|
|
});
|
|
}
|
|
|
|
export function releaseExclusion(exclusionId: number, reason?: string) {
|
|
return apiRequest<CandidateExclusion>(`/parent-inference/exclusions/${exclusionId}/release`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ reason }),
|
|
});
|
|
}
|
|
|
|
// ============================================================================
|
|
// Label Sessions
|
|
// ============================================================================
|
|
|
|
export function fetchLabelSessions(status?: string, page = 0, size = 20) {
|
|
const qs = new URLSearchParams();
|
|
if (status) qs.set('status', status);
|
|
qs.set('page', String(page));
|
|
qs.set('size', String(size));
|
|
return apiRequest<PageResponse<LabelSession>>(`/parent-inference/label-sessions?${qs}`);
|
|
}
|
|
|
|
export function createLabelSession(
|
|
groupKey: string,
|
|
subClusterId: number,
|
|
payload: { labelParentMmsi: string; anchorSnapshot?: Record<string, unknown> },
|
|
) {
|
|
return apiRequest<LabelSession>(
|
|
`/parent-inference/groups/${encodeURIComponent(groupKey)}/${subClusterId}/label-sessions`,
|
|
{ method: 'POST', body: JSON.stringify(payload) },
|
|
);
|
|
}
|
|
|
|
export function cancelLabelSession(sessionId: number, reason?: string) {
|
|
return apiRequest<LabelSession>(`/parent-inference/label-sessions/${sessionId}/cancel`, {
|
|
method: 'POST',
|
|
body: JSON.stringify({ reason }),
|
|
});
|
|
}
|
|
|
|
// ============================================================================
|
|
// Review Logs (도메인 액션 이력)
|
|
// ============================================================================
|
|
|
|
export function fetchReviewLogs(groupKey?: string, page = 0, size = 50) {
|
|
const qs = new URLSearchParams();
|
|
if (groupKey) qs.set('groupKey', groupKey);
|
|
qs.set('page', String(page));
|
|
qs.set('size', String(size));
|
|
return apiRequest<PageResponse<ReviewLog>>(`/parent-inference/review-logs?${qs}`);
|
|
}
|