feat: 재수집 기간 관리 및 재수집 이력 프로세스 개발
This commit is contained in:
부모
8755a92f34
커밋
f1af7f60b2
@ -10,6 +10,8 @@ const Dashboard = lazy(() => import('./pages/Dashboard'));
|
||||
const Jobs = lazy(() => import('./pages/Jobs'));
|
||||
const Executions = lazy(() => import('./pages/Executions'));
|
||||
const ExecutionDetail = lazy(() => import('./pages/ExecutionDetail'));
|
||||
const Recollects = lazy(() => import('./pages/Recollects'));
|
||||
const RecollectDetail = lazy(() => import('./pages/RecollectDetail'));
|
||||
const Schedules = lazy(() => import('./pages/Schedules'));
|
||||
const Timeline = lazy(() => import('./pages/Timeline'));
|
||||
|
||||
@ -26,6 +28,8 @@ function AppLayout() {
|
||||
<Route path="/jobs" element={<Jobs />} />
|
||||
<Route path="/executions" element={<Executions />} />
|
||||
<Route path="/executions/:id" element={<ExecutionDetail />} />
|
||||
<Route path="/recollects" element={<Recollects />} />
|
||||
<Route path="/recollects/:id" element={<RecollectDetail />} />
|
||||
<Route path="/schedules" element={<Schedules />} />
|
||||
<Route path="/schedule-timeline" element={<Timeline />} />
|
||||
</Routes>
|
||||
|
||||
@ -102,6 +102,30 @@ export interface StepExecutionDto {
|
||||
exitMessage: string | null;
|
||||
duration: number | null;
|
||||
apiCallInfo: ApiCallInfo | null;
|
||||
apiLogSummary: StepApiLogSummary | null;
|
||||
}
|
||||
|
||||
export interface ApiLogEntryDto {
|
||||
logId: number;
|
||||
requestUri: string;
|
||||
httpMethod: string;
|
||||
statusCode: number | null;
|
||||
responseTimeMs: number | null;
|
||||
responseCount: number | null;
|
||||
errorMessage: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface StepApiLogSummary {
|
||||
totalCalls: number;
|
||||
successCount: number;
|
||||
errorCount: number;
|
||||
avgResponseMs: number;
|
||||
maxResponseMs: number;
|
||||
minResponseMs: number;
|
||||
totalResponseMs: number;
|
||||
totalRecordCount: number;
|
||||
logs: ApiLogEntryDto[];
|
||||
}
|
||||
|
||||
export interface JobExecutionDetailDto {
|
||||
@ -212,6 +236,73 @@ export interface ExecutionStatisticsDto {
|
||||
avgDurationMs: number;
|
||||
}
|
||||
|
||||
// ── Recollection History ─────────────────────────────────────
|
||||
|
||||
export interface RecollectionHistoryDto {
|
||||
historyId: number;
|
||||
apiKey: string;
|
||||
apiKeyName: string | null;
|
||||
jobName: string;
|
||||
jobExecutionId: number | null;
|
||||
rangeFromDate: string;
|
||||
rangeToDate: string;
|
||||
executionStatus: string;
|
||||
executionStartTime: string | null;
|
||||
executionEndTime: string | null;
|
||||
durationMs: number | null;
|
||||
readCount: number | null;
|
||||
writeCount: number | null;
|
||||
skipCount: number | null;
|
||||
apiCallCount: number | null;
|
||||
executor: string | null;
|
||||
recollectionReason: string | null;
|
||||
failureReason: string | null;
|
||||
hasOverlap: boolean | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface RecollectionSearchResponse {
|
||||
content: RecollectionHistoryDto[];
|
||||
totalElements: number;
|
||||
number: number;
|
||||
size: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
export interface RecollectionStatsResponse {
|
||||
totalCount: number;
|
||||
completedCount: number;
|
||||
failedCount: number;
|
||||
runningCount: number;
|
||||
overlapCount: number;
|
||||
recentHistories: RecollectionHistoryDto[];
|
||||
}
|
||||
|
||||
export interface ApiStatsDto {
|
||||
callCount: number;
|
||||
totalMs: number;
|
||||
avgMs: number;
|
||||
maxMs: number;
|
||||
minMs: number;
|
||||
}
|
||||
|
||||
export interface RecollectionDetailResponse {
|
||||
history: RecollectionHistoryDto;
|
||||
overlappingHistories: RecollectionHistoryDto[];
|
||||
apiStats: ApiStatsDto | null;
|
||||
collectionPeriod: CollectionPeriodDto | null;
|
||||
stepExecutions: StepExecutionDto[];
|
||||
}
|
||||
|
||||
export interface CollectionPeriodDto {
|
||||
apiKey: string;
|
||||
apiKeyName: string | null;
|
||||
jobName: string | null;
|
||||
orderSeq: number | null;
|
||||
rangeFromDate: string | null;
|
||||
rangeToDate: string | null;
|
||||
}
|
||||
|
||||
// ── API Functions ────────────────────────────────────────────
|
||||
|
||||
export const batchApi = {
|
||||
@ -224,9 +315,11 @@ export const batchApi = {
|
||||
getJobsDetail: () =>
|
||||
fetchJson<JobDetailDto[]>(`${BASE}/jobs/detail`),
|
||||
|
||||
executeJob: (jobName: string, params?: Record<string, string>) =>
|
||||
postJson<{ success: boolean; message: string; executionId?: number }>(
|
||||
`${BASE}/jobs/${jobName}/execute`, params),
|
||||
executeJob: (jobName: string, params?: Record<string, string>) => {
|
||||
const qs = params ? '?' + new URLSearchParams(params).toString() : '';
|
||||
return postJson<{ success: boolean; message: string; executionId?: number }>(
|
||||
`${BASE}/jobs/${jobName}/execute${qs}`);
|
||||
},
|
||||
|
||||
getJobExecutions: (jobName: string) =>
|
||||
fetchJson<JobExecutionDto[]>(`${BASE}/jobs/${jobName}/executions`),
|
||||
@ -305,4 +398,48 @@ export const batchApi = {
|
||||
getPeriodExecutions: (jobName: string, view: string, periodKey: string) =>
|
||||
fetchJson<JobExecutionDto[]>(
|
||||
`${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: '/jobs', label: '작업', icon: '⚙️' },
|
||||
{ path: '/executions', label: '실행 이력', icon: '📋' },
|
||||
{ path: '/recollects', label: '재수집 이력', icon: '🔄' },
|
||||
{ path: '/schedules', label: '스케줄', icon: '🕐' },
|
||||
{ path: '/schedule-timeline', label: '타임라인', icon: '📅' },
|
||||
];
|
||||
|
||||
495
frontend/src/pages/RecollectDetail.tsx
Normal file
495
frontend/src/pages/RecollectDetail.tsx
Normal file
@ -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>←</span> 목록으로
|
||||
</button>
|
||||
<EmptyState
|
||||
icon="⚠"
|
||||
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>←</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} · {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="📥"
|
||||
/>
|
||||
<StatCard
|
||||
label="쓰기 (Write)"
|
||||
value={history.writeCount ?? 0}
|
||||
gradient="bg-gradient-to-br from-emerald-500 to-emerald-600"
|
||||
icon="📤"
|
||||
/>
|
||||
<StatCard
|
||||
label="건너뜀 (Skip)"
|
||||
value={history.skipCount ?? 0}
|
||||
gradient="bg-gradient-to-br from-amber-500 to-amber-600"
|
||||
icon="⏭"
|
||||
/>
|
||||
<StatCard
|
||||
label="API 호출"
|
||||
value={history.apiCallCount ?? 0}
|
||||
gradient="bg-gradient-to-br from-purple-500 to-purple-600"
|
||||
icon="🌐"
|
||||
/>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
809
frontend/src/pages/Recollects.tsx
Normal file
809
frontend/src/pages/Recollects.tsx
Normal file
@ -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"
|
||||
>
|
||||
×
|
||||
</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
6
package-lock.json
generated
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "snp-batch-validation",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {}
|
||||
}
|
||||
6
pom.xml
6
pom.xml
@ -143,6 +143,12 @@
|
||||
<artifactId>spring-batch-test</artifactId>
|
||||
<scope>test</scope>
|
||||
</dependency>
|
||||
|
||||
<dependency>
|
||||
<groupId>com.google.code.findbugs</groupId>
|
||||
<artifactId>jsr305</artifactId>
|
||||
<version>3.0.2</version>
|
||||
</dependency>
|
||||
</dependencies>
|
||||
|
||||
<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;
|
||||
|
||||
import com.snp.batch.global.dto.*;
|
||||
import com.snp.batch.global.model.BatchCollectionPeriod;
|
||||
import com.snp.batch.global.model.BatchRecollectionHistory;
|
||||
import com.snp.batch.service.BatchService;
|
||||
import com.snp.batch.service.RecollectionHistoryService;
|
||||
import com.snp.batch.service.ScheduleService;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.Parameter;
|
||||
@ -17,6 +20,9 @@ import org.springdoc.core.annotations.ParameterObject;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.PageRequest;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.HashMap;
|
||||
@ -32,6 +38,7 @@ public class BatchController {
|
||||
|
||||
private final BatchService batchService;
|
||||
private final ScheduleService scheduleService;
|
||||
private final RecollectionHistoryService recollectionHistoryService;
|
||||
|
||||
@Operation(summary = "배치 작업 실행", description = "지정된 배치 작업을 즉시 실행합니다. 쿼리 파라미터로 Job Parameters 전달 가능")
|
||||
@ApiResponses(value = {
|
||||
@ -453,4 +460,120 @@ public class BatchController {
|
||||
ExecutionStatisticsDto stats = batchService.getJobStatistics(jobName, days);
|
||||
return ResponseEntity.ok(stats);
|
||||
}
|
||||
|
||||
// ── 재수집 이력 관리 API ─────────────────────────────────────
|
||||
|
||||
@Operation(summary = "재수집 이력 목록 조회", description = "필터 조건으로 재수집 이력을 페이징 조회합니다")
|
||||
@GetMapping("/recollection-histories")
|
||||
public ResponseEntity<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 exitMessage;
|
||||
private Long duration; // 실행 시간 (ms)
|
||||
private ApiCallInfo apiCallInfo; // API 호출 정보 (옵셔널)
|
||||
private ApiCallInfo apiCallInfo; // API 호출 정보 - StepExecutionContext 기반 (옵셔널)
|
||||
private StepApiLogSummary apiLogSummary; // API 호출 로그 요약 - batch_api_log 기반 (옵셔널)
|
||||
}
|
||||
|
||||
/**
|
||||
* API 호출 정보 DTO
|
||||
* API 호출 정보 DTO (StepExecutionContext 기반)
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@ -86,4 +87,41 @@ public class JobExecutionDetailDto {
|
||||
private Integer completedCalls; // 완료된 API 호출 횟수
|
||||
private String lastCallTime; // 마지막 호출 시간
|
||||
}
|
||||
|
||||
/**
|
||||
* Step별 API 로그 집계 요약 (batch_api_log 테이블 기반)
|
||||
*/
|
||||
@Data
|
||||
@Builder
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
public static class StepApiLogSummary {
|
||||
private Long totalCalls; // 총 호출수
|
||||
private Long successCount; // 성공(2xx) 수
|
||||
private Long errorCount; // 에러(4xx/5xx) 수
|
||||
private Double avgResponseMs; // 평균 응답시간
|
||||
private Long maxResponseMs; // 최대 응답시간
|
||||
private Long minResponseMs; // 최소 응답시간
|
||||
private Long totalResponseMs; // 총 응답시간
|
||||
private Long totalRecordCount; // 총 반환 건수
|
||||
private List<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 org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
|
||||
@Repository
|
||||
public interface BatchApiLogRepository extends JpaRepository<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;
|
||||
|
||||
import com.snp.batch.global.model.BatchCollectionPeriod;
|
||||
import com.snp.batch.global.repository.BatchCollectionPeriodRepository;
|
||||
import com.snp.batch.global.repository.BatchLastExecutionRepository;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.batch.core.scope.context.StepContext;
|
||||
import org.springframework.batch.core.scope.context.StepSynchronizationManager;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
@ -10,35 +15,61 @@ import java.time.ZoneOffset;
|
||||
import java.time.format.DateTimeFormatter;
|
||||
import java.util.HashMap;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class BatchDateService {
|
||||
private final BatchLastExecutionRepository repository;
|
||||
|
||||
public BatchDateService(BatchLastExecutionRepository repository) {
|
||||
this.repository = repository;
|
||||
private final BatchLastExecutionRepository repository;
|
||||
private final BatchCollectionPeriodRepository collectionPeriodRepository;
|
||||
|
||||
/**
|
||||
* 현재 Step의 Job 파라미터에서 executionMode를 확인
|
||||
*/
|
||||
private String getExecutionMode() {
|
||||
try {
|
||||
StepContext context = StepSynchronizationManager.getContext();
|
||||
if (context != null && context.getStepExecution() != null) {
|
||||
return context.getStepExecution().getJobExecution()
|
||||
.getJobParameters().getString("executionMode", "NORMAL");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
log.debug("StepSynchronizationManager 컨텍스트 접근 실패, NORMAL 모드로 처리", e);
|
||||
}
|
||||
return "NORMAL";
|
||||
}
|
||||
|
||||
/**
|
||||
* 현재 Step의 Job 파라미터에서 apiKey 파라미터를 확인 (재수집용)
|
||||
*/
|
||||
private String getRecollectApiKey() {
|
||||
try {
|
||||
StepContext context = StepSynchronizationManager.getContext();
|
||||
if (context != null && context.getStepExecution() != null) {
|
||||
return context.getStepExecution().getJobExecution()
|
||||
.getJobParameters().getString("apiKey");
|
||||
}
|
||||
} catch (Exception e) {
|
||||
// ignore
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public Map<String, String> getDateRangeWithoutTimeParams(String apiKey) {
|
||||
// 재수집 모드: batch_collection_period에서 날짜 조회
|
||||
if ("RECOLLECT".equals(getExecutionMode())) {
|
||||
return getCollectionPeriodDateParams(apiKey);
|
||||
}
|
||||
|
||||
// 정상 모드: last_success_date ~ now()
|
||||
return repository.findDateRangeByApiKey(apiKey)
|
||||
.map(projection -> {
|
||||
Map<String, String> params = new HashMap<>();
|
||||
|
||||
LocalDateTime fromTarget = (projection.getRangeFromDate() != null)
|
||||
? projection.getRangeFromDate()
|
||||
: projection.getLastSuccessDate();
|
||||
|
||||
LocalDateTime toTarget = (projection.getRangeToDate() != null)
|
||||
? projection.getRangeToDate()
|
||||
: LocalDateTime.now();
|
||||
|
||||
// 2. 파라미터 맵에 날짜 정보 매핑
|
||||
putDateParams(params, "from", fromTarget);
|
||||
putDateParams(params, "to", toTarget);
|
||||
|
||||
// 3. 고정 값 설정
|
||||
putDateParams(params, "from", projection.getLastSuccessDate());
|
||||
putDateParams(params, "to", LocalDateTime.now());
|
||||
params.put("shipsCategory", "0");
|
||||
|
||||
return params;
|
||||
})
|
||||
.orElseGet(() -> {
|
||||
@ -47,6 +78,80 @@ public class BatchDateService {
|
||||
});
|
||||
}
|
||||
|
||||
public Map<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에 담는 헬퍼 메소드
|
||||
*/
|
||||
@ -58,63 +163,13 @@ public class BatchDateService {
|
||||
}
|
||||
}
|
||||
|
||||
public Map<String, String> getDateRangeWithTimezoneParams(String apiKey) {
|
||||
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("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 문자열로 변환하는 로직
|
||||
/**
|
||||
* 한국 시간(LocalDateTime)을 UTC 문자열로 변환
|
||||
*/
|
||||
private String formatToUtc(LocalDateTime localDateTime, DateTimeFormatter formatter) {
|
||||
if (localDateTime == null) return null;
|
||||
// 1. 한국 시간대(KST)임을 명시
|
||||
// 2. UTC로 시간대를 변경 (9시간 빠짐)
|
||||
// 3. 포맷팅 (끝에 Z가 자동으로 붙음)
|
||||
return localDateTime.atZone(ZoneId.of("Asia/Seoul"))
|
||||
.withZoneSameInstant(ZoneOffset.UTC)
|
||||
.format(formatter);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -1,7 +1,9 @@
|
||||
package com.snp.batch.service;
|
||||
|
||||
import com.snp.batch.common.batch.listener.RecollectionJobExecutionListener;
|
||||
import com.snp.batch.global.dto.*;
|
||||
import com.snp.batch.global.repository.TimelineRepository;
|
||||
import jakarta.annotation.PostConstruct;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.batch.core.Job;
|
||||
import org.springframework.batch.core.JobExecution;
|
||||
@ -9,6 +11,7 @@ import org.springframework.batch.core.JobInstance;
|
||||
import org.springframework.batch.core.JobParameters;
|
||||
import org.springframework.batch.core.JobParametersBuilder;
|
||||
import org.springframework.batch.core.explore.JobExplorer;
|
||||
import org.springframework.batch.core.job.AbstractJob;
|
||||
import org.springframework.batch.core.launch.JobLauncher;
|
||||
import org.springframework.batch.core.launch.JobOperator;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
@ -31,6 +34,7 @@ public class BatchService {
|
||||
private final Map<String, Job> jobMap;
|
||||
private final ScheduleService scheduleService;
|
||||
private final TimelineRepository timelineRepository;
|
||||
private final RecollectionJobExecutionListener recollectionJobExecutionListener;
|
||||
|
||||
@Autowired
|
||||
public BatchService(JobLauncher jobLauncher,
|
||||
@ -38,13 +42,29 @@ public class BatchService {
|
||||
JobOperator jobOperator,
|
||||
Map<String, Job> jobMap,
|
||||
@Lazy ScheduleService scheduleService,
|
||||
TimelineRepository timelineRepository) {
|
||||
TimelineRepository timelineRepository,
|
||||
RecollectionJobExecutionListener recollectionJobExecutionListener) {
|
||||
this.jobLauncher = jobLauncher;
|
||||
this.jobExplorer = jobExplorer;
|
||||
this.jobOperator = jobOperator;
|
||||
this.jobMap = jobMap;
|
||||
this.scheduleService = scheduleService;
|
||||
this.timelineRepository = timelineRepository;
|
||||
this.recollectionJobExecutionListener = recollectionJobExecutionListener;
|
||||
}
|
||||
|
||||
/**
|
||||
* 모든 Job에 RecollectionJobExecutionListener를 등록
|
||||
* 리스너 내부에서 executionMode 체크하므로 정상 실행에는 영향 없음
|
||||
*/
|
||||
@PostConstruct
|
||||
public void registerGlobalListeners() {
|
||||
jobMap.values().forEach(job -> {
|
||||
if (job instanceof AbstractJob abstractJob) {
|
||||
abstractJob.registerJobExecutionListener(recollectionJobExecutionListener);
|
||||
}
|
||||
});
|
||||
log.info("[BatchService] RecollectionJobExecutionListener를 {}개 Job에 등록", jobMap.size());
|
||||
}
|
||||
|
||||
public Long executeJob(String jobName) throws Exception {
|
||||
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
불러오는 중...
Reference in New Issue
Block a user