From 924135e360878b8448b07e1e1d9af5bdacf1bdf3 Mon Sep 17 00:00:00 2001 From: HYOJIN Date: Mon, 23 Mar 2026 17:12:20 +0900 Subject: [PATCH] =?UTF-8?q?feat(=EB=8F=99=EA=B8=B0=ED=99=94=ED=98=84?= =?UTF-8?q?=ED=99=A9):=20=EB=8F=99=EA=B8=B0=ED=99=94=20=ED=98=84=ED=99=A9?= =?UTF-8?q?=20=EB=A9=94=EB=89=B4=20=EC=B6=94=EA=B0=80=20(#1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - SyncStatusService: batch_flag 기반 테이블별 N/P/S 집계 (병렬 조회) - SyncDataPreviewResponse/SyncStatusResponse DTO 추가 - BatchController: sync-status 조회/미리보기/P상태조회/리셋 API 4개 - BatchTableProperties: application.yml 테이블 매핑 ConfigurationProperties - WebViewController: /sync-status SPA 라우트 추가 Frontend: - SyncStatus 페이지: 도메인 탭 + 테이블 아코디언 + 인라인 데이터 테이블 - SyncDataPreviewModal/StuckRecordsModal 컴포넌트 - batchApi.ts: SyncStatus 관련 타입 및 API 함수 추가 - App.tsx 라우트 + Navbar 메뉴 추가 Co-Authored-By: Claude Opus 4.6 (1M context) --- frontend/src/App.tsx | 2 + frontend/src/api/batchApi.ts | 56 +++ frontend/src/components/Navbar.tsx | 1 + frontend/src/components/StuckRecordsModal.tsx | 127 ++++++ .../src/components/SyncDataPreviewModal.tsx | 108 +++++ frontend/src/pages/SyncStatus.tsx | 393 ++++++++++++++++++ .../global/config/BatchTableProperties.java | 26 ++ .../global/controller/BatchController.java | 84 ++++ .../global/controller/WebViewController.java | 4 +- .../global/dto/SyncDataPreviewResponse.java | 26 ++ .../batch/global/dto/SyncStatusResponse.java | 59 +++ .../snp/batch/service/SyncStatusService.java | 306 ++++++++++++++ 12 files changed, 1190 insertions(+), 2 deletions(-) create mode 100644 frontend/src/components/StuckRecordsModal.tsx create mode 100644 frontend/src/components/SyncDataPreviewModal.tsx create mode 100644 frontend/src/pages/SyncStatus.tsx create mode 100644 src/main/java/com/snp/batch/global/config/BatchTableProperties.java create mode 100644 src/main/java/com/snp/batch/global/dto/SyncDataPreviewResponse.java create mode 100644 src/main/java/com/snp/batch/global/dto/SyncStatusResponse.java create mode 100644 src/main/java/com/snp/batch/service/SyncStatusService.java diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 276ee30..aad83e0 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -12,6 +12,7 @@ const Executions = lazy(() => import('./pages/Executions')); const ExecutionDetail = lazy(() => import('./pages/ExecutionDetail')); const Schedules = lazy(() => import('./pages/Schedules')); const Timeline = lazy(() => import('./pages/Timeline')); +const SyncStatus = lazy(() => import('./pages/SyncStatus')); function AppLayout() { const { toasts, removeToast } = useToastContext(); @@ -28,6 +29,7 @@ function AppLayout() { } /> } /> } /> + } /> diff --git a/frontend/src/api/batchApi.ts b/frontend/src/api/batchApi.ts index 869b470..03b247e 100644 --- a/frontend/src/api/batchApi.ts +++ b/frontend/src/api/batchApi.ts @@ -273,6 +273,48 @@ export interface ExecutionStatisticsDto { avgDurationMs: number; } +// ── Sync Status ───────────────────────────────────────────── + +export interface SyncStatusSummary { + totalTables: number; + pendingCount: number; + processingCount: number; + completedCount: number; + stuckTables: number; +} + +export interface SyncTableStatus { + tableKey: string; + sourceTable: string; + targetTable: string; + domain: string; + pendingCount: number; + processingCount: number; + completedCount: number; + lastSyncTime: string | null; + stuck: boolean; +} + +export interface SyncDomainGroup { + domain: string; + domainLabel: string; + tables: SyncTableStatus[]; +} + +export interface SyncStatusResponse { + summary: SyncStatusSummary; + domains: SyncDomainGroup[]; +} + +export interface SyncDataPreviewResponse { + tableKey: string; + targetTable: string; + targetSchema: string; + columns: string[]; + rows: Record[]; + totalCount: number; +} + // ── API Functions ──────────────────────────────────────────── export const batchApi = { @@ -399,4 +441,18 @@ export const batchApi = { resetRetryCount: (ids: number[]) => postJson<{ success: boolean; message: string; resetCount?: number }>( `${BASE}/failed-records/reset-retry`, { ids }), + + // Sync Status + getSyncStatus: () => + fetchJson(`${BASE}/sync-status`), + + getSyncDataPreview: (tableKey: string, limit = 10) => + fetchJson(`${BASE}/sync-status/${tableKey}/preview?limit=${limit}`), + + getStuckRecords: (tableKey: string, limit = 50) => + fetchJson(`${BASE}/sync-status/${tableKey}/stuck?limit=${limit}`), + + resetStuckRecords: (tableKey: string) => + postJson<{ success: boolean; message: string; resetCount?: number }>( + `${BASE}/sync-status/${tableKey}/reset`), }; diff --git a/frontend/src/components/Navbar.tsx b/frontend/src/components/Navbar.tsx index 663baca..d1007cf 100644 --- a/frontend/src/components/Navbar.tsx +++ b/frontend/src/components/Navbar.tsx @@ -7,6 +7,7 @@ const navItems = [ { path: '/jobs', label: '작업', icon: '⚙️' }, { path: '/schedules', label: '스케줄', icon: '🕐' }, { path: '/schedule-timeline', label: '타임라인', icon: '📅' }, + { path: '/sync-status', label: '동기화 현황', icon: '🔄' }, ]; export default function Navbar() { diff --git a/frontend/src/components/StuckRecordsModal.tsx b/frontend/src/components/StuckRecordsModal.tsx new file mode 100644 index 0000000..5e9f0cb --- /dev/null +++ b/frontend/src/components/StuckRecordsModal.tsx @@ -0,0 +1,127 @@ +import { useState, useEffect } from 'react'; +import { batchApi, type SyncDataPreviewResponse } from '../api/batchApi'; +import LoadingSpinner from './LoadingSpinner'; + +interface Props { + open: boolean; + tableKey: string; + tableName: string; + onClose: () => void; + onReset: () => void; +} + +export default function StuckRecordsModal({ open, tableKey, tableName, onClose, onReset }: Props) { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (!open || !tableKey) return; + setLoading(true); + setError(null); + batchApi.getStuckRecords(tableKey, 50) + .then(setData) + .catch((e) => setError(e.message)) + .finally(() => setLoading(false)); + }, [open, tableKey]); + + if (!open) return null; + + return ( +
+
e.stopPropagation()} + > + {/* Header */} +
+
+
+ ⚠️ +

