diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 7ea26ac..fa9d191 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -10,6 +10,8 @@ const Dashboard = lazy(() => import('./pages/Dashboard')); const Jobs = lazy(() => import('./pages/Jobs')); const Executions = lazy(() => import('./pages/Executions')); const ExecutionDetail = lazy(() => import('./pages/ExecutionDetail')); +const Recollects = lazy(() => import('./pages/Recollects')); +const RecollectDetail = lazy(() => import('./pages/RecollectDetail')); const Schedules = lazy(() => import('./pages/Schedules')); const Timeline = lazy(() => import('./pages/Timeline')); @@ -26,6 +28,8 @@ function AppLayout() { } /> } /> } /> + } /> + } /> } /> } /> diff --git a/frontend/src/api/batchApi.ts b/frontend/src/api/batchApi.ts index 44697ed..07a133c 100644 --- a/frontend/src/api/batchApi.ts +++ b/frontend/src/api/batchApi.ts @@ -102,6 +102,30 @@ export interface StepExecutionDto { exitMessage: string | null; duration: number | null; apiCallInfo: ApiCallInfo | null; + apiLogSummary: StepApiLogSummary | null; +} + +export interface ApiLogEntryDto { + logId: number; + requestUri: string; + httpMethod: string; + statusCode: number | null; + responseTimeMs: number | null; + responseCount: number | null; + errorMessage: string | null; + createdAt: string; +} + +export interface StepApiLogSummary { + totalCalls: number; + successCount: number; + errorCount: number; + avgResponseMs: number; + maxResponseMs: number; + minResponseMs: number; + totalResponseMs: number; + totalRecordCount: number; + logs: ApiLogEntryDto[]; } export interface JobExecutionDetailDto { @@ -212,6 +236,73 @@ export interface ExecutionStatisticsDto { avgDurationMs: number; } +// ── Recollection History ───────────────────────────────────── + +export interface RecollectionHistoryDto { + historyId: number; + apiKey: string; + apiKeyName: string | null; + jobName: string; + jobExecutionId: number | null; + rangeFromDate: string; + rangeToDate: string; + executionStatus: string; + executionStartTime: string | null; + executionEndTime: string | null; + durationMs: number | null; + readCount: number | null; + writeCount: number | null; + skipCount: number | null; + apiCallCount: number | null; + executor: string | null; + recollectionReason: string | null; + failureReason: string | null; + hasOverlap: boolean | null; + createdAt: string; +} + +export interface RecollectionSearchResponse { + content: RecollectionHistoryDto[]; + totalElements: number; + number: number; + size: number; + totalPages: number; +} + +export interface RecollectionStatsResponse { + totalCount: number; + completedCount: number; + failedCount: number; + runningCount: number; + overlapCount: number; + recentHistories: RecollectionHistoryDto[]; +} + +export interface ApiStatsDto { + callCount: number; + totalMs: number; + avgMs: number; + maxMs: number; + minMs: number; +} + +export interface RecollectionDetailResponse { + history: RecollectionHistoryDto; + overlappingHistories: RecollectionHistoryDto[]; + apiStats: ApiStatsDto | null; + collectionPeriod: CollectionPeriodDto | null; + stepExecutions: StepExecutionDto[]; +} + +export interface CollectionPeriodDto { + apiKey: string; + apiKeyName: string | null; + jobName: string | null; + orderSeq: number | null; + rangeFromDate: string | null; + rangeToDate: string | null; +} + // ── API Functions ──────────────────────────────────────────── export const batchApi = { @@ -224,9 +315,11 @@ export const batchApi = { getJobsDetail: () => fetchJson(`${BASE}/jobs/detail`), - executeJob: (jobName: string, params?: Record) => - postJson<{ success: boolean; message: string; executionId?: number }>( - `${BASE}/jobs/${jobName}/execute`, params), + executeJob: (jobName: string, params?: Record) => { + const qs = params ? '?' + new URLSearchParams(params).toString() : ''; + return postJson<{ success: boolean; message: string; executionId?: number }>( + `${BASE}/jobs/${jobName}/execute${qs}`); + }, getJobExecutions: (jobName: string) => fetchJson(`${BASE}/jobs/${jobName}/executions`), @@ -305,4 +398,48 @@ export const batchApi = { getPeriodExecutions: (jobName: string, view: string, periodKey: string) => fetchJson( `${BASE}/timeline/period-executions?jobName=${jobName}&view=${view}&periodKey=${periodKey}`), + + // Recollection + searchRecollections: (params: { + apiKey?: string; + jobName?: string; + status?: string; + fromDate?: string; + toDate?: string; + page?: number; + size?: number; + }) => { + const qs = new URLSearchParams(); + if (params.apiKey) qs.set('apiKey', params.apiKey); + if (params.jobName) qs.set('jobName', params.jobName); + if (params.status) qs.set('status', params.status); + if (params.fromDate) qs.set('fromDate', params.fromDate); + if (params.toDate) qs.set('toDate', params.toDate); + qs.set('page', String(params.page ?? 0)); + qs.set('size', String(params.size ?? 20)); + return fetchJson(`${BASE}/recollection-histories?${qs.toString()}`); + }, + + getRecollectionDetail: (historyId: number) => + fetchJson(`${BASE}/recollection-histories/${historyId}`), + + getRecollectionStats: () => + fetchJson(`${BASE}/recollection-histories/stats`), + + getCollectionPeriods: () => + fetchJson(`${BASE}/collection-periods`), + + resetCollectionPeriod: (apiKey: string) => + postJson<{ success: boolean; message: string }>(`${BASE}/collection-periods/${apiKey}/reset`), + + updateCollectionPeriod: (apiKey: string, body: { rangeFromDate: string; rangeToDate: string }) => { + return fetch(`${BASE}/collection-periods/${apiKey}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }).then(async (res) => { + if (!res.ok) throw new Error(`API Error: ${res.status} ${res.statusText}`); + return res.json() as Promise<{ success: boolean; message: string }>; + }); + }, }; diff --git a/frontend/src/components/Navbar.tsx b/frontend/src/components/Navbar.tsx index 8a81d90..ea28634 100644 --- a/frontend/src/components/Navbar.tsx +++ b/frontend/src/components/Navbar.tsx @@ -5,6 +5,7 @@ const navItems = [ { path: '/', label: '대시보드', icon: '📊' }, { path: '/jobs', label: '작업', icon: '⚙️' }, { path: '/executions', label: '실행 이력', icon: '📋' }, + { path: '/recollects', label: '재수집 이력', icon: '🔄' }, { path: '/schedules', label: '스케줄', icon: '🕐' }, { path: '/schedule-timeline', label: '타임라인', icon: '📅' }, ]; diff --git a/frontend/src/pages/RecollectDetail.tsx b/frontend/src/pages/RecollectDetail.tsx new file mode 100644 index 0000000..ebacec1 --- /dev/null +++ b/frontend/src/pages/RecollectDetail.tsx @@ -0,0 +1,495 @@ +import { useState, useCallback } from 'react'; +import { useParams, useNavigate } from 'react-router-dom'; +import { + batchApi, + type RecollectionDetailResponse, + type StepExecutionDto, +} from '../api/batchApi'; +import { formatDateTime, formatDuration, calculateDuration } from '../utils/formatters'; +import { usePoller } from '../hooks/usePoller'; +import StatusBadge from '../components/StatusBadge'; +import EmptyState from '../components/EmptyState'; +import LoadingSpinner from '../components/LoadingSpinner'; + +const POLLING_INTERVAL_MS = 10_000; + +interface StatCardProps { + label: string; + value: number; + gradient: string; + icon: string; +} + +function StatCard({ label, value, gradient, icon }: StatCardProps) { + return ( +
+
+
+

{label}

+

+ {value.toLocaleString()} +

+
+ {icon} +
+
+ ); +} + +function StepCard({ step }: { step: StepExecutionDto }) { + const [logsOpen, setLogsOpen] = useState(false); + + const stats = [ + { label: '읽기', value: step.readCount }, + { label: '쓰기', value: step.writeCount }, + { label: '커밋', value: step.commitCount }, + { label: '롤백', value: step.rollbackCount }, + { label: '읽기 건너뜀', value: step.readSkipCount }, + { label: '처리 건너뜀', value: step.processSkipCount }, + { label: '쓰기 건너뜀', value: step.writeSkipCount }, + { label: '필터', value: step.filterCount }, + ]; + + const summary = step.apiLogSummary; + + return ( +
+
+
+

+ {step.stepName} +

+ +
+ + {step.duration != null + ? formatDuration(step.duration) + : calculateDuration(step.startTime, step.endTime)} + +
+ +
+
+ 시작: {formatDateTime(step.startTime)} +
+
+ 종료: {formatDateTime(step.endTime)} +
+
+ +
+ {stats.map(({ label, value }) => ( +
+

+ {value.toLocaleString()} +

+

{label}

+
+ ))} +
+ + {/* API 호출 로그 요약 (batch_api_log 기반) */} + {summary && ( +
+

API 호출 정보

+
+
+

{summary.totalCalls.toLocaleString()}

+

총 호출

+
+
+

{summary.successCount.toLocaleString()}

+

성공

+
+
+

0 ? 'text-red-500' : 'text-wing-text'}`}> + {summary.errorCount.toLocaleString()} +

+

에러

+
+
+

{Math.round(summary.avgResponseMs).toLocaleString()}

+

평균(ms)

+
+
+

{summary.maxResponseMs.toLocaleString()}

+

최대(ms)

+
+
+

{summary.minResponseMs.toLocaleString()}

+

최소(ms)

+
+
+ + {/* 펼침/접기 개별 로그 */} + {summary.logs.length > 0 && ( +
+ + + {logsOpen && ( +
+ + + + + + + + + + + + + + + {summary.logs.map((log, idx) => { + const isError = (log.statusCode != null && log.statusCode >= 400) || log.errorMessage; + return ( + + + + + + + + + + + ); + })} + +
#URIMethod상태응답(ms)건수시간에러
{idx + 1} + {log.requestUri} + {log.httpMethod} + + {log.statusCode ?? '-'} + + + {log.responseTimeMs?.toLocaleString() ?? '-'} + + {log.responseCount?.toLocaleString() ?? '-'} + + {formatDateTime(log.createdAt)} + + {log.errorMessage || '-'} +
+
+ )} +
+ )} +
+ )} + + {step.exitMessage && ( +
+

Exit Message

+

+ {step.exitMessage} +

+
+ )} +
+ ); +} + +export default function RecollectDetail() { + const { id: paramId } = useParams<{ id: string }>(); + const navigate = useNavigate(); + + const historyId = paramId ? Number(paramId) : NaN; + + const [data, setData] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const isRunning = data + ? data.history.executionStatus === 'STARTED' + : false; + + const loadDetail = useCallback(async () => { + if (!historyId || isNaN(historyId)) { + setError('유효하지 않은 이력 ID입니다.'); + setLoading(false); + return; + } + try { + const result = await batchApi.getRecollectionDetail(historyId); + setData(result); + setError(null); + } catch (err) { + setError( + err instanceof Error + ? err.message + : '재수집 상세 정보를 불러오지 못했습니다.', + ); + } finally { + setLoading(false); + } + }, [historyId]); + + usePoller(loadDetail, isRunning ? POLLING_INTERVAL_MS : 30_000, [historyId]); + + if (loading) return ; + + if (error || !data) { + return ( +
+ + +
+ ); + } + + const { history, overlappingHistories, apiStats, stepExecutions } = data; + + return ( +
+ {/* 상단 내비게이션 */} + + + {/* 기본 정보 카드 */} +
+
+
+

+ 재수집 #{history.historyId} +

+

+ {history.apiKeyName || history.apiKey} · {history.jobName} +

+
+
+ + {history.hasOverlap && ( + + 기간 중복 + + )} +
+
+ +
+ + + + + + {history.jobExecutionId && ( + + )} +
+
+ + {/* 수집 기간 정보 */} +
+

+ 재수집 기간 +

+
+ + +
+
+ + {/* 처리 통계 카드 */} +
+ + + + +
+ + {/* API 응답시간 통계 */} + {apiStats && ( +
+

+ API 응답시간 통계 +

+
+
+

+ {apiStats.callCount.toLocaleString()} +

+

총 호출수

+
+
+

+ {apiStats.totalMs.toLocaleString()} +

+

총 응답시간(ms)

+
+
+

+ {Math.round(apiStats.avgMs).toLocaleString()} +

+

평균(ms)

+
+
+

+ {apiStats.maxMs.toLocaleString()} +

+

최대(ms)

+
+
+

+ {apiStats.minMs.toLocaleString()} +

+

최소(ms)

+
+
+
+ )} + + {/* 실패 사유 */} + {history.executionStatus === 'FAILED' && history.failureReason && ( +
+

+ 실패 사유 +

+
+                        {history.failureReason}
+                    
+
+ )} + + {/* 기간 중복 이력 */} + {overlappingHistories.length > 0 && ( +
+

+ 기간 중복 이력 + + ({overlappingHistories.length}건) + +

+
+ + + + + + + + + + + + + {overlappingHistories.map((oh) => ( + navigate(`/recollects/${oh.historyId}`)} + > + + + + + + + + ))} + +
이력 ID작업명수집 시작일수집 종료일상태실행자
+ #{oh.historyId} + + {oh.apiKeyName || oh.apiKey} + + {formatDateTime(oh.rangeFromDate)} + + {formatDateTime(oh.rangeToDate)} + + + + {oh.executor || '-'} +
+
+
+ )} + + {/* Step 실행 정보 */} +
+

+ Step 실행 정보 + + ({stepExecutions.length}개) + +

+ {stepExecutions.length === 0 ? ( + + ) : ( +
+ {stepExecutions.map((step) => ( + + ))} +
+ )} +
+
+ ); +} + +function InfoItem({ label, value }: { label: string; value: string }) { + return ( +
+
+ {label} +
+
{value || '-'}
+
+ ); +} diff --git a/frontend/src/pages/Recollects.tsx b/frontend/src/pages/Recollects.tsx new file mode 100644 index 0000000..6b2b898 --- /dev/null +++ b/frontend/src/pages/Recollects.tsx @@ -0,0 +1,809 @@ +import { useState, useMemo, useCallback } from 'react'; +import { useNavigate } from 'react-router-dom'; +import { + batchApi, + type RecollectionHistoryDto, + type RecollectionSearchResponse, + type CollectionPeriodDto, +} from '../api/batchApi'; +import { formatDateTime, formatDuration } from '../utils/formatters'; +import { usePoller } from '../hooks/usePoller'; +import { useToastContext } from '../contexts/ToastContext'; +import StatusBadge from '../components/StatusBadge'; +import InfoModal from '../components/InfoModal'; +import EmptyState from '../components/EmptyState'; +import LoadingSpinner from '../components/LoadingSpinner'; + +type StatusFilter = 'ALL' | 'COMPLETED' | 'FAILED' | 'STARTED'; + +const STATUS_FILTERS: { value: StatusFilter; label: string }[] = [ + { value: 'ALL', label: '전체' }, + { value: 'COMPLETED', label: '완료' }, + { value: 'FAILED', label: '실패' }, + { value: 'STARTED', label: '실행중' }, +]; + +const POLLING_INTERVAL_MS = 10_000; +const PAGE_SIZE = 20; + +/** datetime 문자열에서 date input용 값 추출 (YYYY-MM-DD) */ +function toDateInput(dt: string | null): string { + if (!dt) return ''; + return dt.substring(0, 10); +} + +/** datetime 문자열에서 time input용 값 추출 (HH:mm) */ +function toTimeInput(dt: string | null): string { + if (!dt) return '00:00'; + const t = dt.substring(11, 16); + return t || '00:00'; +} + +/** date + time을 ISO datetime 문자열로 결합 */ +function toIsoDateTime(date: string, time: string): string { + return `${date}T${time || '00:00'}:00`; +} + +interface PeriodEdit { + fromDate: string; + fromTime: string; + toDate: string; + toTime: string; +} + +/** 기간 프리셋 정의 (시간 단위) */ +const DURATION_PRESETS = [ + { label: '6시간', hours: 6 }, + { label: '12시간', hours: 12 }, + { label: '하루', hours: 24 }, + { label: '일주일', hours: 168 }, +] as const; + +/** 시작 날짜+시간에 시간(hours)을 더해 종료 날짜+시간을 반환 */ +function addHoursToDateTime( + date: string, + time: string, + hours: number, +): { toDate: string; toTime: string } { + if (!date) return { toDate: '', toTime: '00:00' }; + const dt = new Date(`${date}T${time || '00:00'}:00`); + dt.setTime(dt.getTime() + hours * 60 * 60 * 1000); + const y = dt.getFullYear(); + const m = String(dt.getMonth() + 1).padStart(2, '0'); + const d = String(dt.getDate()).padStart(2, '0'); + const hh = String(dt.getHours()).padStart(2, '0'); + const mm = String(dt.getMinutes()).padStart(2, '0'); + return { toDate: `${y}-${m}-${d}`, toTime: `${hh}:${mm}` }; +} + +export default function Recollects() { + const navigate = useNavigate(); + const { showToast } = useToastContext(); + const [periods, setPeriods] = useState([]); + const [histories, setHistories] = useState([]); + const [selectedApiKey, setSelectedApiKey] = useState(''); + const [apiDropdownOpen, setApiDropdownOpen] = useState(false); + const [statusFilter, setStatusFilter] = useState('ALL'); + const [loading, setLoading] = useState(true); + + // 날짜 범위 필터 + 페이지네이션 + const [startDate, setStartDate] = useState(''); + const [endDate, setEndDate] = useState(''); + const [page, setPage] = useState(0); + const [totalPages, setTotalPages] = useState(0); + const [totalCount, setTotalCount] = useState(0); + const [useSearch, setUseSearch] = useState(false); + + // 실패 로그 모달 + const [failLogTarget, setFailLogTarget] = useState(null); + + // 수집 기간 관리 패널 + const [periodPanelOpen, setPeriodPanelOpen] = useState(false); + const [selectedPeriodKey, setSelectedPeriodKey] = useState(''); + const [periodDropdownOpen, setPeriodDropdownOpen] = useState(false); + const [periodEdits, setPeriodEdits] = useState>({}); + const [savingApiKey, setSavingApiKey] = useState(null); + const [executingApiKey, setExecutingApiKey] = useState(null); + const [manualToDate, setManualToDate] = useState>({}); + const [selectedDuration, setSelectedDuration] = useState>({}); + + const getPeriodEdit = (p: CollectionPeriodDto): PeriodEdit => { + if (periodEdits[p.apiKey]) return periodEdits[p.apiKey]; + return { + fromDate: toDateInput(p.rangeFromDate), + fromTime: toTimeInput(p.rangeFromDate), + toDate: toDateInput(p.rangeToDate), + toTime: toTimeInput(p.rangeToDate), + }; + }; + + const updatePeriodEdit = (apiKey: string, field: keyof PeriodEdit, value: string) => { + const current = periodEdits[apiKey] || getPeriodEdit(periods.find((p) => p.apiKey === apiKey)!); + setPeriodEdits((prev) => ({ ...prev, [apiKey]: { ...current, [field]: value } })); + }; + + const applyDurationPreset = (apiKey: string, hours: number) => { + const p = periods.find((pp) => pp.apiKey === apiKey); + if (!p) return; + const edit = periodEdits[apiKey] || getPeriodEdit(p); + if (!edit.fromDate) { + showToast('재수집 시작일시를 먼저 선택해 주세요.', 'error'); + return; + } + const { toDate, toTime } = addHoursToDateTime(edit.fromDate, edit.fromTime, hours); + setSelectedDuration((prev) => ({ ...prev, [apiKey]: hours })); + setPeriodEdits((prev) => ({ + ...prev, + [apiKey]: { ...edit, toDate, toTime }, + })); + }; + + const handleFromDateChange = (apiKey: string, field: 'fromDate' | 'fromTime', value: string) => { + const p = periods.find((pp) => pp.apiKey === apiKey); + if (!p) return; + const edit = periodEdits[apiKey] || getPeriodEdit(p); + const updated = { ...edit, [field]: value }; + // 기간 프리셋이 선택된 상태면 종료일시도 자동 갱신 + const dur = selectedDuration[apiKey]; + if (dur != null && !manualToDate[apiKey]) { + const { toDate, toTime } = addHoursToDateTime(updated.fromDate, updated.fromTime, dur); + updated.toDate = toDate; + updated.toTime = toTime; + } + setPeriodEdits((prev) => ({ ...prev, [apiKey]: updated })); + }; + + const handleResetPeriod = async (p: CollectionPeriodDto) => { + setSavingApiKey(p.apiKey); + try { + await batchApi.resetCollectionPeriod(p.apiKey); + showToast(`${p.apiKeyName || p.apiKey} 수집 기간이 초기화되었습니다.`, 'success'); + setPeriodEdits((prev) => { const next = { ...prev }; delete next[p.apiKey]; return next; }); + setSelectedDuration((prev) => ({ ...prev, [p.apiKey]: null })); + setManualToDate((prev) => ({ ...prev, [p.apiKey]: false })); + await loadPeriods(); + } catch (err) { + showToast(err instanceof Error ? err.message : '수집 기간 초기화에 실패했습니다.', 'error'); + } finally { + setSavingApiKey(null); + } + }; + + const handleSavePeriod = async (p: CollectionPeriodDto) => { + const edit = getPeriodEdit(p); + if (!edit.fromDate || !edit.toDate) { + showToast('시작일과 종료일을 모두 입력해 주세요.', 'error'); + return; + } + const from = toIsoDateTime(edit.fromDate, edit.fromTime); + const to = toIsoDateTime(edit.toDate, edit.toTime); + const now = new Date().toISOString().substring(0, 19); + if (from >= now) { + showToast('재수집 시작일시는 현재 시간보다 이전이어야 합니다.', 'error'); + return; + } + if (to >= now) { + showToast('재수집 종료일시는 현재 시간보다 이전이어야 합니다.', 'error'); + return; + } + if (from >= to) { + showToast('재수집 시작일시는 종료일시보다 이전이어야 합니다.', 'error'); + return; + } + setSavingApiKey(p.apiKey); + try { + await batchApi.updateCollectionPeriod(p.apiKey, { rangeFromDate: from, rangeToDate: to }); + showToast(`${p.apiKeyName || p.apiKey} 수집 기간이 저장되었습니다.`, 'success'); + setPeriodEdits((prev) => { const next = { ...prev }; delete next[p.apiKey]; return next; }); + await loadPeriods(); + } catch (err) { + showToast(err instanceof Error ? err.message : '수집 기간 저장에 실패했습니다.', 'error'); + } finally { + setSavingApiKey(null); + } + }; + + const handleExecuteRecollect = async (p: CollectionPeriodDto) => { + if (!p.jobName) { + showToast('연결된 Job이 없습니다.', 'error'); + return; + } + setExecutingApiKey(p.apiKey); + try { + const result = await batchApi.executeJob(p.jobName, { + executionMode: 'RECOLLECT', + apiKey: p.apiKey, + executor: 'MANUAL', + reason: '수집 기간 관리 화면에서 수동 실행', + }); + showToast(result.message || `${p.apiKeyName || p.apiKey} 재수집이 시작되었습니다.`, 'success'); + setLoading(true); + } catch (err) { + showToast(err instanceof Error ? err.message : '재수집 실행에 실패했습니다.', 'error'); + } finally { + setExecutingApiKey(null); + } + }; + + const loadPeriods = useCallback(async () => { + try { + const data = await batchApi.getCollectionPeriods(); + setPeriods(data); + } catch { + /* 수집기간 로드 실패 무시 */ + } + }, []); + + const loadHistories = useCallback(async () => { + try { + const params: { + apiKey?: string; + status?: string; + fromDate?: string; + toDate?: string; + page?: number; + size?: number; + } = { + page: useSearch ? page : 0, + size: PAGE_SIZE, + }; + if (selectedApiKey) params.apiKey = selectedApiKey; + if (statusFilter !== 'ALL') params.status = statusFilter; + if (useSearch && startDate) params.fromDate = `${startDate}T00:00:00`; + if (useSearch && endDate) params.toDate = `${endDate}T23:59:59`; + + const data: RecollectionSearchResponse = await batchApi.searchRecollections(params); + setHistories(data.content); + setTotalPages(data.totalPages); + setTotalCount(data.totalElements); + if (!useSearch) setPage(data.number); + } catch { + setHistories([]); + setTotalPages(0); + setTotalCount(0); + } finally { + setLoading(false); + } + }, [selectedApiKey, statusFilter, startDate, endDate, useSearch, page]); + + usePoller(loadPeriods, 60_000, []); + usePoller(loadHistories, POLLING_INTERVAL_MS, [selectedApiKey, statusFilter, useSearch, page]); + + const filteredHistories = useMemo(() => { + if (useSearch) return histories; + if (statusFilter === 'ALL') return histories; + return histories.filter((h) => h.executionStatus === statusFilter); + }, [histories, statusFilter, useSearch]); + + const handleSearch = async () => { + setUseSearch(true); + setPage(0); + setLoading(true); + }; + + const handleResetSearch = () => { + setUseSearch(false); + setStartDate(''); + setEndDate(''); + setPage(0); + setTotalPages(0); + setTotalCount(0); + setLoading(true); + }; + + const handlePageChange = (newPage: number) => { + if (newPage < 0 || newPage >= totalPages) return; + setPage(newPage); + setLoading(true); + }; + + const getApiLabel = (apiKey: string) => { + const p = periods.find((p) => p.apiKey === apiKey); + return p?.apiKeyName || apiKey; + }; + + return ( +
+ {/* 헤더 */} +
+

재수집 이력

+

+ 배치 재수집 실행 이력을 조회하고 관리합니다. +

+
+ + {/* 수집 기간 관리 패널 */} +
+ + + {periodPanelOpen && ( +
+ {periods.length === 0 ? ( +
+ 등록된 수집 기간이 없습니다. +
+ ) : ( + <> + {/* 작업 선택 드롭다운 */} +
+ +
+ + {periodDropdownOpen && ( + <> +
setPeriodDropdownOpen(false)} /> +
+ {periods.map((p) => ( + + ))} +
+ + )} +
+
+ + {/* 선택된 작업의 기간 편집 */} + {selectedPeriodKey && (() => { + const p = periods.find((pp) => pp.apiKey === selectedPeriodKey); + if (!p) return null; + const edit = getPeriodEdit(p); + const hasChange = !!periodEdits[p.apiKey]; + const isSaving = savingApiKey === p.apiKey; + const isExecuting = executingApiKey === p.apiKey; + const isManual = !!manualToDate[p.apiKey]; + const activeDur = selectedDuration[p.apiKey] ?? null; + return ( +
+
+ 작업명: + {p.jobName || '-'} +
+ + {/* Line 1: 재수집 시작일시 */} +
+ +
+ handleFromDateChange(p.apiKey, 'fromDate', e.target.value)} + className="flex-[3] min-w-0 rounded border border-wing-border bg-wing-surface px-2 py-1.5 text-sm shadow-sm focus:border-wing-accent focus:ring-1 focus:ring-wing-accent" + /> + handleFromDateChange(p.apiKey, 'fromTime', e.target.value)} + className="flex-[2] min-w-0 rounded border border-wing-border bg-wing-surface px-2 py-1.5 text-sm shadow-sm focus:border-wing-accent focus:ring-1 focus:ring-wing-accent" + /> +
+
+ + {/* Line 2: 기간 선택 버튼 + 직접입력 토글 */} +
+ {DURATION_PRESETS.map(({ label, hours }) => ( + + ))} +
+ 직접입력 + +
+
+ + {/* Line 3: 재수집 종료일시 */} +
+ +
+ updatePeriodEdit(p.apiKey, 'toDate', e.target.value)} + className={`flex-[3] min-w-0 rounded border border-wing-border px-2 py-1.5 text-sm shadow-sm focus:border-wing-accent focus:ring-1 focus:ring-wing-accent ${ + isManual ? 'bg-wing-surface' : 'bg-wing-card text-wing-muted cursor-not-allowed' + }`} + /> + updatePeriodEdit(p.apiKey, 'toTime', e.target.value)} + className={`flex-[2] min-w-0 rounded border border-wing-border px-2 py-1.5 text-sm shadow-sm focus:border-wing-accent focus:ring-1 focus:ring-wing-accent ${ + isManual ? 'bg-wing-surface' : 'bg-wing-card text-wing-muted cursor-not-allowed' + }`} + /> +
+
+ +
+ + + +
+
+ ); + })()} + + )} +
+ )} +
+ + {/* 필터 영역 */} +
+
+ {/* API 선택 */} +
+
+ +
+ + {apiDropdownOpen && ( + <> +
setApiDropdownOpen(false)} /> +
+ + {periods.map((p) => ( + + ))} +
+ + )} +
+ {selectedApiKey && ( + + )} +
+ {selectedApiKey && ( +
+ + {getApiLabel(selectedApiKey)} + + +
+ )} +
+ + {/* 상태 필터 버튼 그룹 */} +
+ {STATUS_FILTERS.map(({ value, label }) => ( + + ))} +
+
+ + {/* 날짜 범위 필터 */} +
+ +
+ setStartDate(e.target.value)} + className="block rounded-lg border border-wing-border bg-wing-surface px-3 py-2 text-sm shadow-sm focus:border-wing-accent focus:ring-1 focus:ring-wing-accent" + /> + ~ + setEndDate(e.target.value)} + className="block rounded-lg border border-wing-border bg-wing-surface px-3 py-2 text-sm shadow-sm focus:border-wing-accent focus:ring-1 focus:ring-wing-accent" + /> +
+
+ + {useSearch && ( + + )} +
+
+
+ + {/* 재수집 이력 테이블 */} +
+ {loading ? ( + + ) : filteredHistories.length === 0 ? ( + + ) : ( +
+ + + + + + + + + + + + + + {filteredHistories.map((hist) => ( + + + + + + + + + + ))} + +
재수집 ID작업명상태재수집 시작일시재수집 종료일시소요시간액션
+ #{hist.historyId} + +
+ {hist.apiKeyName || hist.apiKey} +
+
+ {hist.executionStatus === 'FAILED' ? ( + + ) : ( + + )} + {hist.hasOverlap && ( + + ! + )} + +
{formatDateTime(hist.rangeFromDate)}
+
+
{formatDateTime(hist.rangeToDate)}
+
+ {formatDuration(hist.durationMs)} + + +
+
+ )} + + {/* 결과 건수 + 페이지네이션 */} + {!loading && filteredHistories.length > 0 && ( +
+
+ 총 {totalCount}건 +
+ {totalPages > 1 && ( +
+ + + {page + 1} / {totalPages} + + +
+ )} +
+ )} +
+ + {/* 실패 로그 뷰어 모달 */} + setFailLogTarget(null)} + > + {failLogTarget && ( +
+
+

+ 실행 상태 +

+

+ {failLogTarget.executionStatus} +

+
+
+

+ 실패 사유 +

+
+                                {failLogTarget.failureReason || '실패 사유 없음'}
+                            
+
+
+ )} +
+
+ ); +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..55ae0c7 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "snp-batch-validation", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/pom.xml b/pom.xml index 14e0c6c..dce40cd 100644 --- a/pom.xml +++ b/pom.xml @@ -143,6 +143,12 @@ spring-batch-test test + + + com.google.code.findbugs + jsr305 + 3.0.2 + diff --git a/src/main/java/com/snp/batch/common/batch/listener/RecollectionJobExecutionListener.java b/src/main/java/com/snp/batch/common/batch/listener/RecollectionJobExecutionListener.java new file mode 100644 index 0000000..b659cbe --- /dev/null +++ b/src/main/java/com/snp/batch/common/batch/listener/RecollectionJobExecutionListener.java @@ -0,0 +1,140 @@ +package com.snp.batch.common.batch.listener; + +import com.snp.batch.service.RecollectionHistoryService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.JobExecution; +import org.springframework.batch.core.JobExecutionListener; +import org.springframework.batch.core.StepExecution; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; + +@Slf4j +@Component +@RequiredArgsConstructor +public class RecollectionJobExecutionListener implements JobExecutionListener { + + private static final String ORIGINAL_LAST_SUCCESS_DATE_KEY = "originalLastSuccessDate"; + + private final RecollectionHistoryService recollectionHistoryService; + + @Override + public void beforeJob(JobExecution jobExecution) { + String executionMode = jobExecution.getJobParameters() + .getString("executionMode", "NORMAL"); + + if (!"RECOLLECT".equals(executionMode)) { + return; + } + + Long jobExecutionId = jobExecution.getId(); + String jobName = jobExecution.getJobInstance().getJobName(); + String apiKey = jobExecution.getJobParameters().getString("apiKey"); + String executor = jobExecution.getJobParameters().getString("executor", "SYSTEM"); + String reason = jobExecution.getJobParameters().getString("reason"); + + try { + // 1. 현재 last_success_date를 JobExecutionContext에 저장 (afterJob에서 복원용) + if (apiKey != null) { + LocalDateTime originalDate = recollectionHistoryService.getLastSuccessDate(apiKey); + if (originalDate != null) { + jobExecution.getExecutionContext() + .putString(ORIGINAL_LAST_SUCCESS_DATE_KEY, originalDate.toString()); + log.info("[RecollectionListener] 원본 last_success_date 저장: apiKey={}, date={}", + apiKey, originalDate); + } + } + + // 2. 재수집 이력 기록 + recollectionHistoryService.recordStart( + jobName, jobExecutionId, apiKey, executor, reason); + } catch (Exception e) { + log.error("[RecollectionListener] beforeJob 처리 실패: jobExecutionId={}", jobExecutionId, e); + } + } + + @Override + public void afterJob(JobExecution jobExecution) { + String executionMode = jobExecution.getJobParameters() + .getString("executionMode", "NORMAL"); + + if (!"RECOLLECT".equals(executionMode)) { + return; + } + + Long jobExecutionId = jobExecution.getId(); + String status = jobExecution.getStatus().name(); + String apiKey = jobExecution.getJobParameters().getString("apiKey"); + + // Step별 통계 집계 + long totalRead = 0; + long totalWrite = 0; + long totalSkip = 0; + int totalApiCalls = 0; + + for (StepExecution step : jobExecution.getStepExecutions()) { + totalRead += step.getReadCount(); + totalWrite += step.getWriteCount(); + totalSkip += step.getReadSkipCount() + + step.getProcessSkipCount() + + step.getWriteSkipCount(); + + if (step.getExecutionContext().containsKey("totalApiCalls")) { + totalApiCalls += step.getExecutionContext().getInt("totalApiCalls", 0); + } + } + + // 실패 사유 추출 + String failureReason = null; + if ("FAILED".equals(status)) { + failureReason = jobExecution.getExitStatus().getExitDescription(); + if (failureReason == null || failureReason.isEmpty()) { + failureReason = jobExecution.getStepExecutions().stream() + .filter(s -> "FAILED".equals(s.getStatus().name())) + .map(s -> s.getExitStatus().getExitDescription()) + .filter(desc -> desc != null && !desc.isEmpty()) + .findFirst() + .orElse("Unknown error"); + } + if (failureReason != null && failureReason.length() > 2000) { + failureReason = failureReason.substring(0, 2000) + "..."; + } + } + + // 1. 재수집 이력 완료 기록 + try { + recollectionHistoryService.recordCompletion( + jobExecutionId, status, + totalRead, totalWrite, totalSkip, + totalApiCalls, null, + failureReason); + } catch (Exception e) { + log.error("[RecollectionListener] 재수집 이력 완료 기록 실패: jobExecutionId={}", jobExecutionId, e); + } + + // 2. last_success_date 복원 (Tasklet이 NOW()로 업데이트한 것을 되돌림) + // 재수집은 과거 데이터 재처리이므로 last_success_date를 변경하면 안 됨 + // recordCompletion 실패와 무관하게 반드시 실행되어야 함 + try { + if (apiKey != null) { + String originalDateStr = jobExecution.getExecutionContext() + .getString(ORIGINAL_LAST_SUCCESS_DATE_KEY, null); + log.info("[RecollectionListener] last_success_date 복원 시도: apiKey={}, originalDateStr={}", + apiKey, originalDateStr); + if (originalDateStr != null) { + LocalDateTime originalDate = LocalDateTime.parse(originalDateStr); + recollectionHistoryService.restoreLastSuccessDate(apiKey, originalDate); + log.info("[RecollectionListener] last_success_date 복원 완료: apiKey={}, date={}", + apiKey, originalDate); + } else { + log.warn("[RecollectionListener] originalLastSuccessDate가 ExecutionContext에 없음: apiKey={}", + apiKey); + } + } + } catch (Exception e) { + log.error("[RecollectionListener] last_success_date 복원 실패: apiKey={}, jobExecutionId={}", + apiKey, jobExecutionId, e); + } + } +} 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 596ab4a..e112227 100644 --- a/src/main/java/com/snp/batch/global/controller/BatchController.java +++ b/src/main/java/com/snp/batch/global/controller/BatchController.java @@ -1,7 +1,10 @@ package com.snp.batch.global.controller; import com.snp.batch.global.dto.*; +import com.snp.batch.global.model.BatchCollectionPeriod; +import com.snp.batch.global.model.BatchRecollectionHistory; import com.snp.batch.service.BatchService; +import com.snp.batch.service.RecollectionHistoryService; import com.snp.batch.service.ScheduleService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; @@ -17,6 +20,9 @@ import org.springdoc.core.annotations.ParameterObject; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; + import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.HashMap; @@ -32,6 +38,7 @@ public class BatchController { private final BatchService batchService; private final ScheduleService scheduleService; + private final RecollectionHistoryService recollectionHistoryService; @Operation(summary = "배치 작업 실행", description = "지정된 배치 작업을 즉시 실행합니다. 쿼리 파라미터로 Job Parameters 전달 가능") @ApiResponses(value = { @@ -453,4 +460,120 @@ public class BatchController { ExecutionStatisticsDto stats = batchService.getJobStatistics(jobName, days); return ResponseEntity.ok(stats); } + + // ── 재수집 이력 관리 API ───────────────────────────────────── + + @Operation(summary = "재수집 이력 목록 조회", description = "필터 조건으로 재수집 이력을 페이징 조회합니다") + @GetMapping("/recollection-histories") + public ResponseEntity> getRecollectionHistories( + @Parameter(description = "API Key") @RequestParam(required = false) String apiKey, + @Parameter(description = "Job 이름") @RequestParam(required = false) String jobName, + @Parameter(description = "실행 상태") @RequestParam(required = false) String status, + @Parameter(description = "시작일 (yyyy-MM-dd'T'HH:mm:ss)") @RequestParam(required = false) String fromDate, + @Parameter(description = "종료일 (yyyy-MM-dd'T'HH:mm:ss)") @RequestParam(required = false) String toDate, + @Parameter(description = "페이지 번호 (0부터)") @RequestParam(defaultValue = "0") int page, + @Parameter(description = "페이지 크기") @RequestParam(defaultValue = "20") int size) { + log.debug("Search recollection histories: apiKey={}, jobName={}, status={}, page={}, size={}", + apiKey, jobName, status, page, size); + + LocalDateTime from = fromDate != null ? LocalDateTime.parse(fromDate) : null; + LocalDateTime to = toDate != null ? LocalDateTime.parse(toDate) : null; + + Page histories = recollectionHistoryService + .getHistories(apiKey, jobName, status, from, to, PageRequest.of(page, size)); + + Map response = new HashMap<>(); + response.put("content", histories.getContent()); + response.put("totalElements", histories.getTotalElements()); + response.put("totalPages", histories.getTotalPages()); + response.put("number", histories.getNumber()); + response.put("size", histories.getSize()); + return ResponseEntity.ok(response); + } + + @Operation(summary = "재수집 이력 상세 조회", description = "재수집 이력의 상세 정보 (Step Execution + Collection Period + 중복 이력 + API 통계 포함)") + @GetMapping("/recollection-histories/{historyId}") + public ResponseEntity> getRecollectionHistoryDetail( + @Parameter(description = "이력 ID") @PathVariable Long historyId) { + log.debug("Get recollection history detail: historyId={}", historyId); + try { + Map detail = recollectionHistoryService.getHistoryDetailWithSteps(historyId); + return ResponseEntity.ok(detail); + } catch (IllegalArgumentException e) { + return ResponseEntity.notFound().build(); + } + } + + @Operation(summary = "재수집 통계 조회", description = "재수집 실행 통계 및 최근 10건 조회") + @GetMapping("/recollection-histories/stats") + public ResponseEntity> getRecollectionHistoryStats() { + log.debug("Get recollection history stats"); + Map stats = recollectionHistoryService.getHistoryStats(); + stats.put("recentHistories", recollectionHistoryService.getRecentHistories()); + return ResponseEntity.ok(stats); + } + + // ── 수집 기간 관리 API ─────────────────────────────────────── + + @Operation(summary = "수집 기간 목록 조회", description = "모든 API의 수집 기간 설정을 조회합니다") + @GetMapping("/collection-periods") + public ResponseEntity> getCollectionPeriods() { + log.debug("Get all collection periods"); + return ResponseEntity.ok(recollectionHistoryService.getAllCollectionPeriods()); + } + + @Operation(summary = "수집 기간 수정", description = "특정 API의 수집 기간을 수정합니다") + @PutMapping("/collection-periods/{apiKey}") + public ResponseEntity> updateCollectionPeriod( + @Parameter(description = "API Key") @PathVariable String apiKey, + @RequestBody Map request) { + log.info("Update collection period: apiKey={}", apiKey); + try { + String rangeFromStr = request.get("rangeFromDate"); + String rangeToStr = request.get("rangeToDate"); + + if (rangeFromStr == null || rangeToStr == null) { + return ResponseEntity.badRequest().body(Map.of( + "success", false, + "message", "rangeFromDate와 rangeToDate는 필수입니다")); + } + + LocalDateTime rangeFrom = LocalDateTime.parse(rangeFromStr); + LocalDateTime rangeTo = LocalDateTime.parse(rangeToStr); + + if (rangeTo.isBefore(rangeFrom)) { + return ResponseEntity.badRequest().body(Map.of( + "success", false, + "message", "rangeToDate는 rangeFromDate보다 이후여야 합니다")); + } + + recollectionHistoryService.updateCollectionPeriod(apiKey, rangeFrom, rangeTo); + return ResponseEntity.ok(Map.of( + "success", true, + "message", "수집 기간이 수정되었습니다")); + } catch (Exception e) { + log.error("Error updating collection period: apiKey={}", apiKey, e); + return ResponseEntity.internalServerError().body(Map.of( + "success", false, + "message", "수집 기간 수정 실패: " + e.getMessage())); + } + } + + @Operation(summary = "수집 기간 초기화", description = "특정 API의 수집 기간을 null로 초기화합니다") + @PostMapping("/collection-periods/{apiKey}/reset") + public ResponseEntity> resetCollectionPeriod( + @Parameter(description = "API Key") @PathVariable String apiKey) { + log.info("Reset collection period: apiKey={}", apiKey); + try { + recollectionHistoryService.resetCollectionPeriod(apiKey); + return ResponseEntity.ok(Map.of( + "success", true, + "message", "수집 기간이 초기화되었습니다")); + } catch (Exception e) { + log.error("Error resetting collection period: apiKey={}", apiKey, e); + return ResponseEntity.internalServerError().body(Map.of( + "success", false, + "message", "수집 기간 초기화 실패: " + e.getMessage())); + } + } } diff --git a/src/main/java/com/snp/batch/global/dto/JobExecutionDetailDto.java b/src/main/java/com/snp/batch/global/dto/JobExecutionDetailDto.java index 79e4365..f9c0166 100644 --- a/src/main/java/com/snp/batch/global/dto/JobExecutionDetailDto.java +++ b/src/main/java/com/snp/batch/global/dto/JobExecutionDetailDto.java @@ -68,11 +68,12 @@ public class JobExecutionDetailDto { private String exitCode; private String exitMessage; private Long duration; // 실행 시간 (ms) - private ApiCallInfo apiCallInfo; // API 호출 정보 (옵셔널) + private ApiCallInfo apiCallInfo; // API 호출 정보 - StepExecutionContext 기반 (옵셔널) + private StepApiLogSummary apiLogSummary; // API 호출 로그 요약 - batch_api_log 기반 (옵셔널) } /** - * API 호출 정보 DTO + * API 호출 정보 DTO (StepExecutionContext 기반) */ @Data @Builder @@ -86,4 +87,41 @@ public class JobExecutionDetailDto { private Integer completedCalls; // 완료된 API 호출 횟수 private String lastCallTime; // 마지막 호출 시간 } + + /** + * Step별 API 로그 집계 요약 (batch_api_log 테이블 기반) + */ + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class StepApiLogSummary { + private Long totalCalls; // 총 호출수 + private Long successCount; // 성공(2xx) 수 + private Long errorCount; // 에러(4xx/5xx) 수 + private Double avgResponseMs; // 평균 응답시간 + private Long maxResponseMs; // 최대 응답시간 + private Long minResponseMs; // 최소 응답시간 + private Long totalResponseMs; // 총 응답시간 + private Long totalRecordCount; // 총 반환 건수 + private List logs; // 개별 로그 목록 + } + + /** + * 개별 API 호출 로그 DTO (batch_api_log 1건) + */ + @Data + @Builder + @NoArgsConstructor + @AllArgsConstructor + public static class ApiLogEntryDto { + private Long logId; + private String requestUri; + private String httpMethod; + private Integer statusCode; + private Long responseTimeMs; + private Long responseCount; + private String errorMessage; + private LocalDateTime createdAt; + } } diff --git a/src/main/java/com/snp/batch/global/model/BatchCollectionPeriod.java b/src/main/java/com/snp/batch/global/model/BatchCollectionPeriod.java new file mode 100644 index 0000000..39dcf84 --- /dev/null +++ b/src/main/java/com/snp/batch/global/model/BatchCollectionPeriod.java @@ -0,0 +1,53 @@ +package com.snp.batch.global.model; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@Entity +@Getter +@Setter +@NoArgsConstructor +@Table(name = "BATCH_COLLECTION_PERIOD") +@EntityListeners(AuditingEntityListener.class) +public class BatchCollectionPeriod { + + @Id + @Column(name = "API_KEY", length = 50) + private String apiKey; + + @Column(name = "API_KEY_NAME", length = 100) + private String apiKeyName; + + @Column(name = "JOB_NAME", length = 100) + private String jobName; + + @Column(name = "ORDER_SEQ") + private Integer orderSeq; + + @Column(name = "RANGE_FROM_DATE") + private LocalDateTime rangeFromDate; + + @Column(name = "RANGE_TO_DATE") + private LocalDateTime rangeToDate; + + @CreatedDate + @Column(name = "CREATED_AT", updatable = false, nullable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "UPDATED_AT", nullable = false) + private LocalDateTime updatedAt; + + public BatchCollectionPeriod(String apiKey, LocalDateTime rangeFromDate, LocalDateTime rangeToDate) { + this.apiKey = apiKey; + this.rangeFromDate = rangeFromDate; + this.rangeToDate = rangeToDate; + } +} diff --git a/src/main/java/com/snp/batch/global/model/BatchRecollectionHistory.java b/src/main/java/com/snp/batch/global/model/BatchRecollectionHistory.java new file mode 100644 index 0000000..ddaea55 --- /dev/null +++ b/src/main/java/com/snp/batch/global/model/BatchRecollectionHistory.java @@ -0,0 +1,93 @@ +package com.snp.batch.global.model; + +import jakarta.persistence.*; +import lombok.*; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@Entity +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@Table(name = "BATCH_RECOLLECTION_HISTORY") +@EntityListeners(AuditingEntityListener.class) +public class BatchRecollectionHistory { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "HISTORY_ID") + private Long historyId; + + @Column(name = "API_KEY", length = 50, nullable = false) + private String apiKey; + + @Column(name = "API_KEY_NAME", length = 100) + private String apiKeyName; + + @Column(name = "JOB_NAME", length = 100, nullable = false) + private String jobName; + + @Column(name = "JOB_EXECUTION_ID") + private Long jobExecutionId; + + @Column(name = "RANGE_FROM_DATE", nullable = false) + private LocalDateTime rangeFromDate; + + @Column(name = "RANGE_TO_DATE", nullable = false) + private LocalDateTime rangeToDate; + + @Column(name = "EXECUTION_STATUS", length = 20, nullable = false) + private String executionStatus; + + @Column(name = "EXECUTION_START_TIME") + private LocalDateTime executionStartTime; + + @Column(name = "EXECUTION_END_TIME") + private LocalDateTime executionEndTime; + + @Column(name = "DURATION_MS") + private Long durationMs; + + @Column(name = "READ_COUNT") + private Long readCount; + + @Column(name = "WRITE_COUNT") + private Long writeCount; + + @Column(name = "SKIP_COUNT") + private Long skipCount; + + @Column(name = "API_CALL_COUNT") + private Integer apiCallCount; + + @Column(name = "TOTAL_RESPONSE_TIME_MS") + private Long totalResponseTimeMs; + + @Column(name = "EXECUTOR", length = 50) + private String executor; + + @Column(name = "RECOLLECTION_REASON", columnDefinition = "TEXT") + private String recollectionReason; + + @Column(name = "FAILURE_REASON", columnDefinition = "TEXT") + private String failureReason; + + @Column(name = "HAS_OVERLAP") + private Boolean hasOverlap; + + @Column(name = "OVERLAPPING_HISTORY_IDS", length = 500) + private String overlappingHistoryIds; + + @CreatedDate + @Column(name = "CREATED_AT", updatable = false, nullable = false) + private LocalDateTime createdAt; + + @LastModifiedDate + @Column(name = "UPDATED_AT", nullable = false) + private LocalDateTime updatedAt; +} diff --git a/src/main/java/com/snp/batch/global/repository/BatchApiLogRepository.java b/src/main/java/com/snp/batch/global/repository/BatchApiLogRepository.java index 6ec53ff..8335cc3 100644 --- a/src/main/java/com/snp/batch/global/repository/BatchApiLogRepository.java +++ b/src/main/java/com/snp/batch/global/repository/BatchApiLogRepository.java @@ -2,9 +2,45 @@ package com.snp.batch.global.repository; import com.snp.batch.global.model.BatchApiLog; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; +import java.util.List; + @Repository public interface BatchApiLogRepository extends JpaRepository { + @Query(""" + SELECT COUNT(l), + COALESCE(SUM(l.responseTimeMs), 0), + COALESCE(AVG(l.responseTimeMs), 0), + COALESCE(MAX(l.responseTimeMs), 0), + COALESCE(MIN(l.responseTimeMs), 0) + FROM BatchApiLog l + WHERE l.jobExecutionId = :jobExecutionId + """) + List getApiStatsByJobExecutionId(@Param("jobExecutionId") Long jobExecutionId); + + /** + * Step별 API 호출 통계 집계 + */ + @Query(""" + SELECT COUNT(l), + SUM(CASE WHEN l.statusCode >= 200 AND l.statusCode < 300 THEN 1 ELSE 0 END), + SUM(CASE WHEN l.statusCode >= 400 OR l.errorMessage IS NOT NULL THEN 1 ELSE 0 END), + COALESCE(AVG(l.responseTimeMs), 0), + COALESCE(MAX(l.responseTimeMs), 0), + COALESCE(MIN(l.responseTimeMs), 0), + COALESCE(SUM(l.responseTimeMs), 0), + COALESCE(SUM(l.responseCount), 0) + FROM BatchApiLog l + WHERE l.stepExecutionId = :stepExecutionId + """) + List getApiStatsByStepExecutionId(@Param("stepExecutionId") Long stepExecutionId); + + /** + * Step별 개별 API 호출 로그 목록 + */ + List findByStepExecutionIdOrderByCreatedAtAsc(Long stepExecutionId); } \ No newline at end of file diff --git a/src/main/java/com/snp/batch/global/repository/BatchCollectionPeriodRepository.java b/src/main/java/com/snp/batch/global/repository/BatchCollectionPeriodRepository.java new file mode 100644 index 0000000..c056434 --- /dev/null +++ b/src/main/java/com/snp/batch/global/repository/BatchCollectionPeriodRepository.java @@ -0,0 +1,13 @@ +package com.snp.batch.global.repository; + +import com.snp.batch.global.model.BatchCollectionPeriod; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +public interface BatchCollectionPeriodRepository extends JpaRepository { + + List findAllByOrderByOrderSeqAsc(); +} diff --git a/src/main/java/com/snp/batch/global/repository/BatchRecollectionHistoryRepository.java b/src/main/java/com/snp/batch/global/repository/BatchRecollectionHistoryRepository.java new file mode 100644 index 0000000..eec8384 --- /dev/null +++ b/src/main/java/com/snp/batch/global/repository/BatchRecollectionHistoryRepository.java @@ -0,0 +1,40 @@ +package com.snp.batch.global.repository; + +import com.snp.batch.global.model.BatchRecollectionHistory; +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 org.springframework.stereotype.Repository; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +@Repository +public interface BatchRecollectionHistoryRepository + extends JpaRepository, + JpaSpecificationExecutor { + + Optional findByJobExecutionId(Long jobExecutionId); + + List findTop10ByOrderByCreatedAtDesc(); + + @Query(""" + SELECT h FROM BatchRecollectionHistory h + WHERE h.apiKey = :apiKey + AND h.historyId != :excludeId + AND h.rangeFromDate < :toDate + AND h.rangeToDate > :fromDate + ORDER BY h.createdAt DESC + """) + List findOverlappingHistories( + @Param("apiKey") String apiKey, + @Param("fromDate") LocalDateTime fromDate, + @Param("toDate") LocalDateTime toDate, + @Param("excludeId") Long excludeId); + + long countByExecutionStatus(String executionStatus); + + long countByHasOverlapTrue(); +} diff --git a/src/main/java/com/snp/batch/service/BatchDateService.java b/src/main/java/com/snp/batch/service/BatchDateService.java index b4c9eff..6ba45ee 100644 --- a/src/main/java/com/snp/batch/service/BatchDateService.java +++ b/src/main/java/com/snp/batch/service/BatchDateService.java @@ -1,7 +1,12 @@ package com.snp.batch.service; +import com.snp.batch.global.model.BatchCollectionPeriod; +import com.snp.batch.global.repository.BatchCollectionPeriodRepository; import com.snp.batch.global.repository.BatchLastExecutionRepository; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.scope.context.StepContext; +import org.springframework.batch.core.scope.context.StepSynchronizationManager; import org.springframework.stereotype.Service; import java.time.LocalDateTime; @@ -10,35 +15,61 @@ import java.time.ZoneOffset; import java.time.format.DateTimeFormatter; import java.util.HashMap; import java.util.Map; +import java.util.Optional; + @Slf4j @Service +@RequiredArgsConstructor public class BatchDateService { - private final BatchLastExecutionRepository repository; - public BatchDateService(BatchLastExecutionRepository repository) { - this.repository = repository; + private final BatchLastExecutionRepository repository; + private final BatchCollectionPeriodRepository collectionPeriodRepository; + + /** + * 현재 Step의 Job 파라미터에서 executionMode를 확인 + */ + private String getExecutionMode() { + try { + StepContext context = StepSynchronizationManager.getContext(); + if (context != null && context.getStepExecution() != null) { + return context.getStepExecution().getJobExecution() + .getJobParameters().getString("executionMode", "NORMAL"); + } + } catch (Exception e) { + log.debug("StepSynchronizationManager 컨텍스트 접근 실패, NORMAL 모드로 처리", e); + } + return "NORMAL"; + } + + /** + * 현재 Step의 Job 파라미터에서 apiKey 파라미터를 확인 (재수집용) + */ + private String getRecollectApiKey() { + try { + StepContext context = StepSynchronizationManager.getContext(); + if (context != null && context.getStepExecution() != null) { + return context.getStepExecution().getJobExecution() + .getJobParameters().getString("apiKey"); + } + } catch (Exception e) { + // ignore + } + return null; } public Map getDateRangeWithoutTimeParams(String apiKey) { + // 재수집 모드: batch_collection_period에서 날짜 조회 + if ("RECOLLECT".equals(getExecutionMode())) { + return getCollectionPeriodDateParams(apiKey); + } + + // 정상 모드: last_success_date ~ now() return repository.findDateRangeByApiKey(apiKey) .map(projection -> { Map params = new HashMap<>(); - - LocalDateTime fromTarget = (projection.getRangeFromDate() != null) - ? projection.getRangeFromDate() - : projection.getLastSuccessDate(); - - LocalDateTime toTarget = (projection.getRangeToDate() != null) - ? projection.getRangeToDate() - : LocalDateTime.now(); - - // 2. 파라미터 맵에 날짜 정보 매핑 - putDateParams(params, "from", fromTarget); - putDateParams(params, "to", toTarget); - - // 3. 고정 값 설정 + putDateParams(params, "from", projection.getLastSuccessDate()); + putDateParams(params, "to", LocalDateTime.now()); params.put("shipsCategory", "0"); - return params; }) .orElseGet(() -> { @@ -47,6 +78,80 @@ public class BatchDateService { }); } + public Map getDateRangeWithTimezoneParams(String apiKey) { + return getDateRangeWithTimezoneParams(apiKey, "fromDate", "toDate"); + } + + public Map getDateRangeWithTimezoneParams(String apiKey, String dateParam1, String dateParam2) { + DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSX"); + + // 재수집 모드: batch_collection_period에서 날짜 조회 + if ("RECOLLECT".equals(getExecutionMode())) { + return getCollectionPeriodTimezoneParams(apiKey, dateParam1, dateParam2, formatter); + } + + // 정상 모드: last_success_date ~ now() + return repository.findDateRangeByApiKey(apiKey) + .map(projection -> { + Map params = new HashMap<>(); + params.put(dateParam1, formatToUtc(projection.getLastSuccessDate(), formatter)); + params.put(dateParam2, formatToUtc(LocalDateTime.now(), formatter)); + return params; + }) + .orElseGet(() -> { + log.warn("해당 apiKey에 대한 데이터를 찾을 수 없습니다: {}", apiKey); + return new HashMap<>(); + }); + } + + /** + * 재수집 모드: batch_collection_period에서 년/월/일 파라미터 생성 + */ + private Map getCollectionPeriodDateParams(String apiKey) { + String recollectApiKey = getRecollectApiKey(); + String lookupKey = recollectApiKey != null ? recollectApiKey : apiKey; + + Optional opt = collectionPeriodRepository.findById(lookupKey); + if (opt.isEmpty()) { + log.warn("[RECOLLECT] 수집기간 설정을 찾을 수 없습니다: {}", lookupKey); + return new HashMap<>(); + } + + BatchCollectionPeriod cp = opt.get(); + Map params = new HashMap<>(); + putDateParams(params, "from", cp.getRangeFromDate()); + putDateParams(params, "to", cp.getRangeToDate()); + params.put("shipsCategory", "0"); + + log.info("[RECOLLECT] batch_collection_period 날짜 사용: apiKey={}, range={}~{}", + lookupKey, cp.getRangeFromDate(), cp.getRangeToDate()); + return params; + } + + /** + * 재수집 모드: batch_collection_period에서 UTC 타임존 파라미터 생성 + */ + private Map getCollectionPeriodTimezoneParams( + String apiKey, String dateParam1, String dateParam2, DateTimeFormatter formatter) { + String recollectApiKey = getRecollectApiKey(); + String lookupKey = recollectApiKey != null ? recollectApiKey : apiKey; + + Optional opt = collectionPeriodRepository.findById(lookupKey); + if (opt.isEmpty()) { + log.warn("[RECOLLECT] 수집기간 설정을 찾을 수 없습니다: {}", lookupKey); + return new HashMap<>(); + } + + BatchCollectionPeriod cp = opt.get(); + Map params = new HashMap<>(); + params.put(dateParam1, formatToUtc(cp.getRangeFromDate(), formatter)); + params.put(dateParam2, formatToUtc(cp.getRangeToDate(), formatter)); + + log.info("[RECOLLECT] batch_collection_period 날짜 사용 (UTC): apiKey={}, range={}~{}", + lookupKey, cp.getRangeFromDate(), cp.getRangeToDate()); + return params; + } + /** * LocalDateTime에서 연, 월, 일을 추출하여 Map에 담는 헬퍼 메소드 */ @@ -58,63 +163,13 @@ public class BatchDateService { } } - public Map getDateRangeWithTimezoneParams(String apiKey) { - return repository.findDateRangeByApiKey(apiKey) - .map(projection -> { - Map params = new HashMap<>(); - // 'Z'를 문자열 리터럴이 아닌 실제 타임존 기호(X)로 처리 - DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSX"); - - // 한국 시간을 UTC로 변환하는 헬퍼 메소드 (아래 정의) - params.put("fromDate", formatToUtc(projection.getRangeFromDate() != null ? - projection.getRangeFromDate() : projection.getLastSuccessDate(), formatter)); - - LocalDateTime toDateTime = projection.getRangeToDate() != null ? - projection.getRangeToDate() : LocalDateTime.now(); - - params.put("toDate", formatToUtc(toDateTime, formatter)); - - return params; - }) - .orElseGet(() -> { - log.warn("해당 apiKey에 대한 데이터를 찾을 수 없습니다: {}", apiKey); - return new HashMap<>(); - }); - } - - public Map getDateRangeWithTimezoneParams(String apiKey, String dateParam1, String dateParam2) { - return repository.findDateRangeByApiKey(apiKey) - .map(projection -> { - Map params = new HashMap<>(); - // 'Z'를 문자열 리터럴이 아닌 실제 타임존 기호(X)로 처리 - DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSX"); - - // 한국 시간을 UTC로 변환하는 헬퍼 메소드 (아래 정의) - params.put(dateParam1, formatToUtc(projection.getRangeFromDate() != null ? - projection.getRangeFromDate() : projection.getLastSuccessDate(), formatter)); - - LocalDateTime toDateTime = projection.getRangeToDate() != null ? - projection.getRangeToDate() : LocalDateTime.now(); - - params.put(dateParam2, formatToUtc(toDateTime, formatter)); - - return params; - }) - .orElseGet(() -> { - log.warn("해당 apiKey에 대한 데이터를 찾을 수 없습니다: {}", apiKey); - return new HashMap<>(); - }); - } - - // 한국 시간(LocalDateTime)을 UTC 문자열로 변환하는 로직 + /** + * 한국 시간(LocalDateTime)을 UTC 문자열로 변환 + */ private String formatToUtc(LocalDateTime localDateTime, DateTimeFormatter formatter) { if (localDateTime == null) return null; - // 1. 한국 시간대(KST)임을 명시 - // 2. UTC로 시간대를 변경 (9시간 빠짐) - // 3. 포맷팅 (끝에 Z가 자동으로 붙음) return localDateTime.atZone(ZoneId.of("Asia/Seoul")) .withZoneSameInstant(ZoneOffset.UTC) .format(formatter); } - } diff --git a/src/main/java/com/snp/batch/service/BatchService.java b/src/main/java/com/snp/batch/service/BatchService.java index cdce4ec..c380cc5 100644 --- a/src/main/java/com/snp/batch/service/BatchService.java +++ b/src/main/java/com/snp/batch/service/BatchService.java @@ -1,7 +1,9 @@ package com.snp.batch.service; +import com.snp.batch.common.batch.listener.RecollectionJobExecutionListener; import com.snp.batch.global.dto.*; import com.snp.batch.global.repository.TimelineRepository; +import jakarta.annotation.PostConstruct; import lombok.extern.slf4j.Slf4j; import org.springframework.batch.core.Job; import org.springframework.batch.core.JobExecution; @@ -9,6 +11,7 @@ import org.springframework.batch.core.JobInstance; import org.springframework.batch.core.JobParameters; import org.springframework.batch.core.JobParametersBuilder; import org.springframework.batch.core.explore.JobExplorer; +import org.springframework.batch.core.job.AbstractJob; import org.springframework.batch.core.launch.JobLauncher; import org.springframework.batch.core.launch.JobOperator; import org.springframework.beans.factory.annotation.Autowired; @@ -31,6 +34,7 @@ public class BatchService { private final Map jobMap; private final ScheduleService scheduleService; private final TimelineRepository timelineRepository; + private final RecollectionJobExecutionListener recollectionJobExecutionListener; @Autowired public BatchService(JobLauncher jobLauncher, @@ -38,13 +42,29 @@ public class BatchService { JobOperator jobOperator, Map jobMap, @Lazy ScheduleService scheduleService, - TimelineRepository timelineRepository) { + TimelineRepository timelineRepository, + RecollectionJobExecutionListener recollectionJobExecutionListener) { this.jobLauncher = jobLauncher; this.jobExplorer = jobExplorer; this.jobOperator = jobOperator; this.jobMap = jobMap; this.scheduleService = scheduleService; this.timelineRepository = timelineRepository; + this.recollectionJobExecutionListener = recollectionJobExecutionListener; + } + + /** + * 모든 Job에 RecollectionJobExecutionListener를 등록 + * 리스너 내부에서 executionMode 체크하므로 정상 실행에는 영향 없음 + */ + @PostConstruct + public void registerGlobalListeners() { + jobMap.values().forEach(job -> { + if (job instanceof AbstractJob abstractJob) { + abstractJob.registerJobExecutionListener(recollectionJobExecutionListener); + } + }); + log.info("[BatchService] RecollectionJobExecutionListener를 {}개 Job에 등록", jobMap.size()); } public Long executeJob(String jobName) throws Exception { diff --git a/src/main/java/com/snp/batch/service/RecollectionHistoryService.java b/src/main/java/com/snp/batch/service/RecollectionHistoryService.java new file mode 100644 index 0000000..847059f --- /dev/null +++ b/src/main/java/com/snp/batch/service/RecollectionHistoryService.java @@ -0,0 +1,436 @@ +package com.snp.batch.service; + +import com.snp.batch.global.dto.JobExecutionDetailDto; +import com.snp.batch.global.model.BatchCollectionPeriod; +import com.snp.batch.global.model.BatchLastExecution; +import com.snp.batch.global.model.BatchRecollectionHistory; +import com.snp.batch.global.repository.BatchApiLogRepository; +import com.snp.batch.global.repository.BatchCollectionPeriodRepository; +import com.snp.batch.global.repository.BatchLastExecutionRepository; +import com.snp.batch.global.repository.BatchRecollectionHistoryRepository; +import jakarta.persistence.criteria.Predicate; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.batch.core.JobExecution; +import org.springframework.batch.core.StepExecution; +import org.springframework.batch.core.explore.JobExplorer; +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.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import com.snp.batch.global.model.BatchApiLog; + +import java.time.Duration; +import java.time.LocalDateTime; +import java.util.*; +import java.util.stream.Collectors; + +@Slf4j +@Service +@RequiredArgsConstructor +public class RecollectionHistoryService { + + private final BatchRecollectionHistoryRepository historyRepository; + private final BatchCollectionPeriodRepository periodRepository; + private final BatchLastExecutionRepository lastExecutionRepository; + private final BatchApiLogRepository apiLogRepository; + private final JobExplorer jobExplorer; + + /** + * 재수집 실행 시작 기록 + * REQUIRES_NEW: Job 실패해도 이력은 보존 + */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public BatchRecollectionHistory recordStart( + String jobName, + Long jobExecutionId, + String apiKey, + String executor, + String reason) { + + Optional period = periodRepository.findById(apiKey); + if (period.isEmpty()) { + log.warn("[RecollectionHistory] apiKey {} 에 대한 수집기간 없음, 이력 미생성", apiKey); + return null; + } + + BatchCollectionPeriod cp = period.get(); + LocalDateTime rangeFrom = cp.getRangeFromDate(); + LocalDateTime rangeTo = cp.getRangeToDate(); + + // 기간 중복 검출 + List overlaps = historyRepository + .findOverlappingHistories(apiKey, rangeFrom, rangeTo, -1L); + boolean hasOverlap = !overlaps.isEmpty(); + String overlapIds = overlaps.stream() + .map(h -> String.valueOf(h.getHistoryId())) + .collect(Collectors.joining(",")); + + LocalDateTime now = LocalDateTime.now(); + BatchRecollectionHistory history = BatchRecollectionHistory.builder() + .apiKey(apiKey) + .apiKeyName(cp.getApiKeyName()) + .jobName(jobName) + .jobExecutionId(jobExecutionId) + .rangeFromDate(rangeFrom) + .rangeToDate(rangeTo) + .executionStatus("STARTED") + .executionStartTime(now) + .executor(executor != null ? executor : "SYSTEM") + .recollectionReason(reason) + .hasOverlap(hasOverlap) + .overlappingHistoryIds(hasOverlap ? overlapIds : null) + .createdAt(now) + .updatedAt(now) + .build(); + + BatchRecollectionHistory saved = historyRepository.save(history); + log.info("[RecollectionHistory] 재수집 이력 생성: historyId={}, apiKey={}, jobExecutionId={}, range={}~{}", + saved.getHistoryId(), apiKey, jobExecutionId, rangeFrom, rangeTo); + return saved; + } + + /** + * 재수집 실행 완료 기록 + */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void recordCompletion( + Long jobExecutionId, + String status, + Long readCount, + Long writeCount, + Long skipCount, + Integer apiCallCount, + Long totalResponseTimeMs, + String failureReason) { + + Optional opt = + historyRepository.findByJobExecutionId(jobExecutionId); + + if (opt.isEmpty()) { + log.warn("[RecollectionHistory] jobExecutionId {} 에 해당하는 이력 없음", jobExecutionId); + return; + } + + BatchRecollectionHistory history = opt.get(); + LocalDateTime now = LocalDateTime.now(); + + history.setExecutionStatus(status); + history.setExecutionEndTime(now); + history.setReadCount(readCount); + history.setWriteCount(writeCount); + history.setSkipCount(skipCount); + history.setApiCallCount(apiCallCount); + history.setTotalResponseTimeMs(totalResponseTimeMs); + history.setFailureReason(failureReason); + history.setUpdatedAt(now); + + if (history.getExecutionStartTime() != null) { + history.setDurationMs(Duration.between(history.getExecutionStartTime(), now).toMillis()); + } + + historyRepository.save(history); + log.info("[RecollectionHistory] 재수집 완료 기록: jobExecutionId={}, status={}, read={}, write={}", + jobExecutionId, status, readCount, writeCount); + } + + /** + * 동적 필터링 + 페이징 목록 조회 + */ + @Transactional(readOnly = true) + public Page getHistories( + String apiKey, String jobName, String status, + LocalDateTime from, LocalDateTime to, + Pageable pageable) { + + Specification spec = (root, query, cb) -> { + List predicates = new ArrayList<>(); + + if (apiKey != null && !apiKey.isEmpty()) { + predicates.add(cb.equal(root.get("apiKey"), apiKey)); + } + if (jobName != null && !jobName.isEmpty()) { + predicates.add(cb.equal(root.get("jobName"), jobName)); + } + if (status != null && !status.isEmpty()) { + predicates.add(cb.equal(root.get("executionStatus"), status)); + } + if (from != null) { + predicates.add(cb.greaterThanOrEqualTo(root.get("executionStartTime"), from)); + } + if (to != null) { + predicates.add(cb.lessThanOrEqualTo(root.get("executionStartTime"), to)); + } + + query.orderBy(cb.desc(root.get("createdAt"))); + return cb.and(predicates.toArray(new Predicate[0])); + }; + + return historyRepository.findAll(spec, pageable); + } + + /** + * 상세 조회 (중복 이력 실시간 재검사 포함) + */ + @Transactional(readOnly = true) + public Map getHistoryDetail(Long historyId) { + BatchRecollectionHistory history = historyRepository.findById(historyId) + .orElseThrow(() -> new IllegalArgumentException("이력을 찾을 수 없습니다: " + historyId)); + + // 중복 이력 실시간 재검사 + List currentOverlaps = historyRepository + .findOverlappingHistories(history.getApiKey(), + history.getRangeFromDate(), history.getRangeToDate(), + history.getHistoryId()); + + // API 응답시간 통계 + Map apiStats = null; + if (history.getJobExecutionId() != null) { + apiStats = getApiStats(history.getJobExecutionId()); + } + + Map result = new LinkedHashMap<>(); + result.put("history", history); + result.put("overlappingHistories", currentOverlaps); + result.put("apiStats", apiStats); + return result; + } + + /** + * 상세 조회 + Step Execution + Collection Period 포함 + * job_execution_id로 batch_step_execution, batch_collection_period를 조인 + */ + @Transactional(readOnly = true) + public Map getHistoryDetailWithSteps(Long historyId) { + BatchRecollectionHistory history = historyRepository.findById(historyId) + .orElseThrow(() -> new IllegalArgumentException("이력을 찾을 수 없습니다: " + historyId)); + + // 중복 이력 실시간 재검사 + List currentOverlaps = historyRepository + .findOverlappingHistories(history.getApiKey(), + history.getRangeFromDate(), history.getRangeToDate(), + history.getHistoryId()); + + // API 응답시간 통계 + Map apiStats = null; + if (history.getJobExecutionId() != null) { + apiStats = getApiStats(history.getJobExecutionId()); + } + + // Collection Period 조회 + BatchCollectionPeriod collectionPeriod = periodRepository + .findById(history.getApiKey()).orElse(null); + + // Step Execution 조회 (job_execution_id 기반) + List stepExecutions = new ArrayList<>(); + if (history.getJobExecutionId() != null) { + JobExecution jobExecution = jobExplorer.getJobExecution(history.getJobExecutionId()); + if (jobExecution != null) { + stepExecutions = jobExecution.getStepExecutions().stream() + .map(this::convertStepToDto) + .collect(Collectors.toList()); + } + } + + Map result = new LinkedHashMap<>(); + result.put("history", history); + result.put("overlappingHistories", currentOverlaps); + result.put("apiStats", apiStats); + result.put("collectionPeriod", collectionPeriod); + result.put("stepExecutions", stepExecutions); + return result; + } + + private JobExecutionDetailDto.StepExecutionDto convertStepToDto(StepExecution stepExecution) { + Long duration = null; + if (stepExecution.getStartTime() != null && stepExecution.getEndTime() != null) { + duration = Duration.between(stepExecution.getStartTime(), stepExecution.getEndTime()).toMillis(); + } + + // StepExecutionContext에서 API 정보 추출 (ExecutionDetail 호환) + JobExecutionDetailDto.ApiCallInfo apiCallInfo = null; + var context = stepExecution.getExecutionContext(); + if (context.containsKey("apiUrl")) { + apiCallInfo = JobExecutionDetailDto.ApiCallInfo.builder() + .apiUrl(context.getString("apiUrl", "")) + .method(context.getString("apiMethod", "")) + .totalCalls(context.containsKey("totalApiCalls") ? context.getInt("totalApiCalls", 0) : null) + .completedCalls(context.containsKey("completedApiCalls") ? context.getInt("completedApiCalls", 0) : null) + .lastCallTime(context.containsKey("lastCallTime") ? context.getString("lastCallTime", "") : null) + .build(); + } + + // batch_api_log 테이블에서 Step별 API 로그 집계 + 개별 로그 조회 + JobExecutionDetailDto.StepApiLogSummary apiLogSummary = + buildStepApiLogSummary(stepExecution.getId()); + + return JobExecutionDetailDto.StepExecutionDto.builder() + .stepExecutionId(stepExecution.getId()) + .stepName(stepExecution.getStepName()) + .status(stepExecution.getStatus().name()) + .startTime(stepExecution.getStartTime()) + .endTime(stepExecution.getEndTime()) + .readCount((int) stepExecution.getReadCount()) + .writeCount((int) stepExecution.getWriteCount()) + .commitCount((int) stepExecution.getCommitCount()) + .rollbackCount((int) stepExecution.getRollbackCount()) + .readSkipCount((int) stepExecution.getReadSkipCount()) + .processSkipCount((int) stepExecution.getProcessSkipCount()) + .writeSkipCount((int) stepExecution.getWriteSkipCount()) + .filterCount((int) stepExecution.getFilterCount()) + .exitCode(stepExecution.getExitStatus().getExitCode()) + .exitMessage(stepExecution.getExitStatus().getExitDescription()) + .duration(duration) + .apiCallInfo(apiCallInfo) + .apiLogSummary(apiLogSummary) + .build(); + } + + /** + * Step별 batch_api_log 집계 + 개별 로그 목록 조회 + */ + private JobExecutionDetailDto.StepApiLogSummary buildStepApiLogSummary(Long stepExecutionId) { + List stats = apiLogRepository.getApiStatsByStepExecutionId(stepExecutionId); + if (stats.isEmpty() || stats.get(0) == null || ((Number) stats.get(0)[0]).longValue() == 0L) { + return null; + } + + Object[] row = stats.get(0); + + List logs = apiLogRepository + .findByStepExecutionIdOrderByCreatedAtAsc(stepExecutionId); + + List logEntries = logs.stream() + .map(apiLog -> JobExecutionDetailDto.ApiLogEntryDto.builder() + .logId(apiLog.getLogId()) + .requestUri(apiLog.getRequestUri()) + .httpMethod(apiLog.getHttpMethod()) + .statusCode(apiLog.getStatusCode()) + .responseTimeMs(apiLog.getResponseTimeMs()) + .responseCount(apiLog.getResponseCount()) + .errorMessage(apiLog.getErrorMessage()) + .createdAt(apiLog.getCreatedAt()) + .build()) + .toList(); + + return JobExecutionDetailDto.StepApiLogSummary.builder() + .totalCalls(((Number) row[0]).longValue()) + .successCount(((Number) row[1]).longValue()) + .errorCount(((Number) row[2]).longValue()) + .avgResponseMs(((Number) row[3]).doubleValue()) + .maxResponseMs(((Number) row[4]).longValue()) + .minResponseMs(((Number) row[5]).longValue()) + .totalResponseMs(((Number) row[6]).longValue()) + .totalRecordCount(((Number) row[7]).longValue()) + .logs(logEntries) + .build(); + } + + /** + * 대시보드용 최근 10건 + */ + @Transactional(readOnly = true) + public List getRecentHistories() { + return historyRepository.findTop10ByOrderByCreatedAtDesc(); + } + + /** + * 통계 조회 + */ + @Transactional(readOnly = true) + public Map getHistoryStats() { + Map stats = new LinkedHashMap<>(); + stats.put("totalCount", historyRepository.count()); + stats.put("completedCount", historyRepository.countByExecutionStatus("COMPLETED")); + stats.put("failedCount", historyRepository.countByExecutionStatus("FAILED")); + stats.put("runningCount", historyRepository.countByExecutionStatus("STARTED")); + stats.put("overlapCount", historyRepository.countByHasOverlapTrue()); + return stats; + } + + /** + * API 응답시간 통계 (BatchApiLog 집계) + */ + @Transactional(readOnly = true) + public Map getApiStats(Long jobExecutionId) { + List results = apiLogRepository.getApiStatsByJobExecutionId(jobExecutionId); + if (results.isEmpty() || results.get(0) == null) { + return null; + } + + Object[] row = results.get(0); + Map stats = new LinkedHashMap<>(); + stats.put("callCount", row[0]); + stats.put("totalMs", row[1]); + stats.put("avgMs", row[2]); + stats.put("maxMs", row[3]); + stats.put("minMs", row[4]); + return stats; + } + + /** + * 재수집 실행 전: 현재 last_success_date 조회 (복원용) + */ + @Transactional(readOnly = true) + public LocalDateTime getLastSuccessDate(String apiKey) { + return lastExecutionRepository.findById(apiKey) + .map(BatchLastExecution::getLastSuccessDate) + .orElse(null); + } + + /** + * 재수집 실행 후: Tasklet이 업데이트한 last_success_date를 원래 값으로 복원 + * 재수집은 과거 데이터 재처리이므로 last_success_date를 변경하면 안 됨 + */ + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void restoreLastSuccessDate(String apiKey, LocalDateTime originalDate) { + if (originalDate == null) return; + lastExecutionRepository.findById(apiKey).ifPresent(lastExec -> { + LocalDateTime beforeDate = lastExec.getLastSuccessDate(); + lastExec.setLastSuccessDate(originalDate); + lastExec.setUpdatedAt(LocalDateTime.now()); + lastExecutionRepository.save(lastExec); + log.info("[RecollectionHistory] last_success_date 복원: apiKey={}, before={}, after={}", + apiKey, beforeDate, originalDate); + }); + } + + /** + * 수집 기간 전체 조회 + */ + @Transactional(readOnly = true) + public List getAllCollectionPeriods() { + return periodRepository.findAllByOrderByOrderSeqAsc(); + } + + /** + * 수집 기간 수정 + */ + @Transactional + public BatchCollectionPeriod updateCollectionPeriod(String apiKey, + LocalDateTime rangeFromDate, + LocalDateTime rangeToDate) { + BatchCollectionPeriod period = periodRepository.findById(apiKey) + .orElseGet(() -> new BatchCollectionPeriod(apiKey, rangeFromDate, rangeToDate)); + + period.setRangeFromDate(rangeFromDate); + period.setRangeToDate(rangeToDate); + return periodRepository.save(period); + } + + /** + * 수집 기간 초기화 (rangeFromDate, rangeToDate를 null로) + */ + @Transactional + public void resetCollectionPeriod(String apiKey) { + periodRepository.findById(apiKey).ifPresent(period -> { + period.setRangeFromDate(null); + period.setRangeToDate(null); + periodRepository.save(period); + log.info("[RecollectionHistory] 수집 기간 초기화: apiKey={}", apiKey); + }); + } +}