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