P 상태 고착 레코드

+
+

+ {tableName} + {data ? ` | ${data.targetSchema}.${data.targetTable} | 총 ${data.totalCount.toLocaleString()}건 고착` : ''} +

+
+
+ {data && data.totalCount > 0 && ( + + )} + +
+
+ + {/* Body */} +
+ {loading && } + {error && ( +
조회 실패: {error}
+ )} + {!loading && !error && data && data.rows.length === 0 && ( +
P 상태 레코드가 없습니다
+ )} + {!loading && !error && data && data.rows.length > 0 && ( +
+ + + + {data.columns.map((col) => ( + + ))} + + + + {data.rows.map((row, idx) => ( + + {data.columns.map((col) => ( + + ))} + + ))} + +
+ {col} +
+ {formatCellValue(row[col])} +
+
+ )} +
+ + {/* Footer */} + {data && data.rows.length > 0 && ( +
+ {data.rows.length}건 표시 (전체 {data.totalCount.toLocaleString()}건) + 리셋 시 batch_flag가 P→N으로 변경되어 다음 동기화에 재처리됩니다 +
+ )} +
+
+ ); +} + +function formatCellValue(value: unknown): string { + if (value === null || value === undefined) return '-'; + if (typeof value === 'object') return JSON.stringify(value); + return String(value); +} diff --git a/frontend/src/components/SyncDataPreviewModal.tsx b/frontend/src/components/SyncDataPreviewModal.tsx new file mode 100644 index 0000000..9e0d9ee --- /dev/null +++ b/frontend/src/components/SyncDataPreviewModal.tsx @@ -0,0 +1,108 @@ +import { useState, useEffect } from 'react'; +import { batchApi, type SyncDataPreviewResponse } from '../api/batchApi'; +import LoadingSpinner from './LoadingSpinner'; + +interface Props { + open: boolean; + tableKey: string; + tableName: string; + onClose: () => void; +} + +export default function SyncDataPreviewModal({ open, tableKey, tableName, onClose }: Props) { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (!open || !tableKey) return; + setLoading(true); + setError(null); + batchApi.getSyncDataPreview(tableKey, 10) + .then(setData) + .catch((e) => setError(e.message)) + .finally(() => setLoading(false)); + }, [open, tableKey]); + + if (!open) return null; + + return ( +
+
e.stopPropagation()} + > + {/* Header */} +
+
+

