feat(global): Job 한글 표시명 DB 관리 및 전체 화면 통합 (#45)

- job_display_name 테이블 신규 생성 (jobName, displayName, apiKey)
- 정적 Map 제거 → DB 캐시 기반 표시명 조회로 전환
- 초기 데이터 시드 20건 (테이블 비어있을 때 자동 삽입)
- 표시명 조회/수정 REST API 추가 (GET/PUT /api/batch/display-names)
- 재수집 이력 생성 시 displayName 우선 적용
- 전체 화면 displayName 통합 (Dashboard, Executions, Recollects, RecollectDetail, Schedules, Timeline)
This commit is contained in:
HYOJIN 2026-03-13 14:38:34 +09:00
부모 66aba9595d
커밋 ce67dcd7e3
12개의 변경된 파일342개의 추가작업 그리고 79개의 파일을 삭제

파일 보기

@ -336,6 +336,15 @@ export interface LastCollectionStatusDto {
elapsedMinutes: number; elapsedMinutes: number;
} }
// ── Job Display Name ──────────────────────────────────────────
export interface JobDisplayName {
id: number;
jobName: string;
displayName: string;
apiKey: string | null;
}
// ── API Functions ──────────────────────────────────────────── // ── API Functions ────────────────────────────────────────────
export const batchApi = { export const batchApi = {
@ -495,6 +504,10 @@ export const batchApi = {
getLastCollectionStatuses: () => getLastCollectionStatuses: () =>
fetchJson<LastCollectionStatusDto[]>(`${BASE}/last-collections`), fetchJson<LastCollectionStatusDto[]>(`${BASE}/last-collections`),
// Display Names
getDisplayNames: () =>
fetchJson<JobDisplayName[]>(`${BASE}/display-names`),
resolveFailedRecords: (ids: number[]) => resolveFailedRecords: (ids: number[]) =>
postJson<{ success: boolean; message: string; resolvedCount?: number }>( postJson<{ success: boolean; message: string; resolvedCount?: number }>(
`${BASE}/failed-records/resolve`, { ids }), `${BASE}/failed-records/resolve`, { ids }),

파일 보기

@ -1,4 +1,4 @@
import { useState, useCallback, useEffect } from 'react'; import { useState, useCallback, useEffect, useMemo } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { import {
batchApi, batchApi,
@ -6,6 +6,7 @@ import {
type DashboardStats, type DashboardStats,
type ExecutionStatisticsDto, type ExecutionStatisticsDto,
type RecollectionStatsResponse, type RecollectionStatsResponse,
type JobDisplayName,
} from '../api/batchApi'; } from '../api/batchApi';
import { usePoller } from '../hooks/usePoller'; import { usePoller } from '../hooks/usePoller';
import { useToastContext } from '../contexts/ToastContext'; import { useToastContext } from '../contexts/ToastContext';
@ -49,6 +50,7 @@ export default function Dashboard() {
const [abandoning, setAbandoning] = useState(false); const [abandoning, setAbandoning] = useState(false);
const [statistics, setStatistics] = useState<ExecutionStatisticsDto | null>(null); const [statistics, setStatistics] = useState<ExecutionStatisticsDto | null>(null);
const [recollectionStats, setRecollectionStats] = useState<RecollectionStatsResponse | null>(null); const [recollectionStats, setRecollectionStats] = useState<RecollectionStatsResponse | null>(null);
const [displayNames, setDisplayNames] = useState<JobDisplayName[]>([]);
const loadStatistics = useCallback(async () => { const loadStatistics = useCallback(async () => {
try { try {
@ -76,6 +78,19 @@ export default function Dashboard() {
loadRecollectionStats(); loadRecollectionStats();
}, [loadRecollectionStats]); }, [loadRecollectionStats]);
useEffect(() => {
batchApi.getDisplayNames().then(setDisplayNames).catch(() => {});
}, []);
const displayNameMap = useMemo<Record<string, string>>(() => {
const map: Record<string, string> = {};
for (const dn of displayNames) {
if (dn.apiKey) map[dn.apiKey] = dn.displayName;
map[dn.jobName] = dn.displayName;
}
return map;
}, [displayNames]);
const loadDashboard = useCallback(async () => { const loadDashboard = useCallback(async () => {
try { try {
const data = await batchApi.getDashboard(); const data = await batchApi.getDashboard();
@ -235,7 +250,7 @@ export default function Dashboard() {
<tbody> <tbody>
{runningJobs.map((job) => ( {runningJobs.map((job) => (
<tr key={job.executionId} className="border-b border-wing-border/50"> <tr key={job.executionId} className="border-b border-wing-border/50">
<td className="py-3 font-medium text-wing-text">{job.jobName}</td> <td className="py-3 font-medium text-wing-text">{displayNameMap[job.jobName] || job.jobName}</td>
<td className="py-3 text-wing-muted">#{job.executionId}</td> <td className="py-3 text-wing-muted">#{job.executionId}</td>
<td className="py-3 text-wing-muted">{formatDateTime(job.startTime)}</td> <td className="py-3 text-wing-muted">{formatDateTime(job.startTime)}</td>
<td className="py-3"> <td className="py-3">
@ -290,7 +305,7 @@ export default function Dashboard() {
#{exec.executionId} #{exec.executionId}
</Link> </Link>
</td> </td>
<td className="py-3 text-wing-text">{exec.jobName}</td> <td className="py-3 text-wing-text">{displayNameMap[exec.jobName] || exec.jobName}</td>
<td className="py-3 text-wing-muted">{formatDateTime(exec.startTime)}</td> <td className="py-3 text-wing-muted">{formatDateTime(exec.startTime)}</td>
<td className="py-3 text-wing-muted">{formatDateTime(exec.endTime)}</td> <td className="py-3 text-wing-muted">{formatDateTime(exec.endTime)}</td>
<td className="py-3 text-wing-muted"> <td className="py-3 text-wing-muted">
@ -337,7 +352,7 @@ export default function Dashboard() {
#{fail.executionId} #{fail.executionId}
</Link> </Link>
</td> </td>
<td className="py-3 text-wing-text">{fail.jobName}</td> <td className="py-3 text-wing-text">{displayNameMap[fail.jobName] || fail.jobName}</td>
<td className="py-3 text-wing-muted">{formatDateTime(fail.startTime)}</td> <td className="py-3 text-wing-muted">{formatDateTime(fail.startTime)}</td>
<td className="py-3 text-wing-muted" title={fail.exitMessage ?? ''}> <td className="py-3 text-wing-muted" title={fail.exitMessage ?? ''}>
{fail.exitMessage {fail.exitMessage
@ -406,7 +421,7 @@ export default function Dashboard() {
#{h.historyId} #{h.historyId}
</Link> </Link>
</td> </td>
<td className="py-3 text-wing-text">{h.apiKeyName || h.jobName}</td> <td className="py-3 text-wing-text">{displayNameMap[h.apiKey] || h.apiKeyName || h.jobName}</td>
<td className="py-3 text-wing-muted">{h.executor || '-'}</td> <td className="py-3 text-wing-muted">{h.executor || '-'}</td>
<td className="py-3 text-wing-muted">{formatDateTime(h.executionStartTime)}</td> <td className="py-3 text-wing-muted">{formatDateTime(h.executionStartTime)}</td>
<td className="py-3"> <td className="py-3">

파일 보기

@ -1,6 +1,6 @@
import { useState, useMemo, useCallback } from 'react'; import { useState, useMemo, useCallback, useEffect } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom'; import { useNavigate, useSearchParams } from 'react-router-dom';
import { batchApi, type JobExecutionDto, type ExecutionSearchResponse } from '../api/batchApi'; import { batchApi, type JobExecutionDto, type ExecutionSearchResponse, type JobDisplayName } from '../api/batchApi';
import { formatDateTime, calculateDuration } from '../utils/formatters'; import { formatDateTime, calculateDuration } from '../utils/formatters';
import { usePoller } from '../hooks/usePoller'; import { usePoller } from '../hooks/usePoller';
import { useToastContext } from '../contexts/ToastContext'; import { useToastContext } from '../contexts/ToastContext';
@ -31,6 +31,7 @@ export default function Executions() {
const jobFromQuery = searchParams.get('job') || ''; const jobFromQuery = searchParams.get('job') || '';
const [jobs, setJobs] = useState<string[]>([]); const [jobs, setJobs] = useState<string[]>([]);
const [displayNames, setDisplayNames] = useState<JobDisplayName[]>([]);
const [executions, setExecutions] = useState<JobExecutionDto[]>([]); const [executions, setExecutions] = useState<JobExecutionDto[]>([]);
const [selectedJobs, setSelectedJobs] = useState<string[]>(jobFromQuery ? [jobFromQuery] : []); const [selectedJobs, setSelectedJobs] = useState<string[]>(jobFromQuery ? [jobFromQuery] : []);
const [jobDropdownOpen, setJobDropdownOpen] = useState(false); const [jobDropdownOpen, setJobDropdownOpen] = useState(false);
@ -54,6 +55,18 @@ export default function Executions() {
const { showToast } = useToastContext(); const { showToast } = useToastContext();
useEffect(() => {
batchApi.getDisplayNames().then(setDisplayNames).catch(() => {});
}, []);
const displayNameMap = useMemo<Record<string, string>>(() => {
const map: Record<string, string> = {};
for (const dn of displayNames) {
map[dn.jobName] = dn.displayName;
}
return map;
}, [displayNames]);
const loadJobs = useCallback(async () => { const loadJobs = useCallback(async () => {
try { try {
const data = await batchApi.getJobs(); const data = await batchApi.getJobs();
@ -267,7 +280,7 @@ export default function Executions() {
onChange={() => toggleJob(job)} onChange={() => toggleJob(job)}
className="rounded border-wing-border text-wing-accent focus:ring-wing-accent" className="rounded border-wing-border text-wing-accent focus:ring-wing-accent"
/> />
<span className="text-wing-text truncate">{job}</span> <span className="text-wing-text truncate">{displayNameMap[job] || job}</span>
</label> </label>
))} ))}
</div> </div>
@ -291,7 +304,7 @@ export default function Executions() {
key={job} key={job}
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" 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"
> >
{job} {displayNameMap[job] || job}
<button <button
onClick={() => toggleJob(job)} onClick={() => toggleJob(job)}
className="hover:text-wing-text transition-colors" className="hover:text-wing-text transition-colors"
@ -407,7 +420,7 @@ export default function Executions() {
#{exec.executionId} #{exec.executionId}
</td> </td>
<td className="px-6 py-4 text-wing-text"> <td className="px-6 py-4 text-wing-text">
{exec.jobName} {displayNameMap[exec.jobName] || exec.jobName}
</td> </td>
<td className="px-6 py-4"> <td className="px-6 py-4">
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">

파일 보기

@ -1,10 +1,11 @@
import { useState, useCallback } from 'react'; import { useState, useCallback, useEffect, useMemo } from 'react';
import { useParams, useNavigate } from 'react-router-dom'; import { useParams, useNavigate } from 'react-router-dom';
import { import {
batchApi, batchApi,
type RecollectionDetailResponse, type RecollectionDetailResponse,
type StepExecutionDto, type StepExecutionDto,
type FailedRecordDto, type FailedRecordDto,
type JobDisplayName,
} from '../api/batchApi'; } from '../api/batchApi';
import { formatDateTime, formatDuration, calculateDuration } from '../utils/formatters'; import { formatDateTime, formatDuration, calculateDuration } from '../utils/formatters';
import { usePoller } from '../hooks/usePoller'; import { usePoller } from '../hooks/usePoller';
@ -136,6 +137,20 @@ export default function RecollectDetail() {
const [data, setData] = useState<RecollectionDetailResponse | null>(null); const [data, setData] = useState<RecollectionDetailResponse | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [displayNames, setDisplayNames] = useState<JobDisplayName[]>([]);
useEffect(() => {
batchApi.getDisplayNames().then(setDisplayNames).catch(() => {});
}, []);
const displayNameMap = useMemo<Record<string, string>>(() => {
const map: Record<string, string> = {};
for (const dn of displayNames) {
if (dn.apiKey) map[dn.apiKey] = dn.displayName;
map[dn.jobName] = dn.displayName;
}
return map;
}, [displayNames]);
const isRunning = data const isRunning = data
? data.history.executionStatus === 'STARTED' ? data.history.executionStatus === 'STARTED'
@ -203,7 +218,7 @@ export default function RecollectDetail() {
#{history.historyId} #{history.historyId}
</h1> </h1>
<p className="mt-1 text-sm text-wing-muted"> <p className="mt-1 text-sm text-wing-muted">
{history.apiKeyName || history.apiKey} &middot; {history.jobName} {displayNameMap[history.apiKey] || history.apiKeyName || history.apiKey} &middot; {history.jobName}
</p> </p>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -352,7 +367,7 @@ export default function RecollectDetail() {
#{oh.historyId} #{oh.historyId}
</td> </td>
<td className="px-4 py-3 text-wing-text"> <td className="px-4 py-3 text-wing-text">
{oh.apiKeyName || oh.apiKey} {displayNameMap[oh.apiKey] || oh.apiKeyName || oh.apiKey}
</td> </td>
<td className="px-4 py-3 text-wing-muted text-xs"> <td className="px-4 py-3 text-wing-muted text-xs">
{formatDateTime(oh.rangeFromDate)} {formatDateTime(oh.rangeFromDate)}

파일 보기

@ -1,4 +1,4 @@
import { useState, useMemo, useCallback } from 'react'; import { useState, useMemo, useCallback, useEffect } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { import {
batchApi, batchApi,
@ -6,6 +6,7 @@ import {
type RecollectionSearchResponse, type RecollectionSearchResponse,
type CollectionPeriodDto, type CollectionPeriodDto,
type LastCollectionStatusDto, type LastCollectionStatusDto,
type JobDisplayName,
} from '../api/batchApi'; } from '../api/batchApi';
import { formatDateTime, formatDuration } from '../utils/formatters'; import { formatDateTime, formatDuration } from '../utils/formatters';
import { usePoller } from '../hooks/usePoller'; import { usePoller } from '../hooks/usePoller';
@ -121,6 +122,7 @@ function addHoursToDateTime(
export default function Recollects() { export default function Recollects() {
const navigate = useNavigate(); const navigate = useNavigate();
const { showToast } = useToastContext(); const { showToast } = useToastContext();
const [displayNames, setDisplayNames] = useState<JobDisplayName[]>([]);
const [lastCollectionStatuses, setLastCollectionStatuses] = useState<LastCollectionStatusDto[]>([]); const [lastCollectionStatuses, setLastCollectionStatuses] = useState<LastCollectionStatusDto[]>([]);
const [lastCollectionPanelOpen, setLastCollectionPanelOpen] = useState(false); const [lastCollectionPanelOpen, setLastCollectionPanelOpen] = useState(false);
@ -205,7 +207,7 @@ export default function Recollects() {
setSavingApiKey(p.apiKey); setSavingApiKey(p.apiKey);
try { try {
await batchApi.resetCollectionPeriod(p.apiKey); await batchApi.resetCollectionPeriod(p.apiKey);
showToast(`${p.apiKeyName || p.apiKey} 수집 기간이 초기화되었습니다.`, 'success'); showToast(`${displayNameMap[p.apiKey] || p.apiKeyName || p.apiKey} 수집 기간이 초기화되었습니다.`, 'success');
setPeriodEdits((prev) => { const next = { ...prev }; delete next[p.apiKey]; return next; }); setPeriodEdits((prev) => { const next = { ...prev }; delete next[p.apiKey]; return next; });
setSelectedDuration((prev) => ({ ...prev, [p.apiKey]: null })); setSelectedDuration((prev) => ({ ...prev, [p.apiKey]: null }));
setManualToDate((prev) => ({ ...prev, [p.apiKey]: false })); setManualToDate((prev) => ({ ...prev, [p.apiKey]: false }));
@ -241,7 +243,7 @@ export default function Recollects() {
setSavingApiKey(p.apiKey); setSavingApiKey(p.apiKey);
try { try {
await batchApi.updateCollectionPeriod(p.apiKey, { rangeFromDate: from, rangeToDate: to }); await batchApi.updateCollectionPeriod(p.apiKey, { rangeFromDate: from, rangeToDate: to });
showToast(`${p.apiKeyName || p.apiKey} 수집 기간이 저장되었습니다.`, 'success'); showToast(`${displayNameMap[p.apiKey] || p.apiKeyName || p.apiKey} 수집 기간이 저장되었습니다.`, 'success');
setPeriodEdits((prev) => { const next = { ...prev }; delete next[p.apiKey]; return next; }); setPeriodEdits((prev) => { const next = { ...prev }; delete next[p.apiKey]; return next; });
await loadPeriods(); await loadPeriods();
} catch (err) { } catch (err) {
@ -264,7 +266,7 @@ export default function Recollects() {
executor: 'MANUAL', executor: 'MANUAL',
reason: '수집 기간 관리 화면에서 수동 실행', reason: '수집 기간 관리 화면에서 수동 실행',
}); });
showToast(result.message || `${p.apiKeyName || p.apiKey} 재수집이 시작되었습니다.`, 'success'); showToast(result.message || `${displayNameMap[p.apiKey] || p.apiKeyName || p.apiKey} 재수집이 시작되었습니다.`, 'success');
setLoading(true); setLoading(true);
} catch (err) { } catch (err) {
showToast(err instanceof Error ? err.message : '재수집 실행에 실패했습니다.', 'error'); showToast(err instanceof Error ? err.message : '재수집 실행에 실패했습니다.', 'error');
@ -379,7 +381,22 @@ export default function Recollects() {
setLoading(true); setLoading(true);
}; };
useEffect(() => {
batchApi.getDisplayNames()
.then(setDisplayNames)
.catch(() => { /* displayName 로드 실패 무시 */ });
}, []);
const displayNameMap = useMemo<Record<string, string>>(() => {
const map: Record<string, string> = {};
for (const dn of displayNames) {
if (dn.apiKey) map[dn.apiKey] = dn.displayName;
}
return map;
}, [displayNames]);
const getApiLabel = (apiKey: string) => { const getApiLabel = (apiKey: string) => {
if (displayNameMap[apiKey]) return displayNameMap[apiKey];
const p = periods.find((p) => p.apiKey === apiKey); const p = periods.find((p) => p.apiKey === apiKey);
return p?.apiKeyName || apiKey; return p?.apiKeyName || apiKey;
}; };
@ -472,10 +489,7 @@ export default function Recollects() {
return ( return (
<tr key={s.apiKey} className="border-b border-wing-border/30 hover:bg-wing-hover/50 transition-colors"> <tr key={s.apiKey} className="border-b border-wing-border/30 hover:bg-wing-hover/50 transition-colors">
<td className="py-2.5 px-3"> <td className="py-2.5 px-3">
<div className="text-wing-text font-medium">{s.apiDesc || s.apiKey}</div> <div className="text-wing-text font-medium">{displayNameMap[s.apiKey] || s.apiDesc || s.apiKey}</div>
{s.apiDesc && (
<div className="text-xs text-wing-muted font-mono">{s.apiKey}</div>
)}
</td> </td>
<td className="py-2.5 px-3 font-mono text-xs text-wing-muted"> <td className="py-2.5 px-3 font-mono text-xs text-wing-muted">
{formatDateTime(s.lastSuccessDate)} {formatDateTime(s.lastSuccessDate)}
@ -544,7 +558,7 @@ export default function Recollects() {
> >
<span className={selectedPeriodKey ? 'text-wing-text' : 'text-wing-muted'}> <span className={selectedPeriodKey ? 'text-wing-text' : 'text-wing-muted'}>
{selectedPeriodKey {selectedPeriodKey
? (periods.find((p) => p.apiKey === selectedPeriodKey)?.apiKeyName || selectedPeriodKey) ? (displayNameMap[selectedPeriodKey] || periods.find((p) => p.apiKey === selectedPeriodKey)?.apiKeyName || selectedPeriodKey)
: '작업을 선택하세요'} : '작업을 선택하세요'}
</span> </span>
<svg className={`w-4 h-4 text-wing-muted transition-transform ${periodDropdownOpen ? 'rotate-180' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor"> <svg className={`w-4 h-4 text-wing-muted transition-transform ${periodDropdownOpen ? 'rotate-180' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
@ -568,10 +582,7 @@ export default function Recollects() {
: 'text-wing-text' : 'text-wing-text'
}`} }`}
> >
<div>{p.apiKeyName || p.apiKey}</div> <div>{displayNameMap[p.apiKey] || p.apiKeyName || p.apiKey}</div>
{p.jobName && (
<div className="text-xs text-wing-muted font-mono">{p.jobName}</div>
)}
</button> </button>
))} ))}
</div> </div>
@ -757,7 +768,7 @@ export default function Recollects() {
selectedApiKey === p.apiKey ? 'bg-wing-accent/10 text-wing-accent font-medium' : 'text-wing-text' selectedApiKey === p.apiKey ? 'bg-wing-accent/10 text-wing-accent font-medium' : 'text-wing-text'
}`} }`}
> >
{p.apiKeyName || p.apiKey} {displayNameMap[p.apiKey] || p.apiKeyName || p.apiKey}
</button> </button>
))} ))}
</div> </div>
@ -900,8 +911,8 @@ export default function Recollects() {
#{hist.historyId} #{hist.historyId}
</td> </td>
<td className="px-4 py-4 text-wing-text"> <td className="px-4 py-4 text-wing-text">
<div className="max-w-[120px] truncate" title={hist.apiKeyName || hist.apiKey}> <div className="max-w-[120px] truncate" title={displayNameMap[hist.apiKey] || hist.apiKeyName || hist.apiKey}>
{hist.apiKeyName || hist.apiKey} {displayNameMap[hist.apiKey] || hist.apiKeyName || hist.apiKey}
</div> </div>
</td> </td>
<td className="px-4 py-4"> <td className="px-4 py-4">

파일 보기

@ -1,5 +1,5 @@
import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { useState, useEffect, useCallback, useMemo, useRef } from 'react';
import { batchApi, type ScheduleResponse } from '../api/batchApi'; import { batchApi, type ScheduleResponse, type JobDisplayName } from '../api/batchApi';
import { formatDateTime } from '../utils/formatters'; import { formatDateTime } from '../utils/formatters';
import { useToastContext } from '../contexts/ToastContext'; import { useToastContext } from '../contexts/ToastContext';
import ConfirmModal from '../components/ConfirmModal'; import ConfirmModal from '../components/ConfirmModal';
@ -93,6 +93,7 @@ export default function Schedules() {
// Schedule list state // Schedule list state
const [schedules, setSchedules] = useState<ScheduleResponse[]>([]); const [schedules, setSchedules] = useState<ScheduleResponse[]>([]);
const [listLoading, setListLoading] = useState(true); const [listLoading, setListLoading] = useState(true);
const [displayNames, setDisplayNames] = useState<JobDisplayName[]>([]);
// Confirm modal state // Confirm modal state
const [confirmAction, setConfirmAction] = useState<ConfirmAction | null>(null); const [confirmAction, setConfirmAction] = useState<ConfirmAction | null>(null);
@ -125,8 +126,17 @@ export default function Schedules() {
useEffect(() => { useEffect(() => {
loadJobs(); loadJobs();
loadSchedules(); loadSchedules();
batchApi.getDisplayNames().then(setDisplayNames).catch(() => {});
}, [loadJobs, loadSchedules]); }, [loadJobs, loadSchedules]);
const displayNameMap = useMemo<Record<string, string>>(() => {
const map: Record<string, string> = {};
for (const dn of displayNames) {
map[dn.jobName] = dn.displayName;
}
return map;
}, [displayNames]);
const handleJobSelect = async (jobName: string) => { const handleJobSelect = async (jobName: string) => {
setSelectedJob(jobName); setSelectedJob(jobName);
setCronExpression(''); setCronExpression('');
@ -250,7 +260,7 @@ export default function Schedules() {
<option value="">-- --</option> <option value="">-- --</option>
{jobs.map((job) => ( {jobs.map((job) => (
<option key={job} value={job}> <option key={job} value={job}>
{job} {displayNameMap[job] || job}
</option> </option>
))} ))}
</select> </select>
@ -372,8 +382,8 @@ export default function Schedules() {
{/* Header */} {/* Header */}
<div className="flex items-center justify-between mb-3"> <div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2 min-w-0"> <div className="flex items-center gap-2 min-w-0">
<h3 className="text-sm font-bold text-wing-text truncate"> <h3 className="text-sm font-bold text-wing-text truncate" title={schedule.jobName}>
{schedule.jobName} {displayNameMap[schedule.jobName] || schedule.jobName}
</h3> </h3>
<span <span
className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-semibold ${ className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-semibold ${

파일 보기

@ -1,6 +1,6 @@
import { useState, useCallback, useRef, useEffect } from 'react'; import { useState, useCallback, useRef, useEffect, useMemo } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { batchApi, type ExecutionInfo, type JobExecutionDto, type PeriodInfo, type ScheduleTimeline } from '../api/batchApi'; import { batchApi, type ExecutionInfo, type JobDisplayName, type JobExecutionDto, type PeriodInfo, type ScheduleTimeline } from '../api/batchApi';
import { formatDateTime, calculateDuration } from '../utils/formatters'; import { formatDateTime, calculateDuration } from '../utils/formatters';
import { usePoller } from '../hooks/usePoller'; import { usePoller } from '../hooks/usePoller';
import { useToastContext } from '../contexts/ToastContext'; import { useToastContext } from '../contexts/ToastContext';
@ -80,6 +80,20 @@ export default function Timeline() {
const [schedules, setSchedules] = useState<ScheduleTimeline[]>([]); const [schedules, setSchedules] = useState<ScheduleTimeline[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [displayNames, setDisplayNames] = useState<JobDisplayName[]>([]);
useEffect(() => {
batchApi.getDisplayNames().then(setDisplayNames).catch(() => {});
}, []);
const displayNameMap = useMemo<Record<string, string>>(() => {
const map: Record<string, string> = {};
for (const dn of displayNames) {
map[dn.jobName] = dn.displayName;
}
return map;
}, [displayNames]);
// Tooltip // Tooltip
const [tooltip, setTooltip] = useState<TooltipData | null>(null); const [tooltip, setTooltip] = useState<TooltipData | null>(null);
const tooltipTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); const tooltipTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@ -288,9 +302,9 @@ export default function Timeline() {
<div <div
key={`name-${schedule.jobName}`} key={`name-${schedule.jobName}`}
className="sticky left-0 z-10 bg-wing-surface border-b border-r border-wing-border px-3 py-2 text-xs font-medium text-wing-text truncate flex items-center" className="sticky left-0 z-10 bg-wing-surface border-b border-r border-wing-border px-3 py-2 text-xs font-medium text-wing-text truncate flex items-center"
title={schedule.jobName} title={displayNameMap[schedule.jobName] || schedule.jobName}
> >
{schedule.jobName} {displayNameMap[schedule.jobName] || schedule.jobName}
</div> </div>
{/* Execution Cells */} {/* Execution Cells */}
@ -345,7 +359,7 @@ export default function Timeline() {
}} }}
> >
<div className="bg-gray-900 text-white text-xs rounded-lg px-3 py-2 shadow-lg max-w-xs"> <div className="bg-gray-900 text-white text-xs rounded-lg px-3 py-2 shadow-lg max-w-xs">
<div className="font-semibold mb-1">{tooltip.jobName}</div> <div className="font-semibold mb-1">{displayNameMap[tooltip.jobName] || tooltip.jobName}</div>
<div className="space-y-0.5 text-gray-300"> <div className="space-y-0.5 text-gray-300">
<div>: {tooltip.period.label}</div> <div>: {tooltip.period.label}</div>
<div> <div>
@ -379,7 +393,7 @@ export default function Timeline() {
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
<div> <div>
<h3 className="text-sm font-bold text-wing-text"> <h3 className="text-sm font-bold text-wing-text">
{selectedCell.jobName} {displayNameMap[selectedCell.jobName] || selectedCell.jobName}
</h3> </h3>
<p className="text-xs text-wing-muted mt-0.5"> <p className="text-xs text-wing-muted mt-0.5">
: {selectedCell.periodLabel} : {selectedCell.periodLabel}

파일 보기

@ -3,6 +3,8 @@ 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.BatchCollectionPeriod;
import com.snp.batch.global.model.BatchRecollectionHistory; import com.snp.batch.global.model.BatchRecollectionHistory;
import com.snp.batch.global.model.JobDisplayNameEntity;
import com.snp.batch.global.repository.JobDisplayNameRepository;
import com.snp.batch.service.BatchFailedRecordService; import com.snp.batch.service.BatchFailedRecordService;
import com.snp.batch.service.BatchService; import com.snp.batch.service.BatchService;
import com.snp.batch.service.RecollectionHistoryService; import com.snp.batch.service.RecollectionHistoryService;
@ -46,6 +48,7 @@ public class BatchController {
private final ScheduleService scheduleService; private final ScheduleService scheduleService;
private final RecollectionHistoryService recollectionHistoryService; private final RecollectionHistoryService recollectionHistoryService;
private final BatchFailedRecordService batchFailedRecordService; private final BatchFailedRecordService batchFailedRecordService;
private final JobDisplayNameRepository jobDisplayNameRepository;
@Operation(summary = "배치 작업 실행", description = "지정된 배치 작업을 즉시 실행합니다. 쿼리 파라미터로 Job Parameters 전달 가능") @Operation(summary = "배치 작업 실행", description = "지정된 배치 작업을 즉시 실행합니다. 쿼리 파라미터로 Job Parameters 전달 가능")
@ApiResponses(value = { @ApiResponses(value = {
@ -743,4 +746,46 @@ public class BatchController {
} }
return value; return value;
} }
// Job 한글 표시명 관리 API
@Operation(summary = "Job 표시명 전체 조회", description = "등록된 모든 Job의 한글 표시명을 조회합니다")
@GetMapping("/display-names")
public ResponseEntity<List<JobDisplayNameEntity>> getDisplayNames() {
log.debug("Received request to get all display names");
return ResponseEntity.ok(jobDisplayNameRepository.findAll());
}
@Operation(summary = "Job 표시명 수정", description = "특정 Job의 한글 표시명을 수정합니다")
@PutMapping("/display-names/{jobName}")
public ResponseEntity<Map<String, Object>> updateDisplayName(
@Parameter(description = "배치 작업 이름", required = true) @PathVariable String jobName,
@RequestBody Map<String, String> request) {
log.info("Update display name: jobName={}", jobName);
try {
String displayName = request.get("displayName");
if (displayName == null || displayName.isBlank()) {
return ResponseEntity.badRequest().body(Map.of(
"success", false,
"message", "displayName은 필수입니다"));
}
JobDisplayNameEntity entity = jobDisplayNameRepository.findByJobName(jobName)
.orElseGet(() -> JobDisplayNameEntity.builder().jobName(jobName).build());
entity.setDisplayName(displayName);
jobDisplayNameRepository.save(entity);
batchService.refreshDisplayNameCache();
return ResponseEntity.ok(Map.of(
"success", true,
"message", "표시명이 수정되었습니다",
"data", Map.of("jobName", jobName, "displayName", displayName)));
} catch (Exception e) {
log.error("Error updating display name: jobName={}", jobName, e);
return ResponseEntity.internalServerError().body(Map.of(
"success", false,
"message", "표시명 수정 실패: " + e.getMessage()));
}
}
} }

파일 보기

@ -0,0 +1,47 @@
package com.snp.batch.global.model;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
/**
* 배치 작업 한글 표시명을 관리하는 엔티티
* 모든 화면(작업 목록, 재수집 이력 )에서 공통으로 사용
*/
@Entity
@Table(name = "job_display_name", indexes = {
@Index(name = "idx_job_display_name_job_name", columnList = "job_name", unique = true),
@Index(name = "idx_job_display_name_api_key", columnList = "api_key", unique = true)
})
@Getter
@Setter
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class JobDisplayNameEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
/**
* 배치 작업 Bean 이름
*/
@Column(name = "job_name", unique = true, nullable = false, length = 100)
private String jobName;
/**
* 한글 표시명
*/
@Column(name = "display_name", nullable = false, length = 200)
private String displayName;
/**
* 외부 API (batch_collection_period.api_key 연동용, nullable)
*/
@Column(name = "api_key", unique = true, length = 50)
private String apiKey;
}

파일 보기

@ -0,0 +1,13 @@
package com.snp.batch.global.repository;
import com.snp.batch.global.model.JobDisplayNameEntity;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface JobDisplayNameRepository extends JpaRepository<JobDisplayNameEntity, Long> {
Optional<JobDisplayNameEntity> findByJobName(String jobName);
Optional<JobDisplayNameEntity> findByApiKey(String apiKey);
}

파일 보기

@ -5,7 +5,9 @@ import com.snp.batch.global.dto.*;
import com.snp.batch.global.model.BatchApiLog; import com.snp.batch.global.model.BatchApiLog;
import com.snp.batch.global.model.BatchFailedRecord; import com.snp.batch.global.model.BatchFailedRecord;
import com.snp.batch.global.model.BatchLastExecution; import com.snp.batch.global.model.BatchLastExecution;
import com.snp.batch.global.model.JobDisplayNameEntity;
import com.snp.batch.global.repository.BatchApiLogRepository; import com.snp.batch.global.repository.BatchApiLogRepository;
import com.snp.batch.global.repository.JobDisplayNameRepository;
import com.snp.batch.global.repository.BatchFailedRecordRepository; import com.snp.batch.global.repository.BatchFailedRecordRepository;
import com.snp.batch.global.repository.BatchLastExecutionRepository; import com.snp.batch.global.repository.BatchLastExecutionRepository;
import com.snp.batch.global.repository.TimelineRepository; import com.snp.batch.global.repository.TimelineRepository;
@ -37,39 +39,11 @@ import java.util.stream.Collectors;
@Service @Service
public class BatchService { public class BatchService {
/** Job Bean 이름 → 한글 표시명 매핑 */ /** DB에서 로드한 Job 한글 표시명 캐시 */
private static final Map<String, String> JOB_DISPLAY_NAMES = Map.ofEntries( private Map<String, String> jobDisplayNameCache = Map.of();
// AIS
Map.entry("aisTargetImportJob", "AIS 선박위치 수집(1분 주기)"), /** DB에서 로드한 apiKey → 한글 표시명 캐시 */
Map.entry("aisTargetDbSyncJob", "AIS 선박위치 DB 적재(15분 주기)"), private Map<String, String> apiKeyDisplayNameCache = Map.of();
// 공통코드
Map.entry("FlagCodeImportJob", "선박 국가코드 수집"),
Map.entry("Stat5CodeImportJob", "선박 유형코드 수집"),
// Compliance
Map.entry("ComplianceImportRangeJob", "선박 제재 정보 수집"),
Map.entry("CompanyComplianceImportRangeJob", "회사 제재 정보 수집"),
// Event
Map.entry("EventImportJob", "해양 사건/사고 수집"),
// Port
Map.entry("PortImportJob", "항구 시설 수집"),
// Movement
Map.entry("AnchorageCallsRangeImportJob", "정박지 기항 이력 수집"),
Map.entry("BerthCallsRangeImportJob", "선석 기항 이력 수집"),
Map.entry("CurrentlyAtRangeImportJob", "현재 위치 이력 수집"),
Map.entry("DestinationsRangeImportJob", "목적지 이력 수집"),
Map.entry("PortCallsRangeImportJob", "항구 기항 이력 수집"),
Map.entry("STSOperationRangeImportJob", "STS 작업 이력 수집"),
Map.entry("TerminalCallsRangeImportJob", "터미널 기항 이력 수집"),
Map.entry("TransitsRangeImportJob", "항해 이력 수집"),
// PSC
Map.entry("PSCDetailImportJob", "PSC 선박 검사 수집"),
// Risk
Map.entry("RiskRangeImportJob", "선박 위험지표 수집"),
// Ship
Map.entry("ShipDetailUpdateJob", "선박 제원정보 수집"),
// Infra
Map.entry("partitionManagerJob", "AIS 파티션 테이블 생성/관리")
);
private final JobLauncher jobLauncher; private final JobLauncher jobLauncher;
private final JobExplorer jobExplorer; private final JobExplorer jobExplorer;
@ -81,6 +55,7 @@ public class BatchService {
private final BatchApiLogRepository apiLogRepository; private final BatchApiLogRepository apiLogRepository;
private final BatchFailedRecordRepository failedRecordRepository; private final BatchFailedRecordRepository failedRecordRepository;
private final BatchLastExecutionRepository batchLastExecutionRepository; private final BatchLastExecutionRepository batchLastExecutionRepository;
private final JobDisplayNameRepository jobDisplayNameRepository;
@Autowired @Autowired
public BatchService(JobLauncher jobLauncher, public BatchService(JobLauncher jobLauncher,
@ -92,7 +67,8 @@ public class BatchService {
RecollectionJobExecutionListener recollectionJobExecutionListener, RecollectionJobExecutionListener recollectionJobExecutionListener,
BatchApiLogRepository apiLogRepository, BatchApiLogRepository apiLogRepository,
BatchFailedRecordRepository failedRecordRepository, BatchFailedRecordRepository failedRecordRepository,
BatchLastExecutionRepository batchLastExecutionRepository) { BatchLastExecutionRepository batchLastExecutionRepository,
JobDisplayNameRepository jobDisplayNameRepository) {
this.jobLauncher = jobLauncher; this.jobLauncher = jobLauncher;
this.jobExplorer = jobExplorer; this.jobExplorer = jobExplorer;
this.jobOperator = jobOperator; this.jobOperator = jobOperator;
@ -103,6 +79,7 @@ public class BatchService {
this.apiLogRepository = apiLogRepository; this.apiLogRepository = apiLogRepository;
this.failedRecordRepository = failedRecordRepository; this.failedRecordRepository = failedRecordRepository;
this.batchLastExecutionRepository = batchLastExecutionRepository; this.batchLastExecutionRepository = batchLastExecutionRepository;
this.jobDisplayNameRepository = jobDisplayNameRepository;
} }
/** /**
@ -110,13 +87,96 @@ public class BatchService {
* 리스너 내부에서 executionMode 체크하므로 정상 실행에는 영향 없음 * 리스너 내부에서 executionMode 체크하므로 정상 실행에는 영향 없음
*/ */
@PostConstruct @PostConstruct
public void registerGlobalListeners() { public void init() {
// 리스너 등록
jobMap.values().forEach(job -> { jobMap.values().forEach(job -> {
if (job instanceof AbstractJob abstractJob) { if (job instanceof AbstractJob abstractJob) {
abstractJob.registerJobExecutionListener(recollectionJobExecutionListener); abstractJob.registerJobExecutionListener(recollectionJobExecutionListener);
} }
}); });
log.info("[BatchService] RecollectionJobExecutionListener를 {}개 Job에 등록", jobMap.size()); log.info("[BatchService] RecollectionJobExecutionListener를 {}개 Job에 등록", jobMap.size());
// Job 한글 표시명 초기 데이터 시드 (테이블이 비어있을 때만)
seedDisplayNamesIfEmpty();
// Job 한글 표시명 캐시 로드
refreshDisplayNameCache();
}
/**
* job_display_name 테이블이 비어있으면 기본 표시명을 시드합니다.
*/
private void seedDisplayNamesIfEmpty() {
if (jobDisplayNameRepository.count() > 0) {
return;
}
List<JobDisplayNameEntity> entities = List.of(
buildDisplayName("aisTargetImportJob", "AIS 선박위치 수집(1분 주기)", null),
buildDisplayName("aisTargetDbSyncJob", "AIS 선박위치 DB 적재(15분 주기)", null),
buildDisplayName("FlagCodeImportJob", "선박 국가코드 수집", null),
buildDisplayName("Stat5CodeImportJob", "선박 유형코드 수집", null),
buildDisplayName("ComplianceImportRangeJob", "선박 제재 정보 수집", "COMPLIANCE_IMPORT_API"),
buildDisplayName("CompanyComplianceImportRangeJob", "회사 제재 정보 수집", "COMPANY_COMPLIANCE_IMPORT_API"),
buildDisplayName("EventImportJob", "해양 사건/사고 수집", "EVENT_IMPORT_API"),
buildDisplayName("PortImportJob", "항구 시설 수집", null),
buildDisplayName("AnchorageCallsRangeImportJob", "정박지 기항 이력 수집", "ANCHORAGE_CALLS_IMPORT_API"),
buildDisplayName("BerthCallsRangeImportJob", "선석 기항 이력 수집", "BERTH_CALLS_IMPORT_API"),
buildDisplayName("CurrentlyAtRangeImportJob", "현재 위치 이력 수집", "CURRENTLY_AT_IMPORT_API"),
buildDisplayName("DestinationsRangeImportJob", "목적지 이력 수집", "DESTINATIONS_IMPORT_API"),
buildDisplayName("PortCallsRangeImportJob", "항구 기항 이력 수집", "PORT_CALLS_IMPORT_API"),
buildDisplayName("STSOperationRangeImportJob", "STS 작업 이력 수집", "STS_OPERATION_IMPORT_API"),
buildDisplayName("TerminalCallsRangeImportJob", "터미널 기항 이력 수집", "TERMINAL_CALLS_IMPORT_API"),
buildDisplayName("TransitsRangeImportJob", "항해 이력 수집", "TRANSITS_IMPORT_API"),
buildDisplayName("PSCDetailImportJob", "PSC 선박 검사 수집", "PSC_IMPORT_API"),
buildDisplayName("RiskRangeImportJob", "선박 위험지표 수집", "RISK_IMPORT_API"),
buildDisplayName("ShipDetailUpdateJob", "선박 제원정보 수집", "SHIP_DETAIL_UPDATE_API"),
buildDisplayName("partitionManagerJob", "AIS 파티션 테이블 생성/관리", null)
);
jobDisplayNameRepository.saveAll(entities);
log.info("[BatchService] Job 표시명 초기 데이터 시드: {} 건", entities.size());
}
private JobDisplayNameEntity buildDisplayName(String jobName, String displayName, String apiKey) {
return JobDisplayNameEntity.builder()
.jobName(jobName)
.displayName(displayName)
.apiKey(apiKey)
.build();
}
/**
* DB에서 Job 한글 표시명을 로드하여 캐시를 갱신합니다.
*/
public void refreshDisplayNameCache() {
List<JobDisplayNameEntity> all = jobDisplayNameRepository.findAll();
jobDisplayNameCache = all.stream()
.collect(Collectors.toMap(
JobDisplayNameEntity::getJobName,
JobDisplayNameEntity::getDisplayName,
(a, b) -> a
));
apiKeyDisplayNameCache = all.stream()
.filter(e -> e.getApiKey() != null)
.collect(Collectors.toMap(
JobDisplayNameEntity::getApiKey,
JobDisplayNameEntity::getDisplayName,
(a, b) -> a
));
log.info("[BatchService] Job 표시명 캐시 로드: {} 건 (apiKey: {} 건)", jobDisplayNameCache.size(), apiKeyDisplayNameCache.size());
}
/**
* Job의 한글 표시명을 반환합니다. DB에 없으면 null.
*/
public String getDisplayName(String jobName) {
return jobDisplayNameCache.get(jobName);
}
/**
* apiKey로 한글 표시명을 반환합니다. DB에 없으면 null.
*/
public String getDisplayNameByApiKey(String apiKey) {
return apiKeyDisplayNameCache.get(apiKey);
} }
public Long executeJob(String jobName) throws Exception { public Long executeJob(String jobName) throws Exception {
@ -886,7 +946,7 @@ public class BatchService {
return JobDetailDto.builder() return JobDetailDto.builder()
.jobName(jobName) .jobName(jobName)
.displayName(JOB_DISPLAY_NAMES.get(jobName)) .displayName(jobDisplayNameCache.get(jobName))
.lastExecution(lastExec) .lastExecution(lastExec)
.scheduleCron(cronMap.get(jobName)) .scheduleCron(cronMap.get(jobName))
.build(); .build();

파일 보기

@ -5,11 +5,13 @@ import com.snp.batch.global.model.BatchCollectionPeriod;
import com.snp.batch.global.model.BatchLastExecution; import com.snp.batch.global.model.BatchLastExecution;
import com.snp.batch.global.model.BatchRecollectionHistory; import com.snp.batch.global.model.BatchRecollectionHistory;
import com.snp.batch.global.model.BatchFailedRecord; import com.snp.batch.global.model.BatchFailedRecord;
import com.snp.batch.global.model.JobDisplayNameEntity;
import com.snp.batch.global.repository.BatchApiLogRepository; import com.snp.batch.global.repository.BatchApiLogRepository;
import com.snp.batch.global.repository.BatchCollectionPeriodRepository; import com.snp.batch.global.repository.BatchCollectionPeriodRepository;
import com.snp.batch.global.repository.BatchFailedRecordRepository; import com.snp.batch.global.repository.BatchFailedRecordRepository;
import com.snp.batch.global.repository.BatchLastExecutionRepository; import com.snp.batch.global.repository.BatchLastExecutionRepository;
import com.snp.batch.global.repository.BatchRecollectionHistoryRepository; import com.snp.batch.global.repository.BatchRecollectionHistoryRepository;
import com.snp.batch.global.repository.JobDisplayNameRepository;
import jakarta.persistence.criteria.Predicate; import jakarta.persistence.criteria.Predicate;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@ -41,6 +43,7 @@ public class RecollectionHistoryService {
private final BatchApiLogRepository apiLogRepository; private final BatchApiLogRepository apiLogRepository;
private final BatchFailedRecordRepository failedRecordRepository; private final BatchFailedRecordRepository failedRecordRepository;
private final JobExplorer jobExplorer; private final JobExplorer jobExplorer;
private final JobDisplayNameRepository jobDisplayNameRepository;
/** /**
* 재수집 실행 시작 기록 * 재수집 실행 시작 기록
@ -70,9 +73,11 @@ public class RecollectionHistoryService {
if (isRetryByRecordKeys) { if (isRetryByRecordKeys) {
// 실패 재수집 (자동/수동): 날짜 범위가 아닌 실패 레코드 기반이므로 날짜 없이 이력 생성 // 실패 재수집 (자동/수동): 날짜 범위가 아닌 실패 레코드 기반이므로 날짜 없이 이력 생성
if (apiKey != null) { if (apiKey != null) {
apiKeyName = periodRepository.findById(apiKey) apiKeyName = jobDisplayNameRepository.findByApiKey(apiKey)
.map(JobDisplayNameEntity::getDisplayName)
.orElseGet(() -> periodRepository.findById(apiKey)
.map(BatchCollectionPeriod::getApiKeyName) .map(BatchCollectionPeriod::getApiKeyName)
.orElse(null); .orElse(null));
} }
log.info("[RecollectionHistory] 실패 건 재수집 이력 생성 (날짜 범위 없음): executor={}, apiKey={}, apiKeyName={}", executor, apiKey, apiKeyName); log.info("[RecollectionHistory] 실패 건 재수집 이력 생성 (날짜 범위 없음): executor={}, apiKey={}, apiKeyName={}", executor, apiKey, apiKeyName);
} else { } else {
@ -86,7 +91,9 @@ public class RecollectionHistoryService {
BatchCollectionPeriod cp = period.get(); BatchCollectionPeriod cp = period.get();
rangeFrom = cp.getRangeFromDate(); rangeFrom = cp.getRangeFromDate();
rangeTo = cp.getRangeToDate(); rangeTo = cp.getRangeToDate();
apiKeyName = cp.getApiKeyName(); apiKeyName = jobDisplayNameRepository.findByApiKey(apiKey)
.map(JobDisplayNameEntity::getDisplayName)
.orElseGet(cp::getApiKeyName);
// 기간 중복 검출 // 기간 중복 검출
List<BatchRecollectionHistory> overlaps = historyRepository List<BatchRecollectionHistory> overlaps = historyRepository