Merge pull request 'feat(global): Job 한글 표시명 DB 관리 및 전체 화면 통합 (#45)' (#47) from feature/ISSUE-45-job-display-name-db into develop

This commit is contained in:
HYOJIN 2026-03-13 14:40:18 +09:00
커밋 54cb37ce0c
13개의 변경된 파일343개의 추가작업 그리고 79개의 파일을 삭제

파일 보기

@ -29,6 +29,7 @@
- 재시도 초과 레코드 초기화 API/UI 추가
- IMO 기반 Risk 상세 조회 bypass API 추가 (#39)
- 배치 작업 목록 한글 표시명 추가 (#40)
- Job 한글 표시명 DB 관리 및 전체 화면 통합 (#45)
### 수정
- 자동 재수집 JobParameter 오버플로우 수정 (VARCHAR 2500 제한 해결)

파일 보기

@ -336,6 +336,15 @@ export interface LastCollectionStatusDto {
elapsedMinutes: number;
}
// ── Job Display Name ──────────────────────────────────────────
export interface JobDisplayName {
id: number;
jobName: string;
displayName: string;
apiKey: string | null;
}
// ── API Functions ────────────────────────────────────────────
export const batchApi = {
@ -495,6 +504,10 @@ export const batchApi = {
getLastCollectionStatuses: () =>
fetchJson<LastCollectionStatusDto[]>(`${BASE}/last-collections`),
// Display Names
getDisplayNames: () =>
fetchJson<JobDisplayName[]>(`${BASE}/display-names`),
resolveFailedRecords: (ids: number[]) =>
postJson<{ success: boolean; message: string; resolvedCount?: number }>(
`${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 {
batchApi,
@ -6,6 +6,7 @@ import {
type DashboardStats,
type ExecutionStatisticsDto,
type RecollectionStatsResponse,
type JobDisplayName,
} from '../api/batchApi';
import { usePoller } from '../hooks/usePoller';
import { useToastContext } from '../contexts/ToastContext';
@ -49,6 +50,7 @@ export default function Dashboard() {
const [abandoning, setAbandoning] = useState(false);
const [statistics, setStatistics] = useState<ExecutionStatisticsDto | null>(null);
const [recollectionStats, setRecollectionStats] = useState<RecollectionStatsResponse | null>(null);
const [displayNames, setDisplayNames] = useState<JobDisplayName[]>([]);
const loadStatistics = useCallback(async () => {
try {
@ -76,6 +78,19 @@ export default function Dashboard() {
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 () => {
try {
const data = await batchApi.getDashboard();
@ -235,7 +250,7 @@ export default function Dashboard() {
<tbody>
{runningJobs.map((job) => (
<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">{formatDateTime(job.startTime)}</td>
<td className="py-3">
@ -290,7 +305,7 @@ export default function Dashboard() {
#{exec.executionId}
</Link>
</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.endTime)}</td>
<td className="py-3 text-wing-muted">
@ -337,7 +352,7 @@ export default function Dashboard() {
#{fail.executionId}
</Link>
</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" title={fail.exitMessage ?? ''}>
{fail.exitMessage
@ -406,7 +421,7 @@ export default function Dashboard() {
#{h.historyId}
</Link>
</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">{formatDateTime(h.executionStartTime)}</td>
<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 { 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 { usePoller } from '../hooks/usePoller';
import { useToastContext } from '../contexts/ToastContext';
@ -31,6 +31,7 @@ export default function Executions() {
const jobFromQuery = searchParams.get('job') || '';
const [jobs, setJobs] = useState<string[]>([]);
const [displayNames, setDisplayNames] = useState<JobDisplayName[]>([]);
const [executions, setExecutions] = useState<JobExecutionDto[]>([]);
const [selectedJobs, setSelectedJobs] = useState<string[]>(jobFromQuery ? [jobFromQuery] : []);
const [jobDropdownOpen, setJobDropdownOpen] = useState(false);
@ -54,6 +55,18 @@ export default function Executions() {
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 () => {
try {
const data = await batchApi.getJobs();
@ -267,7 +280,7 @@ export default function Executions() {
onChange={() => toggleJob(job)}
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>
))}
</div>
@ -291,7 +304,7 @@ export default function Executions() {
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"
>
{job}
{displayNameMap[job] || job}
<button
onClick={() => toggleJob(job)}
className="hover:text-wing-text transition-colors"
@ -407,7 +420,7 @@ export default function Executions() {
#{exec.executionId}
</td>
<td className="px-6 py-4 text-wing-text">
{exec.jobName}
{displayNameMap[exec.jobName] || exec.jobName}
</td>
<td className="px-6 py-4">
<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 {
batchApi,
type RecollectionDetailResponse,
type StepExecutionDto,
type FailedRecordDto,
type JobDisplayName,
} from '../api/batchApi';
import { formatDateTime, formatDuration, calculateDuration } from '../utils/formatters';
import { usePoller } from '../hooks/usePoller';
@ -136,6 +137,20 @@ export default function RecollectDetail() {
const [data, setData] = useState<RecollectionDetailResponse | null>(null);
const [loading, setLoading] = useState(true);
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
? data.history.executionStatus === 'STARTED'
@ -203,7 +218,7 @@ export default function RecollectDetail() {
#{history.historyId}
</h1>
<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>
</div>
<div className="flex items-center gap-2">
@ -352,7 +367,7 @@ export default function RecollectDetail() {
#{oh.historyId}
</td>
<td className="px-4 py-3 text-wing-text">
{oh.apiKeyName || oh.apiKey}
{displayNameMap[oh.apiKey] || oh.apiKeyName || oh.apiKey}
</td>
<td className="px-4 py-3 text-wing-muted text-xs">
{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 {
batchApi,
@ -6,6 +6,7 @@ import {
type RecollectionSearchResponse,
type CollectionPeriodDto,
type LastCollectionStatusDto,
type JobDisplayName,
} from '../api/batchApi';
import { formatDateTime, formatDuration } from '../utils/formatters';
import { usePoller } from '../hooks/usePoller';
@ -121,6 +122,7 @@ function addHoursToDateTime(
export default function Recollects() {
const navigate = useNavigate();
const { showToast } = useToastContext();
const [displayNames, setDisplayNames] = useState<JobDisplayName[]>([]);
const [lastCollectionStatuses, setLastCollectionStatuses] = useState<LastCollectionStatusDto[]>([]);
const [lastCollectionPanelOpen, setLastCollectionPanelOpen] = useState(false);
@ -205,7 +207,7 @@ export default function Recollects() {
setSavingApiKey(p.apiKey);
try {
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; });
setSelectedDuration((prev) => ({ ...prev, [p.apiKey]: null }));
setManualToDate((prev) => ({ ...prev, [p.apiKey]: false }));
@ -241,7 +243,7 @@ export default function Recollects() {
setSavingApiKey(p.apiKey);
try {
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; });
await loadPeriods();
} catch (err) {
@ -264,7 +266,7 @@ export default function Recollects() {
executor: 'MANUAL',
reason: '수집 기간 관리 화면에서 수동 실행',
});
showToast(result.message || `${p.apiKeyName || p.apiKey} 재수집이 시작되었습니다.`, 'success');
showToast(result.message || `${displayNameMap[p.apiKey] || p.apiKeyName || p.apiKey} 재수집이 시작되었습니다.`, 'success');
setLoading(true);
} catch (err) {
showToast(err instanceof Error ? err.message : '재수집 실행에 실패했습니다.', 'error');
@ -379,7 +381,22 @@ export default function Recollects() {
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) => {
if (displayNameMap[apiKey]) return displayNameMap[apiKey];
const p = periods.find((p) => p.apiKey === apiKey);
return p?.apiKeyName || apiKey;
};
@ -472,10 +489,7 @@ export default function Recollects() {
return (
<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">
<div className="text-wing-text font-medium">{s.apiDesc || s.apiKey}</div>
{s.apiDesc && (
<div className="text-xs text-wing-muted font-mono">{s.apiKey}</div>
)}
<div className="text-wing-text font-medium">{displayNameMap[s.apiKey] || s.apiDesc || s.apiKey}</div>
</td>
<td className="py-2.5 px-3 font-mono text-xs text-wing-muted">
{formatDateTime(s.lastSuccessDate)}
@ -544,7 +558,7 @@ export default function Recollects() {
>
<span className={selectedPeriodKey ? 'text-wing-text' : 'text-wing-muted'}>
{selectedPeriodKey
? (periods.find((p) => p.apiKey === selectedPeriodKey)?.apiKeyName || selectedPeriodKey)
? (displayNameMap[selectedPeriodKey] || periods.find((p) => p.apiKey === selectedPeriodKey)?.apiKeyName || selectedPeriodKey)
: '작업을 선택하세요'}
</span>
<svg className={`w-4 h-4 text-wing-muted transition-transform ${periodDropdownOpen ? 'rotate-180' : ''}`} fill="none" viewBox="0 0 24 24" stroke="currentColor">
@ -568,10 +582,7 @@ export default function Recollects() {
: 'text-wing-text'
}`}
>
<div>{p.apiKeyName || p.apiKey}</div>
{p.jobName && (
<div className="text-xs text-wing-muted font-mono">{p.jobName}</div>
)}
<div>{displayNameMap[p.apiKey] || p.apiKeyName || p.apiKey}</div>
</button>
))}
</div>
@ -757,7 +768,7 @@ export default function Recollects() {
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>
))}
</div>
@ -900,8 +911,8 @@ export default function Recollects() {
#{hist.historyId}
</td>
<td className="px-4 py-4 text-wing-text">
<div className="max-w-[120px] truncate" title={hist.apiKeyName || hist.apiKey}>
{hist.apiKeyName || hist.apiKey}
<div className="max-w-[120px] truncate" title={displayNameMap[hist.apiKey] || hist.apiKeyName || hist.apiKey}>
{displayNameMap[hist.apiKey] || hist.apiKeyName || hist.apiKey}
</div>
</td>
<td className="px-4 py-4">

파일 보기

@ -1,5 +1,5 @@
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 { useToastContext } from '../contexts/ToastContext';
import ConfirmModal from '../components/ConfirmModal';
@ -93,6 +93,7 @@ export default function Schedules() {
// Schedule list state
const [schedules, setSchedules] = useState<ScheduleResponse[]>([]);
const [listLoading, setListLoading] = useState(true);
const [displayNames, setDisplayNames] = useState<JobDisplayName[]>([]);
// Confirm modal state
const [confirmAction, setConfirmAction] = useState<ConfirmAction | null>(null);
@ -125,8 +126,17 @@ export default function Schedules() {
useEffect(() => {
loadJobs();
loadSchedules();
batchApi.getDisplayNames().then(setDisplayNames).catch(() => {});
}, [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) => {
setSelectedJob(jobName);
setCronExpression('');
@ -250,7 +260,7 @@ export default function Schedules() {
<option value="">-- --</option>
{jobs.map((job) => (
<option key={job} value={job}>
{job}
{displayNameMap[job] || job}
</option>
))}
</select>
@ -372,8 +382,8 @@ export default function Schedules() {
{/* Header */}
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2 min-w-0">
<h3 className="text-sm font-bold text-wing-text truncate">
{schedule.jobName}
<h3 className="text-sm font-bold text-wing-text truncate" title={schedule.jobName}>
{displayNameMap[schedule.jobName] || schedule.jobName}
</h3>
<span
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 { 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 { usePoller } from '../hooks/usePoller';
import { useToastContext } from '../contexts/ToastContext';
@ -80,6 +80,20 @@ export default function Timeline() {
const [schedules, setSchedules] = useState<ScheduleTimeline[]>([]);
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
const [tooltip, setTooltip] = useState<TooltipData | null>(null);
const tooltipTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
@ -288,9 +302,9 @@ export default function Timeline() {
<div
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"
title={schedule.jobName}
title={displayNameMap[schedule.jobName] || schedule.jobName}
>
{schedule.jobName}
{displayNameMap[schedule.jobName] || schedule.jobName}
</div>
{/* 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="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>: {tooltip.period.label}</div>
<div>
@ -379,7 +393,7 @@ export default function Timeline() {
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="text-sm font-bold text-wing-text">
{selectedCell.jobName}
{displayNameMap[selectedCell.jobName] || selectedCell.jobName}
</h3>
<p className="text-xs text-wing-muted mt-0.5">
: {selectedCell.periodLabel}

파일 보기

@ -3,6 +3,8 @@ package com.snp.batch.global.controller;
import com.snp.batch.global.dto.*;
import com.snp.batch.global.model.BatchCollectionPeriod;
import com.snp.batch.global.model.BatchRecollectionHistory;
import com.snp.batch.global.model.JobDisplayNameEntity;
import com.snp.batch.global.repository.JobDisplayNameRepository;
import com.snp.batch.service.BatchFailedRecordService;
import com.snp.batch.service.BatchService;
import com.snp.batch.service.RecollectionHistoryService;
@ -46,6 +48,7 @@ public class BatchController {
private final ScheduleService scheduleService;
private final RecollectionHistoryService recollectionHistoryService;
private final BatchFailedRecordService batchFailedRecordService;
private final JobDisplayNameRepository jobDisplayNameRepository;
@Operation(summary = "배치 작업 실행", description = "지정된 배치 작업을 즉시 실행합니다. 쿼리 파라미터로 Job Parameters 전달 가능")
@ApiResponses(value = {
@ -743,4 +746,46 @@ public class BatchController {
}
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.BatchFailedRecord;
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.JobDisplayNameRepository;
import com.snp.batch.global.repository.BatchFailedRecordRepository;
import com.snp.batch.global.repository.BatchLastExecutionRepository;
import com.snp.batch.global.repository.TimelineRepository;
@ -37,39 +39,11 @@ import java.util.stream.Collectors;
@Service
public class BatchService {
/** Job Bean 이름 → 한글 표시명 매핑 */
private static final Map<String, String> JOB_DISPLAY_NAMES = Map.ofEntries(
// AIS
Map.entry("aisTargetImportJob", "AIS 선박위치 수집(1분 주기)"),
Map.entry("aisTargetDbSyncJob", "AIS 선박위치 DB 적재(15분 주기)"),
// 공통코드
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 파티션 테이블 생성/관리")
);
/** DB에서 로드한 Job 한글 표시명 캐시 */
private Map<String, String> jobDisplayNameCache = Map.of();
/** DB에서 로드한 apiKey → 한글 표시명 캐시 */
private Map<String, String> apiKeyDisplayNameCache = Map.of();
private final JobLauncher jobLauncher;
private final JobExplorer jobExplorer;
@ -81,6 +55,7 @@ public class BatchService {
private final BatchApiLogRepository apiLogRepository;
private final BatchFailedRecordRepository failedRecordRepository;
private final BatchLastExecutionRepository batchLastExecutionRepository;
private final JobDisplayNameRepository jobDisplayNameRepository;
@Autowired
public BatchService(JobLauncher jobLauncher,
@ -92,7 +67,8 @@ public class BatchService {
RecollectionJobExecutionListener recollectionJobExecutionListener,
BatchApiLogRepository apiLogRepository,
BatchFailedRecordRepository failedRecordRepository,
BatchLastExecutionRepository batchLastExecutionRepository) {
BatchLastExecutionRepository batchLastExecutionRepository,
JobDisplayNameRepository jobDisplayNameRepository) {
this.jobLauncher = jobLauncher;
this.jobExplorer = jobExplorer;
this.jobOperator = jobOperator;
@ -103,6 +79,7 @@ public class BatchService {
this.apiLogRepository = apiLogRepository;
this.failedRecordRepository = failedRecordRepository;
this.batchLastExecutionRepository = batchLastExecutionRepository;
this.jobDisplayNameRepository = jobDisplayNameRepository;
}
/**
@ -110,13 +87,96 @@ public class BatchService {
* 리스너 내부에서 executionMode 체크하므로 정상 실행에는 영향 없음
*/
@PostConstruct
public void registerGlobalListeners() {
public void init() {
// 리스너 등록
jobMap.values().forEach(job -> {
if (job instanceof AbstractJob abstractJob) {
abstractJob.registerJobExecutionListener(recollectionJobExecutionListener);
}
});
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 {
@ -886,7 +946,7 @@ public class BatchService {
return JobDetailDto.builder()
.jobName(jobName)
.displayName(JOB_DISPLAY_NAMES.get(jobName))
.displayName(jobDisplayNameCache.get(jobName))
.lastExecution(lastExec)
.scheduleCron(cronMap.get(jobName))
.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.BatchRecollectionHistory;
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.BatchCollectionPeriodRepository;
import com.snp.batch.global.repository.BatchFailedRecordRepository;
import com.snp.batch.global.repository.BatchLastExecutionRepository;
import com.snp.batch.global.repository.BatchRecollectionHistoryRepository;
import com.snp.batch.global.repository.JobDisplayNameRepository;
import jakarta.persistence.criteria.Predicate;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@ -41,6 +43,7 @@ public class RecollectionHistoryService {
private final BatchApiLogRepository apiLogRepository;
private final BatchFailedRecordRepository failedRecordRepository;
private final JobExplorer jobExplorer;
private final JobDisplayNameRepository jobDisplayNameRepository;
/**
* 재수집 실행 시작 기록
@ -70,9 +73,11 @@ public class RecollectionHistoryService {
if (isRetryByRecordKeys) {
// 실패 재수집 (자동/수동): 날짜 범위가 아닌 실패 레코드 기반이므로 날짜 없이 이력 생성
if (apiKey != null) {
apiKeyName = periodRepository.findById(apiKey)
.map(BatchCollectionPeriod::getApiKeyName)
.orElse(null);
apiKeyName = jobDisplayNameRepository.findByApiKey(apiKey)
.map(JobDisplayNameEntity::getDisplayName)
.orElseGet(() -> periodRepository.findById(apiKey)
.map(BatchCollectionPeriod::getApiKeyName)
.orElse(null));
}
log.info("[RecollectionHistory] 실패 건 재수집 이력 생성 (날짜 범위 없음): executor={}, apiKey={}, apiKeyName={}", executor, apiKey, apiKeyName);
} else {
@ -86,7 +91,9 @@ public class RecollectionHistoryService {
BatchCollectionPeriod cp = period.get();
rangeFrom = cp.getRangeFromDate();
rangeTo = cp.getRangeToDate();
apiKeyName = cp.getApiKeyName();
apiKeyName = jobDisplayNameRepository.findByApiKey(apiKey)
.map(JobDisplayNameEntity::getDisplayName)
.orElseGet(cp::getApiKeyName);
// 기간 중복 검출
List<BatchRecollectionHistory> overlaps = historyRepository