{tableName}

+

+ {data ? `${data.targetSchema}.${data.targetTable} | 총 ${data.totalCount.toLocaleString()}건` : ''} +

+
+ +
+ + {/* Body */} +
+ {loading && } + {error && ( +
+

조회 실패: {error}

+
+ )} + {!loading && !error && data && data.rows.length === 0 && ( +
데이터가 없습니다
+ )} + {!loading && !error && data && data.rows.length > 0 && ( +
+ + + + {data.columns.map((col) => ( + + ))} + + + + {data.rows.map((row, idx) => ( + + {data.columns.map((col) => ( + + ))} + + ))} + +
+ {col} +
+ {formatCellValue(row[col])} +
+
+ )} +
+ + {/* Footer */} + {data && data.rows.length > 0 && ( +
+ 최근 {data.rows.length}건 표시 (전체 {data.totalCount.toLocaleString()}건) +
+ )} +
+
+ ); +} + +function formatCellValue(value: unknown): string { + if (value === null || value === undefined) return '-'; + if (typeof value === 'object') return JSON.stringify(value); + return String(value); +} diff --git a/frontend/src/pages/SyncStatus.tsx b/frontend/src/pages/SyncStatus.tsx new file mode 100644 index 0000000..4c016c1 --- /dev/null +++ b/frontend/src/pages/SyncStatus.tsx @@ -0,0 +1,393 @@ +import { useState, useCallback, useEffect } from 'react'; +import { + batchApi, + type SyncStatusResponse, + type SyncTableStatus, + type SyncDataPreviewResponse, +} from '../api/batchApi'; +import { usePoller } from '../hooks/usePoller'; +import { useToastContext } from '../contexts/ToastContext'; +import LoadingSpinner from '../components/LoadingSpinner'; +import EmptyState from '../components/EmptyState'; +import ConfirmModal from '../components/ConfirmModal'; +import GuideModal, { HelpButton } from '../components/GuideModal'; + +const POLLING_INTERVAL = 30000; + +const DOMAIN_ICONS: Record = { + ship: '🚢', + company: '🏢', + event: '⚠️', + facility: '🏭', + psc: '🔍', + movements: '📍', + code: '🏷️', + 'risk-compliance': '🛡️', +}; + +const GUIDE_ITEMS = [ + { + title: '도메인 탭', + content: 'Ship, PSC 등 도메인별로 테이블을 그룹핑하여 조회합니다.\nP 고착 테이블이 있는 도메인에는 경고 뱃지가 표시됩니다.', + }, + { + title: '테이블 아코디언', + content: '각 테이블을 펼치면 대기(N)/진행(P)/완료(S) 건수와 상세 데이터를 확인할 수 있습니다.\n⚠️ 표시는 P 상태에 고착된 레코드가 있음을 의미합니다.', + }, + { + title: '동기화 데이터 / P 상태 레코드', + content: '동기화 데이터 탭: 타겟 스키마(std_snp_svc)의 최근 동기화 데이터를 보여줍니다.\nP 상태 레코드 탭: Writer 실패로 P 상태에 멈춘 레코드를 확인하고 리셋할 수 있습니다.', + }, +]; + +export default function SyncStatus() { + const { showToast } = useToastContext(); + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [guideOpen, setGuideOpen] = useState(false); + + // Tab & accordion state + const [activeDomain, setActiveDomain] = useState('ship'); + const [expandedTable, setExpandedTable] = useState('ship-001'); + const [detailTabs, setDetailTabs] = useState>({}); + + // Reset confirm + const [resetTableKey, setResetTableKey] = useState(''); + const [resetConfirmOpen, setResetConfirmOpen] = useState(false); + const [resetting, setResetting] = useState(false); + + const loadData = useCallback(async () => { + try { + const result = await batchApi.getSyncStatus(); + setData(result); + } catch { + if (loading) showToast('동기화 현황 조회 실패', 'error'); + } finally { + setLoading(false); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + usePoller(loadData, POLLING_INTERVAL); + + const toggleAccordion = (tableKey: string) => { + setExpandedTable((prev) => (prev === tableKey ? '' : tableKey)); + }; + + const getDetailTab = (tableKey: string) => detailTabs[tableKey] || 'preview'; + const setDetailTab = (tableKey: string, tab: 'preview' | 'stuck') => { + setDetailTabs((prev) => ({ ...prev, [tableKey]: tab })); + }; + + const handleReset = async () => { + setResetting(true); + try { + const result = await batchApi.resetStuckRecords(resetTableKey); + const allTables = data?.domains.flatMap((d) => d.tables) ?? []; + const table = allTables.find((t) => t.tableKey === resetTableKey); + showToast(`${table?.sourceTable ?? resetTableKey}: ${result.resetCount}건 리셋 완료`, 'success'); + setResetConfirmOpen(false); + loadData(); + } catch { + showToast('리셋 실패', 'error'); + } finally { + setResetting(false); + } + }; + + const activeDomainGroup = data?.domains.find((d) => d.domain === activeDomain); + const resetTable = data?.domains.flatMap((d) => d.tables).find((t) => t.tableKey === resetTableKey); + + if (loading) return ; + if (!data) return ; + + return ( +
+ {/* Header */} +
+

동기화 현황

+ setGuideOpen(true)} /> +
+ + {/* ── Domain Tabs ── */} +
+ {data.domains.map((d) => { + const stuckCount = d.tables.filter((t) => t.stuck).length; + return ( + + ); + })} +
+ + {/* ── Table Accordions ── */} + {activeDomainGroup && ( +
+ {activeDomainGroup.tables.map((table) => ( + toggleAccordion(table.tableKey)} + onDetailTabChange={(tab) => setDetailTab(table.tableKey, tab)} + onReset={() => { setResetTableKey(table.tableKey); setResetConfirmOpen(true); }} + /> + ))} +
+ )} + + {/* Reset Confirm Modal */} + setResetConfirmOpen(false)} + /> + + setGuideOpen(false)} + /> +
+ ); +} + +// ── Sub Components ──────────────────────────────────────────── + +interface TableAccordionProps { + table: SyncTableStatus; + expanded: boolean; + detailTab: 'preview' | 'stuck'; + onToggle: () => void; + onDetailTabChange: (tab: 'preview' | 'stuck') => void; + onReset: () => void; +} + +function TableAccordion({ table, expanded, detailTab, onToggle, onDetailTabChange, onReset }: TableAccordionProps) { + return ( +
+ {/* Accordion header */} + + + {/* Accordion body */} + {expanded && ( +
+ {/* Stats row */} +
+ + + +
+

최근 동기화

+

{formatRelativeTime(table.lastSyncTime)}

+
+
+ + {/* Detail sub-tabs */} +
+
+ + +
+ {detailTab === 'stuck' && table.stuck && ( + + )} +
+ + {/* Tab content */} + {detailTab === 'preview' && ( + + )} + {detailTab === 'stuck' && ( + + )} +
+ )} +
+ ); +} + +interface MiniStatProps { + label: string; + value: number; + color: string; + warn?: boolean; +} + +function MiniStat({ label, value, color, warn }: MiniStatProps) { + return ( +
+

{label}

+

+ {value.toLocaleString()} + {warn && 고착} +

+
+ ); +} + +interface InlineDataTableProps { + tableKey: string; + fetchFn: (tableKey: string, limit: number) => Promise; +} + +function InlineDataTable({ tableKey, fetchFn }: InlineDataTableProps) { + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + setLoading(true); + setError(null); + setData(null); + fetchFn(tableKey, 20) + .then(setData) + .catch((e) => setError(e.message)) + .finally(() => setLoading(false)); + }, [tableKey, fetchFn]); + + if (loading) return
; + if (error) return
조회 실패: {error}
; + if (!data || data.rows.length === 0) { + return
데이터가 없습니다
; + } + + return ( +
+
+ + + + {data.columns.map((col) => ( + + ))} + + + + {data.rows.map((row, idx) => ( + + {data.columns.map((col) => ( + + ))} + + ))} + +
+ {col} +
+ {formatCellValue(row[col])} +
+
+

+ {data.rows.length}건 표시 (전체 {data.totalCount.toLocaleString()}건) · {data.targetSchema}.{data.targetTable} +

+
+ ); +} + +function formatCellValue(value: unknown): string { + if (value === null || value === undefined) return '-'; + if (typeof value === 'object') return JSON.stringify(value); + return String(value); +} + +function formatRelativeTime(dateStr: string | null): string { + if (!dateStr) return '-'; + try { + const date = new Date(dateStr); + if (isNaN(date.getTime())) return '-'; + const now = new Date(); + const diffMs = now.getTime() - date.getTime(); + const diffMin = Math.floor(diffMs / 60000); + if (diffMin < 1) return '방금 전'; + if (diffMin < 60) return `${diffMin}분 전`; + const diffHour = Math.floor(diffMin / 60); + if (diffHour < 24) return `${diffHour}시간 전`; + const diffDay = Math.floor(diffHour / 24); + return `${diffDay}일 전`; + } catch { + return '-'; + } +} diff --git a/src/main/java/com/snp/batch/global/config/BatchTableProperties.java b/src/main/java/com/snp/batch/global/config/BatchTableProperties.java new file mode 100644 index 0000000..aa95ea1 --- /dev/null +++ b/src/main/java/com/snp/batch/global/config/BatchTableProperties.java @@ -0,0 +1,26 @@ +package com.snp.batch.global.config; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +import java.util.LinkedHashMap; +import java.util.Map; + +@Getter +@Setter +@Component +@ConfigurationProperties(prefix = "app.batch") +public class BatchTableProperties { + + private SchemaConfig sourceSchema = new SchemaConfig(); + private SchemaConfig targetSchema = new SchemaConfig(); + + @Getter + @Setter + public static class SchemaConfig { + private String name; + private Map tables = new LinkedHashMap<>(); + } +} diff --git a/src/main/java/com/snp/batch/global/controller/BatchController.java b/src/main/java/com/snp/batch/global/controller/BatchController.java index 5ddd26e..a7eb9b0 100644 --- a/src/main/java/com/snp/batch/global/controller/BatchController.java +++ b/src/main/java/com/snp/batch/global/controller/BatchController.java @@ -3,8 +3,11 @@ package com.snp.batch.global.controller; import com.snp.batch.global.dto.JobExecutionDto; import com.snp.batch.global.dto.ScheduleRequest; import com.snp.batch.global.dto.ScheduleResponse; +import com.snp.batch.global.dto.SyncDataPreviewResponse; +import com.snp.batch.global.dto.SyncStatusResponse; import com.snp.batch.service.BatchService; import com.snp.batch.service.ScheduleService; +import com.snp.batch.service.SyncStatusService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.responses.ApiResponse; @@ -27,6 +30,7 @@ public class BatchController { private final BatchService batchService; private final ScheduleService scheduleService; + private final SyncStatusService syncStatusService; @Operation(summary = "배치 작업 실행", description = "지정된 배치 작업을 즉시 실행합니다. 쿼리 파라미터로 Job Parameters 전달 가능") @ApiResponses(value = { @@ -324,4 +328,84 @@ public class BatchController { com.snp.batch.global.dto.ExecutionStatisticsDto stats = batchService.getJobStatistics(jobName, days); return ResponseEntity.ok(stats); } + + // ── 동기화 현황 API ────────────────────────────────────────── + + @Operation(summary = "동기화 현황 조회", description = "전체 테이블의 batch_flag 기반 동기화 현황을 조회합니다") + @GetMapping("/sync-status") + public ResponseEntity getSyncStatus() { + log.info("Received request to get sync status"); + try { + SyncStatusResponse status = syncStatusService.getSyncStatus(); + return ResponseEntity.ok(status); + } catch (Exception e) { + log.error("Error getting sync status", e); + return ResponseEntity.internalServerError().build(); + } + } + + @Operation(summary = "동기화 데이터 미리보기", description = "특정 테이블의 최근 동기화 성공 데이터를 조회합니다") + @GetMapping("/sync-status/{tableKey}/preview") + public ResponseEntity getSyncDataPreview( + @Parameter(description = "테이블 키 (예: ship-001)", required = true) + @PathVariable String tableKey, + @Parameter(description = "조회 건수", example = "10") + @RequestParam(defaultValue = "10") int limit) { + log.info("Received request to preview sync data for: {}", tableKey); + try { + SyncDataPreviewResponse preview = syncStatusService.getDataPreview(tableKey, limit); + return ResponseEntity.ok(preview); + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest().build(); + } catch (Exception e) { + log.error("Error getting sync data preview for: {}", tableKey, e); + return ResponseEntity.internalServerError().build(); + } + } + + @Operation(summary = "P 상태 고착 레코드 조회", description = "특정 테이블의 batch_flag='P' 고착 레코드를 조회합니다") + @GetMapping("/sync-status/{tableKey}/stuck") + public ResponseEntity getStuckRecords( + @Parameter(description = "테이블 키 (예: ship-001)", required = true) + @PathVariable String tableKey, + @Parameter(description = "조회 건수", example = "50") + @RequestParam(defaultValue = "50") int limit) { + log.info("Received request to get stuck records for: {}", tableKey); + try { + SyncDataPreviewResponse stuck = syncStatusService.getStuckRecords(tableKey, limit); + return ResponseEntity.ok(stuck); + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest().build(); + } catch (Exception e) { + log.error("Error getting stuck records for: {}", tableKey, e); + return ResponseEntity.internalServerError().build(); + } + } + + @Operation(summary = "P 상태 고착 레코드 리셋", description = "특정 테이블의 batch_flag='P' 레코드를 'N'으로 리셋합니다") + @PostMapping("/sync-status/{tableKey}/reset") + public ResponseEntity> resetStuckRecords( + @Parameter(description = "테이블 키 (예: ship-001)", required = true) + @PathVariable String tableKey) { + log.info("Received request to reset stuck records for: {}", tableKey); + try { + int resetCount = syncStatusService.resetStuckRecords(tableKey); + return ResponseEntity.ok(Map.of( + "success", true, + "message", "P→N 리셋 완료", + "resetCount", resetCount + )); + } catch (IllegalArgumentException e) { + return ResponseEntity.badRequest().body(Map.of( + "success", false, + "message", e.getMessage() + )); + } catch (Exception e) { + log.error("Error resetting stuck records for: {}", tableKey, e); + return ResponseEntity.internalServerError().body(Map.of( + "success", false, + "message", "리셋 실패: " + e.getMessage() + )); + } + } } diff --git a/src/main/java/com/snp/batch/global/controller/WebViewController.java b/src/main/java/com/snp/batch/global/controller/WebViewController.java index a30ac55..ac5f27d 100644 --- a/src/main/java/com/snp/batch/global/controller/WebViewController.java +++ b/src/main/java/com/snp/batch/global/controller/WebViewController.java @@ -13,9 +13,9 @@ import org.springframework.web.bind.annotation.GetMapping; public class WebViewController { @GetMapping({"/", "/jobs", "/executions", "/executions/{id:\\d+}", - "/schedules", "/schedule-timeline", + "/schedules", "/schedule-timeline", "/sync-status", "/jobs/**", "/executions/**", - "/schedules/**", "/schedule-timeline/**"}) + "/schedules/**", "/schedule-timeline/**", "/sync-status/**"}) public String forward() { return "forward:/index.html"; } diff --git a/src/main/java/com/snp/batch/global/dto/SyncDataPreviewResponse.java b/src/main/java/com/snp/batch/global/dto/SyncDataPreviewResponse.java new file mode 100644 index 0000000..bfdcc94 --- /dev/null +++ b/src/main/java/com/snp/batch/global/dto/SyncDataPreviewResponse.java @@ -0,0 +1,26 @@ +package com.snp.batch.global.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; +import java.util.Map; + +/** + * 동기화 성공 데이터 미리보기 응답 + */ +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SyncDataPreviewResponse { + + private String tableKey; + private String targetTable; + private String targetSchema; + private List columns; + private List> rows; + private long totalCount; +} diff --git a/src/main/java/com/snp/batch/global/dto/SyncStatusResponse.java b/src/main/java/com/snp/batch/global/dto/SyncStatusResponse.java new file mode 100644 index 0000000..e2618a6 --- /dev/null +++ b/src/main/java/com/snp/batch/global/dto/SyncStatusResponse.java @@ -0,0 +1,59 @@ +package com.snp.batch.global.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * 동기화 현황 전체 응답 + */ +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class SyncStatusResponse { + + private SyncStatusSummary summary; + private List domains; + + @Getter + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class SyncStatusSummary { + private int totalTables; + private long pendingCount; + private long processingCount; + private long completedCount; + private int stuckTables; + } + + @Getter + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class SyncDomainGroup { + private String domain; + private String domainLabel; + private List tables; + } + + @Getter + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class SyncTableStatus { + private String tableKey; + private String sourceTable; + private String targetTable; + private String domain; + private long pendingCount; + private long processingCount; + private long completedCount; + private String lastSyncTime; + private boolean stuck; + } +} diff --git a/src/main/java/com/snp/batch/service/SyncStatusService.java b/src/main/java/com/snp/batch/service/SyncStatusService.java new file mode 100644 index 0000000..bc7010d --- /dev/null +++ b/src/main/java/com/snp/batch/service/SyncStatusService.java @@ -0,0 +1,306 @@ +package com.snp.batch.service; + +import com.snp.batch.global.config.BatchTableProperties; +import com.snp.batch.global.dto.SyncDataPreviewResponse; +import com.snp.batch.global.dto.SyncStatusResponse; +import com.snp.batch.global.dto.SyncStatusResponse.SyncDomainGroup; +import com.snp.batch.global.dto.SyncStatusResponse.SyncStatusSummary; +import com.snp.batch.global.dto.SyncStatusResponse.SyncTableStatus; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Qualifier; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Service; + +import javax.sql.DataSource; +import java.util.*; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.stream.Collectors; + +/** + * 동기화 현황 조회 서비스 + * - batch_flag 기반 테이블별 N/P/S 건수 집계 + * - 타겟 스키마 데이터 미리보기 + * - P 상태 고착 레코드 리셋 + */ +@Slf4j +@Service +public class SyncStatusService { + + private static final Map DOMAIN_LABELS = Map.of( + "ship", "Ship (선박)", + "company", "Company (회사)", + "event", "Event (사건)", + "facility", "Facility (시설)", + "psc", "PSC (검사)", + "movements", "Movements (이동)", + "code", "Code (코드)", + "risk-compliance", "Risk & Compliance" + ); + + private static final List DOMAIN_ORDER = List.of( + "ship", "company", "event", "facility", "psc", + "movements", "code", "risk-compliance" + ); + + private final JdbcTemplate businessJdbc; + private final BatchTableProperties tableProps; + + private String sourceSchema; + private String targetSchema; + private Map sourceTables; + private Map targetTables; + + public SyncStatusService(@Qualifier("businessDataSource") DataSource businessDataSource, + BatchTableProperties tableProps) { + this.businessJdbc = new JdbcTemplate(businessDataSource); + this.tableProps = tableProps; + this.sourceSchema = tableProps.getSourceSchema().getName(); + this.targetSchema = tableProps.getTargetSchema().getName(); + this.sourceTables = tableProps.getSourceSchema().getTables(); + this.targetTables = tableProps.getTargetSchema().getTables(); + } + + /** + * 전체 동기화 현황 조회 + */ + public SyncStatusResponse getSyncStatus() { + // 테이블을 병렬 조회 (HikariCP pool=10 기준 동시 10개) + ExecutorService executor = Executors.newFixedThreadPool( + Math.min(sourceTables.size(), 10)); + + List> futures = sourceTables.entrySet().stream() + .map(entry -> CompletableFuture.supplyAsync(() -> { + String tableKey = entry.getKey(); + String sourceTable = entry.getValue(); + String targetTable = targetTables.getOrDefault(tableKey, ""); + + try { + return queryTableStatus(tableKey, sourceTable, targetTable); + } catch (Exception e) { + log.warn("테이블 상태 조회 실패: {} ({})", tableKey, e.getMessage()); + return SyncTableStatus.builder() + .tableKey(tableKey) + .sourceTable(sourceTable) + .targetTable(targetTable) + .domain(extractDomain(tableKey)) + .pendingCount(0) + .processingCount(0) + .completedCount(0) + .stuck(false) + .build(); + } + }, executor)) + .collect(Collectors.toList()); + + List allStatuses = futures.stream() + .map(CompletableFuture::join) + .collect(Collectors.toList()); + + executor.shutdown(); + + // 도메인별 그룹핑 + Map> grouped = allStatuses.stream() + .collect(Collectors.groupingBy(SyncTableStatus::getDomain)); + + List domains = DOMAIN_ORDER.stream() + .filter(grouped::containsKey) + .map(domain -> SyncDomainGroup.builder() + .domain(domain) + .domainLabel(DOMAIN_LABELS.getOrDefault(domain, domain)) + .tables(grouped.get(domain)) + .build()) + .collect(Collectors.toList()); + + // 요약 + long totalPending = allStatuses.stream().mapToLong(SyncTableStatus::getPendingCount).sum(); + long totalProcessing = allStatuses.stream().mapToLong(SyncTableStatus::getProcessingCount).sum(); + long totalCompleted = allStatuses.stream().mapToLong(SyncTableStatus::getCompletedCount).sum(); + int stuckTables = (int) allStatuses.stream().filter(SyncTableStatus::isStuck).count(); + + SyncStatusSummary summary = SyncStatusSummary.builder() + .totalTables(allStatuses.size()) + .pendingCount(totalPending) + .processingCount(totalProcessing) + .completedCount(totalCompleted) + .stuckTables(stuckTables) + .build(); + + return SyncStatusResponse.builder() + .summary(summary) + .domains(domains) + .build(); + } + + /** + * 특정 테이블의 최근 동기화 데이터 미리보기 + */ + public SyncDataPreviewResponse getDataPreview(String tableKey, int limit) { + String targetTable = targetTables.get(tableKey); + if (targetTable == null) { + throw new IllegalArgumentException("존재하지 않는 테이블 키: " + tableKey); + } + + String countSql = "SELECT COUNT(*) FROM %s.%s".formatted(targetSchema, targetTable); + Long totalCount = businessJdbc.queryForObject(countSql, Long.class); + + String sql = "SELECT * FROM %s.%s ORDER BY mdfcn_dt DESC NULLS LAST LIMIT %d" + .formatted(targetSchema, targetTable, limit); + + List> rows = businessJdbc.queryForList(sql); + + List columns = rows.isEmpty() + ? getTableColumns(targetTable) + : new ArrayList<>(rows.get(0).keySet()); + + return SyncDataPreviewResponse.builder() + .tableKey(tableKey) + .targetTable(targetTable) + .targetSchema(targetSchema) + .columns(columns) + .rows(rows) + .totalCount(totalCount != null ? totalCount : 0) + .build(); + } + + /** + * P 상태 고착 레코드 조회 + */ + public SyncDataPreviewResponse getStuckRecords(String tableKey, int limit) { + String sourceTable = sourceTables.get(tableKey); + if (sourceTable == null) { + throw new IllegalArgumentException("존재하지 않는 테이블 키: " + tableKey); + } + + String countSql = """ + SELECT COUNT(*) + FROM %s.%s a + INNER JOIN %s.batch_job_execution b + ON a.job_execution_id = b.job_execution_id + AND b.status = 'COMPLETED' + WHERE a.batch_flag = 'P' + """.formatted(sourceSchema, sourceTable, sourceSchema); + Long totalCount = businessJdbc.queryForObject(countSql, Long.class); + + String sql = """ + SELECT a.* + FROM %s.%s a + INNER JOIN %s.batch_job_execution b + ON a.job_execution_id = b.job_execution_id + AND b.status = 'COMPLETED' + WHERE a.batch_flag = 'P' + ORDER BY a.mdfcn_dt DESC NULLS LAST + LIMIT %d + """.formatted(sourceSchema, sourceTable, sourceSchema, limit); + + List> rows = businessJdbc.queryForList(sql); + + List columns = rows.isEmpty() + ? getTableColumns(sourceTable) + : new ArrayList<>(rows.get(0).keySet()); + + return SyncDataPreviewResponse.builder() + .tableKey(tableKey) + .targetTable(sourceTable) + .targetSchema(sourceSchema) + .columns(columns) + .rows(rows) + .totalCount(totalCount != null ? totalCount : 0) + .build(); + } + + /** + * P 상태 고착 레코드를 N으로 리셋 + */ + public int resetStuckRecords(String tableKey) { + String sourceTable = sourceTables.get(tableKey); + if (sourceTable == null) { + throw new IllegalArgumentException("존재하지 않는 테이블 키: " + tableKey); + } + + String sql = """ + UPDATE %s.%s + SET batch_flag = 'N' + , mdfcn_dt = CURRENT_TIMESTAMP + , mdfr_id = 'MANUAL_RESET' + WHERE batch_flag = 'P' + """.formatted(sourceSchema, sourceTable); + + int updated = businessJdbc.update(sql); + log.info("P→N 리셋 완료: {} ({}) {}건", tableKey, sourceTable, updated); + return updated; + } + + private SyncTableStatus queryTableStatus(String tableKey, String sourceTable, String targetTable) { + // batch_job_execution.status = 'COMPLETED'인 데이터만 집계 + // (수집/적재가 완전히 완료된 데이터만 동기화 대상) + String sql = """ + SELECT a.batch_flag, COUNT(*) AS cnt + FROM %s.%s a + INNER JOIN %s.batch_job_execution b + ON a.job_execution_id = b.job_execution_id + AND b.status = 'COMPLETED' + WHERE a.batch_flag IN ('N', 'P', 'S') + GROUP BY a.batch_flag + """.formatted(sourceSchema, sourceTable, sourceSchema); + + Map counts = new HashMap<>(); + counts.put("N", 0L); + counts.put("P", 0L); + counts.put("S", 0L); + + businessJdbc.query(sql, rs -> { + counts.put(rs.getString("batch_flag"), rs.getLong("cnt")); + }); + + // 최근 동기화 시간 (COMPLETED된 job의 batch_flag='S'인 가장 최근 mdfcn_dt) + String lastSyncSql = """ + SELECT MAX(a.mdfcn_dt) + FROM %s.%s a + INNER JOIN %s.batch_job_execution b + ON a.job_execution_id = b.job_execution_id + AND b.status = 'COMPLETED' + WHERE a.batch_flag = 'S' + """.formatted(sourceSchema, sourceTable, sourceSchema); + + String lastSyncTime = null; + try { + lastSyncTime = businessJdbc.queryForObject(lastSyncSql, String.class); + } catch (Exception e) { + log.trace("최근 동기화 시간 조회 실패: {}", tableKey); + } + + boolean stuck = counts.get("P") > 0; + + return SyncTableStatus.builder() + .tableKey(tableKey) + .sourceTable(sourceTable) + .targetTable(targetTable) + .domain(extractDomain(tableKey)) + .pendingCount(counts.get("N")) + .processingCount(counts.get("P")) + .completedCount(counts.get("S")) + .lastSyncTime(lastSyncTime) + .stuck(stuck) + .build(); + } + + private String extractDomain(String tableKey) { + int dashIndex = tableKey.indexOf('-'); + if (dashIndex < 0) return tableKey; + String prefix = tableKey.substring(0, dashIndex); + // "risk" prefix → "risk-compliance" domain + if ("risk".equals(prefix)) return "risk-compliance"; + return prefix; + } + + private List getTableColumns(String tableName) { + String sql = """ + SELECT column_name FROM information_schema.columns + WHERE table_schema = ? AND table_name = ? + ORDER BY ordinal_position + """; + return businessJdbc.queryForList(sql, String.class, targetSchema, tableName); + } +}