feat: 재수집 기간 관리 및 재수집 이력 프로세스 개발

This commit is contained in:
hyojin kim 2026-02-19 17:13:59 +09:00
부모 8755a92f34
커밋 f1af7f60b2
18개의 변경된 파일2582개의 추가작업 그리고 77개의 파일을 삭제

파일 보기

@ -10,6 +10,8 @@ const Dashboard = lazy(() => import('./pages/Dashboard'));
const Jobs = lazy(() => import('./pages/Jobs')); const Jobs = lazy(() => import('./pages/Jobs'));
const Executions = lazy(() => import('./pages/Executions')); const Executions = lazy(() => import('./pages/Executions'));
const ExecutionDetail = lazy(() => import('./pages/ExecutionDetail')); 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 Schedules = lazy(() => import('./pages/Schedules'));
const Timeline = lazy(() => import('./pages/Timeline')); const Timeline = lazy(() => import('./pages/Timeline'));
@ -26,6 +28,8 @@ function AppLayout() {
<Route path="/jobs" element={<Jobs />} /> <Route path="/jobs" element={<Jobs />} />
<Route path="/executions" element={<Executions />} /> <Route path="/executions" element={<Executions />} />
<Route path="/executions/:id" element={<ExecutionDetail />} /> <Route path="/executions/:id" element={<ExecutionDetail />} />
<Route path="/recollects" element={<Recollects />} />
<Route path="/recollects/:id" element={<RecollectDetail />} />
<Route path="/schedules" element={<Schedules />} /> <Route path="/schedules" element={<Schedules />} />
<Route path="/schedule-timeline" element={<Timeline />} /> <Route path="/schedule-timeline" element={<Timeline />} />
</Routes> </Routes>

파일 보기

@ -102,6 +102,30 @@ export interface StepExecutionDto {
exitMessage: string | null; exitMessage: string | null;
duration: number | null; duration: number | null;
apiCallInfo: ApiCallInfo | 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 { export interface JobExecutionDetailDto {
@ -212,6 +236,73 @@ export interface ExecutionStatisticsDto {
avgDurationMs: number; 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 ──────────────────────────────────────────── // ── API Functions ────────────────────────────────────────────
export const batchApi = { export const batchApi = {
@ -224,9 +315,11 @@ export const batchApi = {
getJobsDetail: () => getJobsDetail: () =>
fetchJson<JobDetailDto[]>(`${BASE}/jobs/detail`), fetchJson<JobDetailDto[]>(`${BASE}/jobs/detail`),
executeJob: (jobName: string, params?: Record<string, string>) => executeJob: (jobName: string, params?: Record<string, string>) => {
postJson<{ success: boolean; message: string; executionId?: number }>( const qs = params ? '?' + new URLSearchParams(params).toString() : '';
`${BASE}/jobs/${jobName}/execute`, params), return postJson<{ success: boolean; message: string; executionId?: number }>(
`${BASE}/jobs/${jobName}/execute${qs}`);
},
getJobExecutions: (jobName: string) => getJobExecutions: (jobName: string) =>
fetchJson<JobExecutionDto[]>(`${BASE}/jobs/${jobName}/executions`), fetchJson<JobExecutionDto[]>(`${BASE}/jobs/${jobName}/executions`),
@ -305,4 +398,48 @@ export const batchApi = {
getPeriodExecutions: (jobName: string, view: string, periodKey: string) => getPeriodExecutions: (jobName: string, view: string, periodKey: string) =>
fetchJson<JobExecutionDto[]>( fetchJson<JobExecutionDto[]>(
`${BASE}/timeline/period-executions?jobName=${jobName}&view=${view}&periodKey=${periodKey}`), `${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<RecollectionSearchResponse>(`${BASE}/recollection-histories?${qs.toString()}`);
},
getRecollectionDetail: (historyId: number) =>
fetchJson<RecollectionDetailResponse>(`${BASE}/recollection-histories/${historyId}`),
getRecollectionStats: () =>
fetchJson<RecollectionStatsResponse>(`${BASE}/recollection-histories/stats`),
getCollectionPeriods: () =>
fetchJson<CollectionPeriodDto[]>(`${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 }>;
});
},
}; };

파일 보기

@ -5,6 +5,7 @@ const navItems = [
{ path: '/', label: '대시보드', icon: '📊' }, { path: '/', label: '대시보드', icon: '📊' },
{ path: '/jobs', label: '작업', icon: '⚙️' }, { path: '/jobs', label: '작업', icon: '⚙️' },
{ path: '/executions', label: '실행 이력', icon: '📋' }, { path: '/executions', label: '실행 이력', icon: '📋' },
{ path: '/recollects', label: '재수집 이력', icon: '🔄' },
{ path: '/schedules', label: '스케줄', icon: '🕐' }, { path: '/schedules', label: '스케줄', icon: '🕐' },
{ path: '/schedule-timeline', label: '타임라인', icon: '📅' }, { path: '/schedule-timeline', label: '타임라인', icon: '📅' },
]; ];

파일 보기

@ -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 (
<div className={`rounded-xl p-5 text-white shadow-md ${gradient}`}>
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-white/80">{label}</p>
<p className="mt-1 text-3xl font-bold">
{value.toLocaleString()}
</p>
</div>
<span className="text-3xl opacity-80">{icon}</span>
</div>
</div>
);
}
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 (
<div className="bg-wing-surface rounded-xl shadow-md p-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between mb-4">
<div className="flex items-center gap-3">
<h3 className="text-base font-semibold text-wing-text">
{step.stepName}
</h3>
<StatusBadge status={step.status} />
</div>
<span className="text-sm text-wing-muted">
{step.duration != null
? formatDuration(step.duration)
: calculateDuration(step.startTime, step.endTime)}
</span>
</div>
<div className="grid grid-cols-2 gap-2 text-sm mb-3">
<div className="text-wing-muted">
: <span className="text-wing-text">{formatDateTime(step.startTime)}</span>
</div>
<div className="text-wing-muted">
: <span className="text-wing-text">{formatDateTime(step.endTime)}</span>
</div>
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
{stats.map(({ label, value }) => (
<div
key={label}
className="rounded-lg bg-wing-card px-3 py-2 text-center"
>
<p className="text-lg font-bold text-wing-text">
{value.toLocaleString()}
</p>
<p className="text-xs text-wing-muted">{label}</p>
</div>
))}
</div>
{/* API 호출 로그 요약 (batch_api_log 기반) */}
{summary && (
<div className="mt-4 rounded-lg bg-blue-50 border border-blue-200 p-3">
<p className="text-xs font-medium text-blue-700 mb-2">API </p>
<div className="grid grid-cols-3 sm:grid-cols-6 gap-2">
<div className="rounded bg-white px-2 py-1.5 text-center">
<p className="text-sm font-bold text-wing-text">{summary.totalCalls.toLocaleString()}</p>
<p className="text-[10px] text-wing-muted"> </p>
</div>
<div className="rounded bg-white px-2 py-1.5 text-center">
<p className="text-sm font-bold text-emerald-600">{summary.successCount.toLocaleString()}</p>
<p className="text-[10px] text-wing-muted"></p>
</div>
<div className="rounded bg-white px-2 py-1.5 text-center">
<p className={`text-sm font-bold ${summary.errorCount > 0 ? 'text-red-500' : 'text-wing-text'}`}>
{summary.errorCount.toLocaleString()}
</p>
<p className="text-[10px] text-wing-muted"></p>
</div>
<div className="rounded bg-white px-2 py-1.5 text-center">
<p className="text-sm font-bold text-blue-600">{Math.round(summary.avgResponseMs).toLocaleString()}</p>
<p className="text-[10px] text-wing-muted">(ms)</p>
</div>
<div className="rounded bg-white px-2 py-1.5 text-center">
<p className="text-sm font-bold text-red-500">{summary.maxResponseMs.toLocaleString()}</p>
<p className="text-[10px] text-wing-muted">(ms)</p>
</div>
<div className="rounded bg-white px-2 py-1.5 text-center">
<p className="text-sm font-bold text-emerald-500">{summary.minResponseMs.toLocaleString()}</p>
<p className="text-[10px] text-wing-muted">(ms)</p>
</div>
</div>
{/* 펼침/접기 개별 로그 */}
{summary.logs.length > 0 && (
<div className="mt-2">
<button
onClick={() => setLogsOpen((v) => !v)}
className="inline-flex items-center gap-1 text-xs font-medium text-blue-600 hover:text-blue-800 transition-colors"
>
<svg
className={`w-3 h-3 transition-transform ${logsOpen ? 'rotate-90' : ''}`}
fill="none" viewBox="0 0 24 24" stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
({summary.logs.length})
</button>
{logsOpen && (
<div className="mt-2 overflow-x-auto max-h-64 overflow-y-auto">
<table className="w-full text-xs text-left">
<thead className="bg-blue-100 text-blue-700 sticky top-0">
<tr>
<th className="px-2 py-1.5 font-medium">#</th>
<th className="px-2 py-1.5 font-medium">URI</th>
<th className="px-2 py-1.5 font-medium">Method</th>
<th className="px-2 py-1.5 font-medium"></th>
<th className="px-2 py-1.5 font-medium text-right">(ms)</th>
<th className="px-2 py-1.5 font-medium text-right"></th>
<th className="px-2 py-1.5 font-medium"></th>
<th className="px-2 py-1.5 font-medium"></th>
</tr>
</thead>
<tbody className="divide-y divide-blue-100">
{summary.logs.map((log, idx) => {
const isError = (log.statusCode != null && log.statusCode >= 400) || log.errorMessage;
return (
<tr
key={log.logId}
className={isError ? 'bg-red-50' : 'bg-white hover:bg-blue-50'}
>
<td className="px-2 py-1.5 text-blue-500">{idx + 1}</td>
<td className="px-2 py-1.5 font-mono text-blue-900 max-w-[200px] truncate" title={log.requestUri}>
{log.requestUri}
</td>
<td className="px-2 py-1.5 font-semibold text-blue-900">{log.httpMethod}</td>
<td className="px-2 py-1.5">
<span className={`font-semibold ${
log.statusCode == null ? 'text-gray-400'
: log.statusCode < 300 ? 'text-emerald-600'
: log.statusCode < 400 ? 'text-amber-600'
: 'text-red-600'
}`}>
{log.statusCode ?? '-'}
</span>
</td>
<td className="px-2 py-1.5 text-right text-blue-900">
{log.responseTimeMs?.toLocaleString() ?? '-'}
</td>
<td className="px-2 py-1.5 text-right text-blue-900">
{log.responseCount?.toLocaleString() ?? '-'}
</td>
<td className="px-2 py-1.5 text-blue-600 whitespace-nowrap">
{formatDateTime(log.createdAt)}
</td>
<td className="px-2 py-1.5 text-red-500 max-w-[150px] truncate" title={log.errorMessage || ''}>
{log.errorMessage || '-'}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
)}
</div>
)}
{step.exitMessage && (
<div className="mt-4 rounded-lg bg-red-50 border border-red-200 p-3">
<p className="text-xs font-medium text-red-700 mb-1">Exit Message</p>
<p className="text-xs text-red-600 whitespace-pre-wrap break-words">
{step.exitMessage}
</p>
</div>
)}
</div>
);
}
export default function RecollectDetail() {
const { id: paramId } = useParams<{ id: string }>();
const navigate = useNavigate();
const historyId = paramId ? Number(paramId) : NaN;
const [data, setData] = useState<RecollectionDetailResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(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 <LoadingSpinner />;
if (error || !data) {
return (
<div className="space-y-4">
<button
onClick={() => navigate('/recollects')}
className="inline-flex items-center gap-1 text-sm text-wing-muted hover:text-wing-text transition-colors"
>
<span>&larr;</span>
</button>
<EmptyState
icon="&#x26A0;"
message={error || '재수집 이력을 찾을 수 없습니다.'}
/>
</div>
);
}
const { history, overlappingHistories, apiStats, stepExecutions } = data;
return (
<div className="space-y-6">
{/* 상단 내비게이션 */}
<button
onClick={() => navigate(-1)}
className="inline-flex items-center gap-1 text-sm text-wing-muted hover:text-wing-text transition-colors"
>
<span>&larr;</span>
</button>
{/* 기본 정보 카드 */}
<div className="bg-wing-surface rounded-xl shadow-md p-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<h1 className="text-2xl font-bold text-wing-text">
#{history.historyId}
</h1>
<p className="mt-1 text-sm text-wing-muted">
{history.apiKeyName || history.apiKey} &middot; {history.jobName}
</p>
</div>
<div className="flex items-center gap-2">
<StatusBadge status={history.executionStatus} className="text-sm" />
{history.hasOverlap && (
<span className="inline-flex items-center px-2 py-0.5 text-xs font-medium bg-amber-100 text-amber-700 rounded-full">
</span>
)}
</div>
</div>
<div className="mt-6 grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4 text-sm">
<InfoItem label="실행자" value={history.executor || '-'} />
<InfoItem label="재수집 배치 실행일시" value={formatDateTime(history.executionStartTime)} />
<InfoItem label="재수집 배치 종료일시" value={formatDateTime(history.executionEndTime)} />
<InfoItem label="소요시간" value={formatDuration(history.durationMs)} />
<InfoItem label="재수집 사유" value={history.recollectionReason || '-'} />
{history.jobExecutionId && (
<InfoItem label="Job Execution ID" value={String(history.jobExecutionId)} />
)}
</div>
</div>
{/* 수집 기간 정보 */}
<div className="bg-wing-surface rounded-xl shadow-md p-6">
<h2 className="text-lg font-semibold text-wing-text mb-4">
</h2>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4 text-sm">
<InfoItem label="재수집 시작일시" value={formatDateTime(history.rangeFromDate)} />
<InfoItem label="재수집 종료일시" value={formatDateTime(history.rangeToDate)} />
</div>
</div>
{/* 처리 통계 카드 */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
<StatCard
label="읽기 (Read)"
value={history.readCount ?? 0}
gradient="bg-gradient-to-br from-blue-500 to-blue-600"
icon="&#x1F4E5;"
/>
<StatCard
label="쓰기 (Write)"
value={history.writeCount ?? 0}
gradient="bg-gradient-to-br from-emerald-500 to-emerald-600"
icon="&#x1F4E4;"
/>
<StatCard
label="건너뜀 (Skip)"
value={history.skipCount ?? 0}
gradient="bg-gradient-to-br from-amber-500 to-amber-600"
icon="&#x23ED;"
/>
<StatCard
label="API 호출"
value={history.apiCallCount ?? 0}
gradient="bg-gradient-to-br from-purple-500 to-purple-600"
icon="&#x1F310;"
/>
</div>
{/* API 응답시간 통계 */}
{apiStats && (
<div className="bg-wing-surface rounded-xl shadow-md p-6">
<h2 className="text-lg font-semibold text-wing-text mb-4">
API
</h2>
<div className="grid grid-cols-2 sm:grid-cols-5 gap-4">
<div className="rounded-lg bg-wing-card px-4 py-3 text-center">
<p className="text-2xl font-bold text-wing-text">
{apiStats.callCount.toLocaleString()}
</p>
<p className="text-xs text-wing-muted mt-1"> </p>
</div>
<div className="rounded-lg bg-wing-card px-4 py-3 text-center">
<p className="text-2xl font-bold text-wing-text">
{apiStats.totalMs.toLocaleString()}
</p>
<p className="text-xs text-wing-muted mt-1"> (ms)</p>
</div>
<div className="rounded-lg bg-wing-card px-4 py-3 text-center">
<p className="text-2xl font-bold text-blue-600">
{Math.round(apiStats.avgMs).toLocaleString()}
</p>
<p className="text-xs text-wing-muted mt-1">(ms)</p>
</div>
<div className="rounded-lg bg-wing-card px-4 py-3 text-center">
<p className="text-2xl font-bold text-red-500">
{apiStats.maxMs.toLocaleString()}
</p>
<p className="text-xs text-wing-muted mt-1">(ms)</p>
</div>
<div className="rounded-lg bg-wing-card px-4 py-3 text-center">
<p className="text-2xl font-bold text-emerald-500">
{apiStats.minMs.toLocaleString()}
</p>
<p className="text-xs text-wing-muted mt-1">(ms)</p>
</div>
</div>
</div>
)}
{/* 실패 사유 */}
{history.executionStatus === 'FAILED' && history.failureReason && (
<div className="bg-wing-surface rounded-xl shadow-md p-6">
<h2 className="text-lg font-semibold text-red-600 mb-3">
</h2>
<pre className="text-sm text-wing-text font-mono bg-red-50 border border-red-200 px-4 py-3 rounded-lg whitespace-pre-wrap break-words max-h-64 overflow-y-auto">
{history.failureReason}
</pre>
</div>
)}
{/* 기간 중복 이력 */}
{overlappingHistories.length > 0 && (
<div className="bg-wing-surface rounded-xl shadow-md p-6">
<h2 className="text-lg font-semibold text-amber-600 mb-4">
<span className="ml-2 text-sm font-normal text-wing-muted">
({overlappingHistories.length})
</span>
</h2>
<div className="overflow-x-auto">
<table className="w-full text-sm text-left">
<thead className="bg-wing-card text-xs uppercase text-wing-muted">
<tr>
<th className="px-4 py-3 font-medium"> ID</th>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"> </th>
<th className="px-4 py-3 font-medium"> </th>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th>
</tr>
</thead>
<tbody className="divide-y divide-wing-border/50">
{overlappingHistories.map((oh) => (
<tr
key={oh.historyId}
className="hover:bg-wing-hover transition-colors cursor-pointer"
onClick={() => navigate(`/recollects/${oh.historyId}`)}
>
<td className="px-4 py-3 font-mono text-wing-text">
#{oh.historyId}
</td>
<td className="px-4 py-3 text-wing-text">
{oh.apiKeyName || oh.apiKey}
</td>
<td className="px-4 py-3 text-wing-muted text-xs">
{formatDateTime(oh.rangeFromDate)}
</td>
<td className="px-4 py-3 text-wing-muted text-xs">
{formatDateTime(oh.rangeToDate)}
</td>
<td className="px-4 py-3">
<StatusBadge status={oh.executionStatus} />
</td>
<td className="px-4 py-3 text-wing-muted">
{oh.executor || '-'}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Step 실행 정보 */}
<div>
<h2 className="text-lg font-semibold text-wing-text mb-4">
Step
<span className="ml-2 text-sm font-normal text-wing-muted">
({stepExecutions.length})
</span>
</h2>
{stepExecutions.length === 0 ? (
<EmptyState message="Step 실행 정보가 없습니다." />
) : (
<div className="space-y-4">
{stepExecutions.map((step) => (
<StepCard key={step.stepExecutionId} step={step} />
))}
</div>
)}
</div>
</div>
);
}
function InfoItem({ label, value }: { label: string; value: string }) {
return (
<div>
<dt className="text-xs font-medium text-wing-muted uppercase tracking-wide">
{label}
</dt>
<dd className="mt-1 text-sm text-wing-text break-words">{value || '-'}</dd>
</div>
);
}

파일 보기

@ -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<CollectionPeriodDto[]>([]);
const [histories, setHistories] = useState<RecollectionHistoryDto[]>([]);
const [selectedApiKey, setSelectedApiKey] = useState('');
const [apiDropdownOpen, setApiDropdownOpen] = useState(false);
const [statusFilter, setStatusFilter] = useState<StatusFilter>('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<RecollectionHistoryDto | null>(null);
// 수집 기간 관리 패널
const [periodPanelOpen, setPeriodPanelOpen] = useState(false);
const [selectedPeriodKey, setSelectedPeriodKey] = useState<string>('');
const [periodDropdownOpen, setPeriodDropdownOpen] = useState(false);
const [periodEdits, setPeriodEdits] = useState<Record<string, PeriodEdit>>({});
const [savingApiKey, setSavingApiKey] = useState<string | null>(null);
const [executingApiKey, setExecutingApiKey] = useState<string | null>(null);
const [manualToDate, setManualToDate] = useState<Record<string, boolean>>({});
const [selectedDuration, setSelectedDuration] = useState<Record<string, number | null>>({});
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 (
<div className="space-y-6">
{/* 헤더 */}
<div>
<h1 className="text-2xl font-bold text-wing-text"> </h1>
<p className="mt-1 text-sm text-wing-muted">
.
</p>
</div>
{/* 수집 기간 관리 패널 */}
<div className="bg-wing-surface rounded-xl shadow-md">
<button
onClick={() => setPeriodPanelOpen((v) => !v)}
className="w-full flex items-center justify-between px-6 py-4 hover:bg-wing-hover transition-colors rounded-xl"
>
<div className="flex items-center gap-2">
<svg
className={`w-4 h-4 text-wing-muted transition-transform ${periodPanelOpen ? 'rotate-90' : ''}`}
fill="none" viewBox="0 0 24 24" stroke="currentColor"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 5l7 7-7 7" />
</svg>
<span className="text-sm font-semibold text-wing-text"> </span>
<span className="text-xs text-wing-muted">({periods.length})</span>
</div>
<span className="text-xs text-wing-muted">
{periodPanelOpen ? '접기' : '펼치기'}
</span>
</button>
{periodPanelOpen && (
<div className="border-t border-wing-border/50 px-6 py-4 space-y-4">
{periods.length === 0 ? (
<div className="py-4 text-center text-sm text-wing-muted">
.
</div>
) : (
<>
{/* 작업 선택 드롭다운 */}
<div className="flex items-center gap-3">
<label className="text-sm font-medium text-wing-text shrink-0">
</label>
<div className="relative">
<button
onClick={() => setPeriodDropdownOpen((v) => !v)}
className="inline-flex items-center gap-1.5 px-3 py-2 text-sm rounded-lg border border-wing-border bg-wing-surface hover:bg-wing-hover transition-colors min-w-[200px] justify-between"
>
<span className={selectedPeriodKey ? 'text-wing-text' : 'text-wing-muted'}>
{selectedPeriodKey
? (periods.find((p) => p.apiKey === selectedPeriodKey)?.apiKeyName || selectedPeriodKey)
: '작업을 선택하세요'}
</span>
<svg className={`w-4 h-4 text-wing-muted transition-transform ${periodDropdownOpen ? 'rotate-180' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{periodDropdownOpen && (
<>
<div className="fixed inset-0 z-10" onClick={() => setPeriodDropdownOpen(false)} />
<div className="absolute z-20 mt-1 w-72 max-h-60 overflow-y-auto bg-wing-surface border border-wing-border rounded-lg shadow-lg">
{periods.map((p) => (
<button
key={p.apiKey}
onClick={() => {
setSelectedPeriodKey(p.apiKey);
setPeriodDropdownOpen(false);
}}
className={`w-full text-left px-3 py-2 text-sm hover:bg-wing-hover transition-colors ${
selectedPeriodKey === p.apiKey
? 'bg-wing-accent/10 text-wing-accent font-medium'
: 'text-wing-text'
}`}
>
<div>{p.apiKeyName || p.apiKey}</div>
{p.jobName && (
<div className="text-xs text-wing-muted font-mono">{p.jobName}</div>
)}
</button>
))}
</div>
</>
)}
</div>
</div>
{/* 선택된 작업의 기간 편집 */}
{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 (
<div className="bg-wing-card rounded-lg p-4 space-y-3">
<div className="flex items-center gap-4 text-sm">
<span className="text-wing-muted">:</span>
<span className="font-mono text-xs text-wing-text">{p.jobName || '-'}</span>
</div>
{/* Line 1: 재수집 시작일시 */}
<div>
<label className="block text-xs font-medium text-wing-muted mb-1"> </label>
<div className="flex items-center gap-1">
<input
type="date"
value={edit.fromDate}
onChange={(e) => 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"
/>
<input
type="time"
value={edit.fromTime}
onChange={(e) => 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"
/>
</div>
</div>
{/* Line 2: 기간 선택 버튼 + 직접입력 토글 */}
<div className="flex items-center gap-2 flex-wrap">
{DURATION_PRESETS.map(({ label, hours }) => (
<button
key={hours}
onClick={() => {
setManualToDate((prev) => ({ ...prev, [p.apiKey]: false }));
applyDurationPreset(p.apiKey, hours);
}}
className={`px-3 py-1.5 text-xs font-medium rounded-lg border transition-colors ${
!isManual && activeDur === hours
? 'bg-wing-accent text-white border-wing-accent shadow-sm'
: 'bg-wing-surface text-wing-muted border-wing-border hover:bg-wing-hover'
}`}
>
{label}
</button>
))}
<div className="ml-auto flex items-center gap-1.5">
<span className="text-xs text-wing-muted"></span>
<button
onClick={() => {
const next = !isManual;
setManualToDate((prev) => ({ ...prev, [p.apiKey]: next }));
if (next) {
setSelectedDuration((prev) => ({ ...prev, [p.apiKey]: null }));
}
}}
className={`relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors ${
isManual ? 'bg-wing-accent' : 'bg-wing-border'
}`}
>
<span
className={`pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow-sm transition-transform ${
isManual ? 'translate-x-4' : 'translate-x-0'
}`}
/>
</button>
</div>
</div>
{/* Line 3: 재수집 종료일시 */}
<div>
<label className="block text-xs font-medium text-wing-muted mb-1"> </label>
<div className="flex items-center gap-1">
<input
type="date"
value={edit.toDate}
disabled={!isManual}
onChange={(e) => 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'
}`}
/>
<input
type="time"
value={edit.toTime}
disabled={!isManual}
onChange={(e) => 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'
}`}
/>
</div>
</div>
<div className="flex items-center justify-end gap-2 pt-1">
<button
onClick={() => handleResetPeriod(p)}
disabled={isSaving}
className="px-4 py-2 text-sm font-medium rounded-lg text-red-600 bg-red-50 hover:bg-red-100 border border-red-200 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
</button>
<button
onClick={() => handleSavePeriod(p)}
disabled={isSaving || !hasChange}
className={`px-4 py-2 text-sm font-medium rounded-lg transition-colors ${
hasChange
? 'text-white bg-wing-accent hover:bg-wing-accent/80 shadow-sm'
: 'text-wing-muted bg-wing-surface border border-wing-border cursor-not-allowed'
}`}
>
{isSaving ? '저장중...' : '기간 저장'}
</button>
<button
onClick={() => handleExecuteRecollect(p)}
disabled={isExecuting || !p.jobName}
className="px-4 py-2 text-sm font-medium rounded-lg text-emerald-700 bg-emerald-50 hover:bg-emerald-100 border border-emerald-200 transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
{isExecuting ? '실행중...' : '재수집 실행'}
</button>
</div>
</div>
);
})()}
</>
)}
</div>
)}
</div>
{/* 필터 영역 */}
<div className="bg-wing-surface rounded-xl shadow-md p-6">
<div className="space-y-3">
{/* API 선택 */}
<div>
<div className="flex items-center gap-3 mb-2">
<label className="text-sm font-medium text-wing-text shrink-0">
</label>
<div className="relative">
<button
onClick={() => setApiDropdownOpen((v) => !v)}
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-lg border border-wing-border bg-wing-surface hover:bg-wing-hover transition-colors"
>
{selectedApiKey
? getApiLabel(selectedApiKey)
: '전체'}
<svg className={`w-4 h-4 text-wing-muted transition-transform ${apiDropdownOpen ? 'rotate-180' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /></svg>
</button>
{apiDropdownOpen && (
<>
<div className="fixed inset-0 z-10" onClick={() => setApiDropdownOpen(false)} />
<div className="absolute z-20 mt-1 w-72 max-h-60 overflow-y-auto bg-wing-surface border border-wing-border rounded-lg shadow-lg">
<button
onClick={() => { setSelectedApiKey(''); setApiDropdownOpen(false); setPage(0); setLoading(true); }}
className={`w-full text-left px-3 py-2 text-sm hover:bg-wing-hover transition-colors ${
!selectedApiKey ? 'bg-wing-accent/10 text-wing-accent font-medium' : 'text-wing-text'
}`}
>
</button>
{periods.map((p) => (
<button
key={p.apiKey}
onClick={() => { setSelectedApiKey(p.apiKey); setApiDropdownOpen(false); setPage(0); setLoading(true); }}
className={`w-full text-left px-3 py-2 text-sm hover:bg-wing-hover transition-colors ${
selectedApiKey === p.apiKey ? 'bg-wing-accent/10 text-wing-accent font-medium' : 'text-wing-text'
}`}
>
{p.apiKeyName || p.apiKey}
</button>
))}
</div>
</>
)}
</div>
{selectedApiKey && (
<button
onClick={() => { setSelectedApiKey(''); setPage(0); setLoading(true); }}
className="text-xs text-wing-muted hover:text-wing-accent transition-colors"
>
</button>
)}
</div>
{selectedApiKey && (
<div className="flex flex-wrap gap-1.5">
<span className="inline-flex items-center gap-1 px-2.5 py-1 text-xs font-medium bg-wing-accent/15 text-wing-accent rounded-full">
{getApiLabel(selectedApiKey)}
<button
onClick={() => { setSelectedApiKey(''); setPage(0); setLoading(true); }}
className="hover:text-wing-text transition-colors"
>
&times;
</button>
</span>
</div>
)}
</div>
{/* 상태 필터 버튼 그룹 */}
<div className="flex flex-wrap gap-1">
{STATUS_FILTERS.map(({ value, label }) => (
<button
key={value}
onClick={() => {
setStatusFilter(value);
setPage(0);
setLoading(true);
}}
className={`px-3 py-1.5 text-xs font-medium rounded-lg transition-colors ${
statusFilter === value
? 'bg-wing-accent text-white shadow-sm'
: 'bg-wing-card text-wing-muted hover:bg-wing-hover'
}`}
>
{label}
</button>
))}
</div>
</div>
{/* 날짜 범위 필터 */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-center mt-4 pt-4 border-t border-wing-border/50">
<label className="text-sm font-medium text-wing-text shrink-0">
</label>
<div className="flex items-center gap-2">
<input
type="date"
value={startDate}
onChange={(e) => 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"
/>
<span className="text-wing-muted text-sm">~</span>
<input
type="date"
value={endDate}
onChange={(e) => 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"
/>
</div>
<div className="flex items-center gap-2">
<button
onClick={handleSearch}
className="px-4 py-2 text-sm font-medium text-white bg-wing-accent rounded-lg hover:bg-wing-accent/80 transition-colors shadow-sm"
>
</button>
{useSearch && (
<button
onClick={handleResetSearch}
className="px-4 py-2 text-sm font-medium text-wing-text bg-wing-card rounded-lg hover:bg-wing-hover transition-colors"
>
</button>
)}
</div>
</div>
</div>
{/* 재수집 이력 테이블 */}
<div className="bg-wing-surface rounded-xl shadow-md overflow-hidden">
{loading ? (
<LoadingSpinner />
) : filteredHistories.length === 0 ? (
<EmptyState
message="재수집 이력이 없습니다."
sub={
statusFilter !== 'ALL'
? '다른 상태 필터를 선택해 보세요.'
: selectedApiKey
? '선택한 API의 재수집 이력이 없습니다.'
: undefined
}
/>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm text-left">
<thead className="bg-wing-card text-xs uppercase text-wing-muted">
<tr>
<th className="px-4 py-3 font-medium"> ID</th>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium"> </th>
<th className="px-4 py-3 font-medium"> </th>
<th className="px-4 py-3 font-medium"></th>
<th className="px-4 py-3 font-medium text-right"></th>
</tr>
</thead>
<tbody className="divide-y divide-wing-border/50">
{filteredHistories.map((hist) => (
<tr
key={hist.historyId}
className="hover:bg-wing-hover transition-colors"
>
<td className="px-4 py-4 font-mono text-wing-text">
#{hist.historyId}
</td>
<td className="px-4 py-4 text-wing-text">
<div className="max-w-[120px] truncate" title={hist.apiKeyName || hist.apiKey}>
{hist.apiKeyName || hist.apiKey}
</div>
</td>
<td className="px-4 py-4">
{hist.executionStatus === 'FAILED' ? (
<button
onClick={() => setFailLogTarget(hist)}
className="cursor-pointer"
title="클릭하여 실패 로그 확인"
>
<StatusBadge status={hist.executionStatus} />
</button>
) : (
<StatusBadge status={hist.executionStatus} />
)}
{hist.hasOverlap && (
<span className="ml-1 text-xs text-amber-500" title="기간 중복 감지">
!</span>
)}
</td>
<td className="px-4 py-4 text-wing-muted whitespace-nowrap text-xs">
<div>{formatDateTime(hist.rangeFromDate)}</div>
</td>
<td className="px-4 py-4 text-wing-muted whitespace-nowrap text-xs">
<div>{formatDateTime(hist.rangeToDate)}</div>
</td>
<td className="px-4 py-4 text-wing-muted whitespace-nowrap">
{formatDuration(hist.durationMs)}
</td>
<td className="px-4 py-4 text-right">
<button
onClick={() => navigate(`/recollects/${hist.historyId}`)}
className="inline-flex items-center gap-1 px-3 py-1.5 text-xs font-medium text-wing-accent bg-wing-accent/10 rounded-lg hover:bg-wing-accent/15 transition-colors"
>
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{/* 결과 건수 + 페이지네이션 */}
{!loading && filteredHistories.length > 0 && (
<div className="px-6 py-3 bg-wing-card border-t border-wing-border/50 flex items-center justify-between">
<div className="text-xs text-wing-muted">
{totalCount}
</div>
{totalPages > 1 && (
<div className="flex items-center gap-2">
<button
onClick={() => handlePageChange(page - 1)}
disabled={page === 0}
className="px-3 py-1.5 text-xs font-medium rounded-lg transition-colors disabled:opacity-40 disabled:cursor-not-allowed bg-wing-card text-wing-muted hover:bg-wing-hover"
>
</button>
<span className="text-xs text-wing-muted">
{page + 1} / {totalPages}
</span>
<button
onClick={() => handlePageChange(page + 1)}
disabled={page >= totalPages - 1}
className="px-3 py-1.5 text-xs font-medium rounded-lg transition-colors disabled:opacity-40 disabled:cursor-not-allowed bg-wing-card text-wing-muted hover:bg-wing-hover"
>
</button>
</div>
)}
</div>
)}
</div>
{/* 실패 로그 뷰어 모달 */}
<InfoModal
open={failLogTarget !== null}
title={
failLogTarget
? `실패 로그 - #${failLogTarget.historyId} (${failLogTarget.jobName})`
: '실패 로그'
}
onClose={() => setFailLogTarget(null)}
>
{failLogTarget && (
<div className="space-y-4">
<div>
<h4 className="text-xs font-semibold text-wing-muted uppercase mb-1">
</h4>
<p className="text-sm text-wing-text font-mono bg-wing-card px-3 py-2 rounded-lg">
{failLogTarget.executionStatus}
</p>
</div>
<div>
<h4 className="text-xs font-semibold text-wing-muted uppercase mb-1">
</h4>
<pre className="text-sm text-wing-text font-mono bg-wing-card px-3 py-2 rounded-lg whitespace-pre-wrap break-words max-h-64 overflow-y-auto">
{failLogTarget.failureReason || '실패 사유 없음'}
</pre>
</div>
</div>
)}
</InfoModal>
</div>
);
}

6
package-lock.json generated Normal file
파일 보기

@ -0,0 +1,6 @@
{
"name": "snp-batch-validation",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}

파일 보기

@ -143,6 +143,12 @@
<artifactId>spring-batch-test</artifactId> <artifactId>spring-batch-test</artifactId>
<scope>test</scope> <scope>test</scope>
</dependency> </dependency>
<dependency>
<groupId>com.google.code.findbugs</groupId>
<artifactId>jsr305</artifactId>
<version>3.0.2</version>
</dependency>
</dependencies> </dependencies>
<build> <build>

파일 보기

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

파일 보기

@ -1,7 +1,10 @@
package com.snp.batch.global.controller; package com.snp.batch.global.controller;
import com.snp.batch.global.dto.*; 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.BatchService;
import com.snp.batch.service.RecollectionHistoryService;
import com.snp.batch.service.ScheduleService; import com.snp.batch.service.ScheduleService;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter; 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.http.ResponseEntity;
import org.springframework.web.bind.annotation.*; 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.LocalDateTime;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.util.HashMap; import java.util.HashMap;
@ -32,6 +38,7 @@ public class BatchController {
private final BatchService batchService; private final BatchService batchService;
private final ScheduleService scheduleService; private final ScheduleService scheduleService;
private final RecollectionHistoryService recollectionHistoryService;
@Operation(summary = "배치 작업 실행", description = "지정된 배치 작업을 즉시 실행합니다. 쿼리 파라미터로 Job Parameters 전달 가능") @Operation(summary = "배치 작업 실행", description = "지정된 배치 작업을 즉시 실행합니다. 쿼리 파라미터로 Job Parameters 전달 가능")
@ApiResponses(value = { @ApiResponses(value = {
@ -453,4 +460,120 @@ public class BatchController {
ExecutionStatisticsDto stats = batchService.getJobStatistics(jobName, days); ExecutionStatisticsDto stats = batchService.getJobStatistics(jobName, days);
return ResponseEntity.ok(stats); return ResponseEntity.ok(stats);
} }
// 재수집 이력 관리 API
@Operation(summary = "재수집 이력 목록 조회", description = "필터 조건으로 재수집 이력을 페이징 조회합니다")
@GetMapping("/recollection-histories")
public ResponseEntity<Map<String, Object>> 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<BatchRecollectionHistory> histories = recollectionHistoryService
.getHistories(apiKey, jobName, status, from, to, PageRequest.of(page, size));
Map<String, Object> 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<Map<String, Object>> getRecollectionHistoryDetail(
@Parameter(description = "이력 ID") @PathVariable Long historyId) {
log.debug("Get recollection history detail: historyId={}", historyId);
try {
Map<String, Object> 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<Map<String, Object>> getRecollectionHistoryStats() {
log.debug("Get recollection history stats");
Map<String, Object> stats = recollectionHistoryService.getHistoryStats();
stats.put("recentHistories", recollectionHistoryService.getRecentHistories());
return ResponseEntity.ok(stats);
}
// 수집 기간 관리 API
@Operation(summary = "수집 기간 목록 조회", description = "모든 API의 수집 기간 설정을 조회합니다")
@GetMapping("/collection-periods")
public ResponseEntity<List<BatchCollectionPeriod>> getCollectionPeriods() {
log.debug("Get all collection periods");
return ResponseEntity.ok(recollectionHistoryService.getAllCollectionPeriods());
}
@Operation(summary = "수집 기간 수정", description = "특정 API의 수집 기간을 수정합니다")
@PutMapping("/collection-periods/{apiKey}")
public ResponseEntity<Map<String, Object>> updateCollectionPeriod(
@Parameter(description = "API Key") @PathVariable String apiKey,
@RequestBody Map<String, String> 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<Map<String, Object>> 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()));
}
}
} }

파일 보기

@ -68,11 +68,12 @@ public class JobExecutionDetailDto {
private String exitCode; private String exitCode;
private String exitMessage; private String exitMessage;
private Long duration; // 실행 시간 (ms) 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 @Data
@Builder @Builder
@ -86,4 +87,41 @@ public class JobExecutionDetailDto {
private Integer completedCalls; // 완료된 API 호출 횟수 private Integer completedCalls; // 완료된 API 호출 횟수
private String lastCallTime; // 마지막 호출 시간 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<ApiLogEntryDto> 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;
}
} }

파일 보기

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

파일 보기

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

파일 보기

@ -2,9 +2,45 @@ package com.snp.batch.global.repository;
import com.snp.batch.global.model.BatchApiLog; import com.snp.batch.global.model.BatchApiLog;
import org.springframework.data.jpa.repository.JpaRepository; 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 org.springframework.stereotype.Repository;
import java.util.List;
@Repository @Repository
public interface BatchApiLogRepository extends JpaRepository<BatchApiLog, Long> { public interface BatchApiLogRepository extends JpaRepository<BatchApiLog, Long> {
@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<Object[]> 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<Object[]> getApiStatsByStepExecutionId(@Param("stepExecutionId") Long stepExecutionId);
/**
* Step별 개별 API 호출 로그 목록
*/
List<BatchApiLog> findByStepExecutionIdOrderByCreatedAtAsc(Long stepExecutionId);
} }

파일 보기

@ -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<BatchCollectionPeriod, String> {
List<BatchCollectionPeriod> findAllByOrderByOrderSeqAsc();
}

파일 보기

@ -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<BatchRecollectionHistory, Long>,
JpaSpecificationExecutor<BatchRecollectionHistory> {
Optional<BatchRecollectionHistory> findByJobExecutionId(Long jobExecutionId);
List<BatchRecollectionHistory> 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<BatchRecollectionHistory> findOverlappingHistories(
@Param("apiKey") String apiKey,
@Param("fromDate") LocalDateTime fromDate,
@Param("toDate") LocalDateTime toDate,
@Param("excludeId") Long excludeId);
long countByExecutionStatus(String executionStatus);
long countByHasOverlapTrue();
}

파일 보기

@ -1,7 +1,12 @@
package com.snp.batch.service; 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 com.snp.batch.global.repository.BatchLastExecutionRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; 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 org.springframework.stereotype.Service;
import java.time.LocalDateTime; import java.time.LocalDateTime;
@ -10,35 +15,61 @@ import java.time.ZoneOffset;
import java.time.format.DateTimeFormatter; import java.time.format.DateTimeFormatter;
import java.util.HashMap; import java.util.HashMap;
import java.util.Map; import java.util.Map;
import java.util.Optional;
@Slf4j @Slf4j
@Service @Service
@RequiredArgsConstructor
public class BatchDateService { public class BatchDateService {
private final BatchLastExecutionRepository repository;
public BatchDateService(BatchLastExecutionRepository repository) { private final BatchLastExecutionRepository repository;
this.repository = 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<String, String> getDateRangeWithoutTimeParams(String apiKey) { public Map<String, String> getDateRangeWithoutTimeParams(String apiKey) {
// 재수집 모드: batch_collection_period에서 날짜 조회
if ("RECOLLECT".equals(getExecutionMode())) {
return getCollectionPeriodDateParams(apiKey);
}
// 정상 모드: last_success_date ~ now()
return repository.findDateRangeByApiKey(apiKey) return repository.findDateRangeByApiKey(apiKey)
.map(projection -> { .map(projection -> {
Map<String, String> params = new HashMap<>(); Map<String, String> params = new HashMap<>();
putDateParams(params, "from", projection.getLastSuccessDate());
LocalDateTime fromTarget = (projection.getRangeFromDate() != null) putDateParams(params, "to", LocalDateTime.now());
? projection.getRangeFromDate()
: projection.getLastSuccessDate();
LocalDateTime toTarget = (projection.getRangeToDate() != null)
? projection.getRangeToDate()
: LocalDateTime.now();
// 2. 파라미터 맵에 날짜 정보 매핑
putDateParams(params, "from", fromTarget);
putDateParams(params, "to", toTarget);
// 3. 고정 설정
params.put("shipsCategory", "0"); params.put("shipsCategory", "0");
return params; return params;
}) })
.orElseGet(() -> { .orElseGet(() -> {
@ -47,6 +78,80 @@ public class BatchDateService {
}); });
} }
public Map<String, String> getDateRangeWithTimezoneParams(String apiKey) {
return getDateRangeWithTimezoneParams(apiKey, "fromDate", "toDate");
}
public Map<String, String> 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<String, String> 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<String, String> getCollectionPeriodDateParams(String apiKey) {
String recollectApiKey = getRecollectApiKey();
String lookupKey = recollectApiKey != null ? recollectApiKey : apiKey;
Optional<BatchCollectionPeriod> opt = collectionPeriodRepository.findById(lookupKey);
if (opt.isEmpty()) {
log.warn("[RECOLLECT] 수집기간 설정을 찾을 수 없습니다: {}", lookupKey);
return new HashMap<>();
}
BatchCollectionPeriod cp = opt.get();
Map<String, String> 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<String, String> getCollectionPeriodTimezoneParams(
String apiKey, String dateParam1, String dateParam2, DateTimeFormatter formatter) {
String recollectApiKey = getRecollectApiKey();
String lookupKey = recollectApiKey != null ? recollectApiKey : apiKey;
Optional<BatchCollectionPeriod> opt = collectionPeriodRepository.findById(lookupKey);
if (opt.isEmpty()) {
log.warn("[RECOLLECT] 수집기간 설정을 찾을 수 없습니다: {}", lookupKey);
return new HashMap<>();
}
BatchCollectionPeriod cp = opt.get();
Map<String, String> 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에 담는 헬퍼 메소드 * LocalDateTime에서 , , 일을 추출하여 Map에 담는 헬퍼 메소드
*/ */
@ -58,63 +163,13 @@ public class BatchDateService {
} }
} }
public Map<String, String> getDateRangeWithTimezoneParams(String apiKey) { /**
return repository.findDateRangeByApiKey(apiKey) * 한국 시간(LocalDateTime) UTC 문자열로 변환
.map(projection -> { */
Map<String, String> 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<String, String> getDateRangeWithTimezoneParams(String apiKey, String dateParam1, String dateParam2) {
return repository.findDateRangeByApiKey(apiKey)
.map(projection -> {
Map<String, String> 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 문자열로 변환하는 로직
private String formatToUtc(LocalDateTime localDateTime, DateTimeFormatter formatter) { private String formatToUtc(LocalDateTime localDateTime, DateTimeFormatter formatter) {
if (localDateTime == null) return null; if (localDateTime == null) return null;
// 1. 한국 시간대(KST)임을 명시
// 2. UTC로 시간대를 변경 (9시간 빠짐)
// 3. 포맷팅 (끝에 Z가 자동으로 붙음)
return localDateTime.atZone(ZoneId.of("Asia/Seoul")) return localDateTime.atZone(ZoneId.of("Asia/Seoul"))
.withZoneSameInstant(ZoneOffset.UTC) .withZoneSameInstant(ZoneOffset.UTC)
.format(formatter); .format(formatter);
} }
} }

파일 보기

@ -1,7 +1,9 @@
package com.snp.batch.service; package com.snp.batch.service;
import com.snp.batch.common.batch.listener.RecollectionJobExecutionListener;
import com.snp.batch.global.dto.*; import com.snp.batch.global.dto.*;
import com.snp.batch.global.repository.TimelineRepository; import com.snp.batch.global.repository.TimelineRepository;
import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
import org.springframework.batch.core.Job; import org.springframework.batch.core.Job;
import org.springframework.batch.core.JobExecution; 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.JobParameters;
import org.springframework.batch.core.JobParametersBuilder; import org.springframework.batch.core.JobParametersBuilder;
import org.springframework.batch.core.explore.JobExplorer; 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.JobLauncher;
import org.springframework.batch.core.launch.JobOperator; import org.springframework.batch.core.launch.JobOperator;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
@ -31,6 +34,7 @@ public class BatchService {
private final Map<String, Job> jobMap; private final Map<String, Job> jobMap;
private final ScheduleService scheduleService; private final ScheduleService scheduleService;
private final TimelineRepository timelineRepository; private final TimelineRepository timelineRepository;
private final RecollectionJobExecutionListener recollectionJobExecutionListener;
@Autowired @Autowired
public BatchService(JobLauncher jobLauncher, public BatchService(JobLauncher jobLauncher,
@ -38,13 +42,29 @@ public class BatchService {
JobOperator jobOperator, JobOperator jobOperator,
Map<String, Job> jobMap, Map<String, Job> jobMap,
@Lazy ScheduleService scheduleService, @Lazy ScheduleService scheduleService,
TimelineRepository timelineRepository) { TimelineRepository timelineRepository,
RecollectionJobExecutionListener recollectionJobExecutionListener) {
this.jobLauncher = jobLauncher; this.jobLauncher = jobLauncher;
this.jobExplorer = jobExplorer; this.jobExplorer = jobExplorer;
this.jobOperator = jobOperator; this.jobOperator = jobOperator;
this.jobMap = jobMap; this.jobMap = jobMap;
this.scheduleService = scheduleService; this.scheduleService = scheduleService;
this.timelineRepository = timelineRepository; 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 { public Long executeJob(String jobName) throws Exception {

파일 보기

@ -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<BatchCollectionPeriod> 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<BatchRecollectionHistory> 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<BatchRecollectionHistory> 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<BatchRecollectionHistory> getHistories(
String apiKey, String jobName, String status,
LocalDateTime from, LocalDateTime to,
Pageable pageable) {
Specification<BatchRecollectionHistory> spec = (root, query, cb) -> {
List<Predicate> 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<String, Object> getHistoryDetail(Long historyId) {
BatchRecollectionHistory history = historyRepository.findById(historyId)
.orElseThrow(() -> new IllegalArgumentException("이력을 찾을 수 없습니다: " + historyId));
// 중복 이력 실시간 재검사
List<BatchRecollectionHistory> currentOverlaps = historyRepository
.findOverlappingHistories(history.getApiKey(),
history.getRangeFromDate(), history.getRangeToDate(),
history.getHistoryId());
// API 응답시간 통계
Map<String, Object> apiStats = null;
if (history.getJobExecutionId() != null) {
apiStats = getApiStats(history.getJobExecutionId());
}
Map<String, Object> 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<String, Object> getHistoryDetailWithSteps(Long historyId) {
BatchRecollectionHistory history = historyRepository.findById(historyId)
.orElseThrow(() -> new IllegalArgumentException("이력을 찾을 수 없습니다: " + historyId));
// 중복 이력 실시간 재검사
List<BatchRecollectionHistory> currentOverlaps = historyRepository
.findOverlappingHistories(history.getApiKey(),
history.getRangeFromDate(), history.getRangeToDate(),
history.getHistoryId());
// API 응답시간 통계
Map<String, Object> 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<JobExecutionDetailDto.StepExecutionDto> 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<String, Object> 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<Object[]> 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<BatchApiLog> logs = apiLogRepository
.findByStepExecutionIdOrderByCreatedAtAsc(stepExecutionId);
List<JobExecutionDetailDto.ApiLogEntryDto> 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<BatchRecollectionHistory> getRecentHistories() {
return historyRepository.findTop10ByOrderByCreatedAtDesc();
}
/**
* 통계 조회
*/
@Transactional(readOnly = true)
public Map<String, Object> getHistoryStats() {
Map<String, Object> 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<String, Object> getApiStats(Long jobExecutionId) {
List<Object[]> results = apiLogRepository.getApiStatsByJobExecutionId(jobExecutionId);
if (results.isEmpty() || results.get(0) == null) {
return null;
}
Object[] row = results.get(0);
Map<String, Object> 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<BatchCollectionPeriod> 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);
});
}
}