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:
부모
66aba9595d
커밋
ce67dcd7e3
@ -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} · {history.jobName}
|
{displayNameMap[history.apiKey] || history.apiKeyName || history.apiKey} · {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
|
||||||
|
|||||||
불러오는 중...
Reference in New Issue
Block a user