snp-batch-validation/src/main/java/com/snp/batch/service/RecollectionHistoryService.java

420 lines
17 KiB
Java

package com.snp.batch.service;
import com.snp.batch.global.dto.JobExecutionDetailDto;
import com.snp.batch.global.model.BatchCollectionPeriod;
import com.snp.batch.global.model.BatchLastExecution;
import com.snp.batch.global.model.BatchRecollectionHistory;
import com.snp.batch.global.repository.BatchApiLogRepository;
import com.snp.batch.global.repository.BatchCollectionPeriodRepository;
import com.snp.batch.global.repository.BatchLastExecutionRepository;
import com.snp.batch.global.repository.BatchRecollectionHistoryRepository;
import jakarta.persistence.criteria.Predicate;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.batch.core.JobExecution;
import org.springframework.batch.core.StepExecution;
import org.springframework.batch.core.explore.JobExplorer;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.domain.Specification;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Propagation;
import org.springframework.transaction.annotation.Transactional;
import com.snp.batch.global.model.BatchApiLog;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.*;
import java.util.stream.Collectors;
@Slf4j
@Service
@RequiredArgsConstructor
public class RecollectionHistoryService {
private final BatchRecollectionHistoryRepository historyRepository;
private final BatchCollectionPeriodRepository periodRepository;
private final BatchLastExecutionRepository lastExecutionRepository;
private final BatchApiLogRepository apiLogRepository;
private final JobExplorer jobExplorer;
/**
* 재수집 실행 시작 기록
* REQUIRES_NEW: Job 실패해도 이력은 보존
*/
@Transactional(propagation = Propagation.REQUIRES_NEW)
public BatchRecollectionHistory recordStart(
String jobName,
Long jobExecutionId,
String apiKey,
String executor,
String reason) {
Optional<BatchCollectionPeriod> period = periodRepository.findById(apiKey);
if (period.isEmpty()) {
log.warn("[RecollectionHistory] apiKey {} 에 대한 수집기간 없음, 이력 미생성", apiKey);
return null;
}
BatchCollectionPeriod cp = period.get();
LocalDateTime rangeFrom = cp.getRangeFromDate();
LocalDateTime rangeTo = cp.getRangeToDate();
// 기간 중복 검출
List<BatchRecollectionHistory> overlaps = historyRepository
.findOverlappingHistories(apiKey, rangeFrom, rangeTo, -1L);
boolean hasOverlap = !overlaps.isEmpty();
String overlapIds = overlaps.stream()
.map(h -> String.valueOf(h.getHistoryId()))
.collect(Collectors.joining(","));
LocalDateTime now = LocalDateTime.now();
BatchRecollectionHistory history = BatchRecollectionHistory.builder()
.apiKey(apiKey)
.apiKeyName(cp.getApiKeyName())
.jobName(jobName)
.jobExecutionId(jobExecutionId)
.rangeFromDate(rangeFrom)
.rangeToDate(rangeTo)
.executionStatus("STARTED")
.executionStartTime(now)
.executor(executor != null ? executor : "SYSTEM")
.recollectionReason(reason)
.hasOverlap(hasOverlap)
.overlappingHistoryIds(hasOverlap ? overlapIds : null)
.createdAt(now)
.updatedAt(now)
.build();
BatchRecollectionHistory saved = historyRepository.save(history);
log.info("[RecollectionHistory] 재수집 이력 생성: historyId={}, apiKey={}, jobExecutionId={}, range={}~{}",
saved.getHistoryId(), apiKey, jobExecutionId, rangeFrom, rangeTo);
return saved;
}
/**
* 재수집 실행 완료 기록
*/
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void recordCompletion(
Long jobExecutionId,
String status,
Long readCount,
Long writeCount,
Long skipCount,
Integer apiCallCount,
Long totalResponseTimeMs,
String failureReason) {
Optional<BatchRecollectionHistory> opt =
historyRepository.findByJobExecutionId(jobExecutionId);
if (opt.isEmpty()) {
log.warn("[RecollectionHistory] jobExecutionId {} 에 해당하는 이력 없음", jobExecutionId);
return;
}
BatchRecollectionHistory history = opt.get();
LocalDateTime now = LocalDateTime.now();
history.setExecutionStatus(status);
history.setExecutionEndTime(now);
history.setReadCount(readCount);
history.setWriteCount(writeCount);
history.setSkipCount(skipCount);
history.setApiCallCount(apiCallCount);
history.setTotalResponseTimeMs(totalResponseTimeMs);
history.setFailureReason(failureReason);
history.setUpdatedAt(now);
if (history.getExecutionStartTime() != null) {
history.setDurationMs(Duration.between(history.getExecutionStartTime(), now).toMillis());
}
historyRepository.save(history);
log.info("[RecollectionHistory] 재수집 완료 기록: jobExecutionId={}, status={}, read={}, write={}",
jobExecutionId, status, readCount, writeCount);
}
/**
* 동적 필터링 + 페이징 목록 조회
*/
@Transactional(readOnly = true)
public Page<BatchRecollectionHistory> getHistories(
String apiKey, String jobName, String status,
LocalDateTime from, LocalDateTime to,
Pageable pageable) {
Specification<BatchRecollectionHistory> spec = (root, query, cb) -> {
List<Predicate> predicates = new ArrayList<>();
if (apiKey != null && !apiKey.isEmpty()) {
predicates.add(cb.equal(root.get("apiKey"), apiKey));
}
if (jobName != null && !jobName.isEmpty()) {
predicates.add(cb.equal(root.get("jobName"), jobName));
}
if (status != null && !status.isEmpty()) {
predicates.add(cb.equal(root.get("executionStatus"), status));
}
if (from != null) {
predicates.add(cb.greaterThanOrEqualTo(root.get("executionStartTime"), from));
}
if (to != null) {
predicates.add(cb.lessThanOrEqualTo(root.get("executionStartTime"), to));
}
query.orderBy(cb.desc(root.get("createdAt")));
return cb.and(predicates.toArray(new Predicate[0]));
};
return historyRepository.findAll(spec, pageable);
}
/**
* 상세 조회 (중복 이력 실시간 재검사 포함)
*/
@Transactional(readOnly = true)
public Map<String, Object> getHistoryDetail(Long historyId) {
BatchRecollectionHistory history = historyRepository.findById(historyId)
.orElseThrow(() -> new IllegalArgumentException("이력을 찾을 수 없습니다: " + historyId));
// 중복 이력 실시간 재검사
List<BatchRecollectionHistory> currentOverlaps = historyRepository
.findOverlappingHistories(history.getApiKey(),
history.getRangeFromDate(), history.getRangeToDate(),
history.getHistoryId());
// API 응답시간 통계
Map<String, Object> apiStats = null;
if (history.getJobExecutionId() != null) {
apiStats = getApiStats(history.getJobExecutionId());
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("history", history);
result.put("overlappingHistories", currentOverlaps);
result.put("apiStats", apiStats);
return result;
}
/**
* 상세 조회 + Step Execution + Collection Period 포함
* job_execution_id로 batch_step_execution, batch_collection_period를 조인
*/
@Transactional(readOnly = true)
public Map<String, Object> getHistoryDetailWithSteps(Long historyId) {
BatchRecollectionHistory history = historyRepository.findById(historyId)
.orElseThrow(() -> new IllegalArgumentException("이력을 찾을 수 없습니다: " + historyId));
// 중복 이력 실시간 재검사
List<BatchRecollectionHistory> currentOverlaps = historyRepository
.findOverlappingHistories(history.getApiKey(),
history.getRangeFromDate(), history.getRangeToDate(),
history.getHistoryId());
// API 응답시간 통계
Map<String, Object> apiStats = null;
if (history.getJobExecutionId() != null) {
apiStats = getApiStats(history.getJobExecutionId());
}
// Collection Period 조회
BatchCollectionPeriod collectionPeriod = periodRepository
.findById(history.getApiKey()).orElse(null);
// Step Execution 조회 (job_execution_id 기반)
List<JobExecutionDetailDto.StepExecutionDto> stepExecutions = new ArrayList<>();
if (history.getJobExecutionId() != null) {
JobExecution jobExecution = jobExplorer.getJobExecution(history.getJobExecutionId());
if (jobExecution != null) {
stepExecutions = jobExecution.getStepExecutions().stream()
.map(this::convertStepToDto)
.collect(Collectors.toList());
}
}
Map<String, Object> result = new LinkedHashMap<>();
result.put("history", history);
result.put("overlappingHistories", currentOverlaps);
result.put("apiStats", apiStats);
result.put("collectionPeriod", collectionPeriod);
result.put("stepExecutions", stepExecutions);
return result;
}
private JobExecutionDetailDto.StepExecutionDto convertStepToDto(StepExecution stepExecution) {
Long duration = null;
if (stepExecution.getStartTime() != null && stepExecution.getEndTime() != null) {
duration = Duration.between(stepExecution.getStartTime(), stepExecution.getEndTime()).toMillis();
}
// StepExecutionContext에서 API 정보 추출 (ExecutionDetail 호환)
JobExecutionDetailDto.ApiCallInfo apiCallInfo = null;
var context = stepExecution.getExecutionContext();
if (context.containsKey("apiUrl")) {
apiCallInfo = JobExecutionDetailDto.ApiCallInfo.builder()
.apiUrl(context.getString("apiUrl", ""))
.method(context.getString("apiMethod", ""))
.totalCalls(context.containsKey("totalApiCalls") ? context.getInt("totalApiCalls", 0) : null)
.completedCalls(context.containsKey("completedApiCalls") ? context.getInt("completedApiCalls", 0) : null)
.lastCallTime(context.containsKey("lastCallTime") ? context.getString("lastCallTime", "") : null)
.build();
}
// batch_api_log 테이블에서 Step별 API 로그 집계 + 개별 로그 조회
JobExecutionDetailDto.StepApiLogSummary apiLogSummary =
buildStepApiLogSummary(stepExecution.getId());
return JobExecutionDetailDto.StepExecutionDto.builder()
.stepExecutionId(stepExecution.getId())
.stepName(stepExecution.getStepName())
.status(stepExecution.getStatus().name())
.startTime(stepExecution.getStartTime())
.endTime(stepExecution.getEndTime())
.readCount((int) stepExecution.getReadCount())
.writeCount((int) stepExecution.getWriteCount())
.commitCount((int) stepExecution.getCommitCount())
.rollbackCount((int) stepExecution.getRollbackCount())
.readSkipCount((int) stepExecution.getReadSkipCount())
.processSkipCount((int) stepExecution.getProcessSkipCount())
.writeSkipCount((int) stepExecution.getWriteSkipCount())
.filterCount((int) stepExecution.getFilterCount())
.exitCode(stepExecution.getExitStatus().getExitCode())
.exitMessage(stepExecution.getExitStatus().getExitDescription())
.duration(duration)
.apiCallInfo(apiCallInfo)
.apiLogSummary(apiLogSummary)
.build();
}
/**
* Step별 batch_api_log 통계 집계 (개별 로그는 별도 API로 페이징 조회)
*/
private JobExecutionDetailDto.StepApiLogSummary buildStepApiLogSummary(Long stepExecutionId) {
List<Object[]> stats = apiLogRepository.getApiStatsByStepExecutionId(stepExecutionId);
if (stats.isEmpty() || stats.get(0) == null || ((Number) stats.get(0)[0]).longValue() == 0L) {
return null;
}
Object[] row = stats.get(0);
return JobExecutionDetailDto.StepApiLogSummary.builder()
.totalCalls(((Number) row[0]).longValue())
.successCount(((Number) row[1]).longValue())
.errorCount(((Number) row[2]).longValue())
.avgResponseMs(((Number) row[3]).doubleValue())
.maxResponseMs(((Number) row[4]).longValue())
.minResponseMs(((Number) row[5]).longValue())
.totalResponseMs(((Number) row[6]).longValue())
.totalRecordCount(((Number) row[7]).longValue())
.build();
}
/**
* 대시보드용 최근 10건
*/
@Transactional(readOnly = true)
public List<BatchRecollectionHistory> getRecentHistories() {
return historyRepository.findTop10ByOrderByCreatedAtDesc();
}
/**
* 통계 조회
*/
@Transactional(readOnly = true)
public Map<String, Object> getHistoryStats() {
Map<String, Object> stats = new LinkedHashMap<>();
stats.put("totalCount", historyRepository.count());
stats.put("completedCount", historyRepository.countByExecutionStatus("COMPLETED"));
stats.put("failedCount", historyRepository.countByExecutionStatus("FAILED"));
stats.put("runningCount", historyRepository.countByExecutionStatus("STARTED"));
stats.put("overlapCount", historyRepository.countByHasOverlapTrue());
return stats;
}
/**
* API 응답시간 통계 (BatchApiLog 집계)
*/
@Transactional(readOnly = true)
public Map<String, Object> getApiStats(Long jobExecutionId) {
List<Object[]> results = apiLogRepository.getApiStatsByJobExecutionId(jobExecutionId);
if (results.isEmpty() || results.get(0) == null) {
return null;
}
Object[] row = results.get(0);
Map<String, Object> stats = new LinkedHashMap<>();
stats.put("callCount", row[0]);
stats.put("totalMs", row[1]);
stats.put("avgMs", row[2]);
stats.put("maxMs", row[3]);
stats.put("minMs", row[4]);
return stats;
}
/**
* 재수집 실행 전: 현재 last_success_date 조회 (복원용)
*/
@Transactional(readOnly = true)
public LocalDateTime getLastSuccessDate(String apiKey) {
return lastExecutionRepository.findById(apiKey)
.map(BatchLastExecution::getLastSuccessDate)
.orElse(null);
}
/**
* 재수집 실행 후: Tasklet이 업데이트한 last_success_date를 원래 값으로 복원
* 재수집은 과거 데이터 재처리이므로 last_success_date를 변경하면 안 됨
*/
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void restoreLastSuccessDate(String apiKey, LocalDateTime originalDate) {
if (originalDate == null) return;
lastExecutionRepository.findById(apiKey).ifPresent(lastExec -> {
LocalDateTime beforeDate = lastExec.getLastSuccessDate();
lastExec.setLastSuccessDate(originalDate);
lastExec.setUpdatedAt(LocalDateTime.now());
lastExecutionRepository.save(lastExec);
log.info("[RecollectionHistory] last_success_date 복원: apiKey={}, before={}, after={}",
apiKey, beforeDate, originalDate);
});
}
/**
* 수집 기간 전체 조회
*/
@Transactional(readOnly = true)
public List<BatchCollectionPeriod> getAllCollectionPeriods() {
return periodRepository.findAllByOrderByOrderSeqAsc();
}
/**
* 수집 기간 수정
*/
@Transactional
public BatchCollectionPeriod updateCollectionPeriod(String apiKey,
LocalDateTime rangeFromDate,
LocalDateTime rangeToDate) {
BatchCollectionPeriod period = periodRepository.findById(apiKey)
.orElseGet(() -> new BatchCollectionPeriod(apiKey, rangeFromDate, rangeToDate));
period.setRangeFromDate(rangeFromDate);
period.setRangeToDate(rangeToDate);
return periodRepository.save(period);
}
/**
* 수집 기간 초기화 (rangeFromDate, rangeToDate를 null로)
*/
@Transactional
public void resetCollectionPeriod(String apiKey) {
periodRepository.findById(apiKey).ifPresent(period -> {
period.setRangeFromDate(null);
period.setRangeToDate(null);
periodRepository.save(period);
log.info("[RecollectionHistory] 수집 기간 초기화: apiKey={}", apiKey);
});
}
}