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; import com.snp.batch.service.ScheduleService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.enums.Explode; import io.swagger.v3.oas.annotations.enums.ParameterIn; import io.swagger.v3.oas.annotations.enums.ParameterStyle; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springdoc.core.annotations.ParameterObject; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; import java.io.PrintWriter; import java.time.LocalDateTime; import java.time.format.DateTimeFormatter; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.Objects; @Slf4j @RestController @RequestMapping("/api/batch") @RequiredArgsConstructor @Tag(name = "Batch Management API", description = "배치 작업 실행 및 스케줄 관리 API") public class BatchController { private final BatchService batchService; private final ScheduleService scheduleService; private final RecollectionHistoryService recollectionHistoryService; private final BatchFailedRecordService batchFailedRecordService; private final JobDisplayNameRepository jobDisplayNameRepository; @Operation(summary = "배치 작업 실행", description = "지정된 배치 작업을 즉시 실행합니다. 쿼리 파라미터로 Job Parameters 전달 가능") @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "작업 실행 성공"), @ApiResponse(responseCode = "500", description = "작업 실행 실패") }) @PostMapping("/jobs/{jobName}/execute") public ResponseEntity> executeJob( @Parameter(description = "실행할 배치 작업 이름", required = true, example = "sampleProductImportJob") @PathVariable String jobName, @Parameter(description = "Job Parameters (동적 파라미터)", required = false, example = "?param1=value1¶m2=value2") @RequestParam(required = false) Map params) { log.info("Received request to execute job: {} with params: {}", jobName, params); try { Long executionId = batchService.executeJob(jobName, params); return ResponseEntity.ok(Map.of( "success", true, "message", "Job started successfully", "executionId", executionId )); } catch (Exception e) { log.error("Error executing job: {}", jobName, e); return ResponseEntity.internalServerError().body(Map.of( "success", false, "message", "Failed to start job: " + e.getMessage() )); } } @Operation(summary = "배치 작업 실행", description = "지정된 배치 작업을 즉시 실행합니다. 쿼리 파라미터로 Job Parameters 전달 가능") @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "작업 실행 성공"), @ApiResponse(responseCode = "500", description = "작업 실행 실패") }) @PostMapping("/jobs/{jobName}/executeJobTest") public ResponseEntity> executeJobTest( @Parameter( description = "실행할 배치 작업 이름", required = true,example = "sampleProductImportJob") @PathVariable String jobName, @ParameterObject JobLaunchRequest request ) { Map params = new HashMap<>(); if (request.getStartDate() != null) params.put("startDate", request.getStartDate()); if (request.getStopDate() != null) params.put("stopDate", request.getStopDate()); log.info("Executing job: {} with params: {}", jobName, params); try { Long executionId = batchService.executeJob(jobName, params); return ResponseEntity.ok(Map.of( "success", true, "message", "Job started successfully", "executionId", executionId )); } catch (Exception e) { log.error("Error executing job: {}", jobName, e); return ResponseEntity.internalServerError().body(Map.of( "success", false, "message", "Failed to start job: " + e.getMessage() )); } } @Operation(summary = "배치 작업 목록 조회", description = "등록된 모든 배치 작업 목록을 조회합니다") @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "조회 성공") }) @GetMapping("/jobs") public ResponseEntity> listJobs() { log.debug("Received request to list all jobs"); List jobs = batchService.listAllJobs(); return ResponseEntity.ok(jobs); } @Operation(summary = "배치 작업 실행 이력 조회", description = "특정 배치 작업의 실행 이력을 조회합니다") @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "조회 성공") }) @GetMapping("/jobs/{jobName}/executions") public ResponseEntity> getJobExecutions( @Parameter(description = "배치 작업 이름", required = true, example = "sampleProductImportJob") @PathVariable String jobName) { log.info("Received request to get executions for job: {}", jobName); List executions = batchService.getJobExecutions(jobName); return ResponseEntity.ok(executions); } @Operation(summary = "최근 전체 실행 이력 조회", description = "Job 구분 없이 최근 실행 이력을 조회합니다") @GetMapping("/executions/recent") public ResponseEntity> getRecentExecutions( @Parameter(description = "조회 건수", example = "50") @RequestParam(defaultValue = "50") int limit) { log.debug("Received request to get recent executions: limit={}", limit); List executions = batchService.getRecentExecutions(limit); return ResponseEntity.ok(executions); } @GetMapping("/executions/{executionId}") public ResponseEntity getExecutionDetails(@PathVariable Long executionId) { log.info("Received request to get execution details for: {}", executionId); try { JobExecutionDto execution = batchService.getExecutionDetails(executionId); return ResponseEntity.ok(execution); } catch (Exception e) { log.error("Error getting execution details: {}", executionId, e); return ResponseEntity.notFound().build(); } } @GetMapping("/executions/{executionId}/detail") public ResponseEntity getExecutionDetailWithSteps(@PathVariable Long executionId) { log.info("Received request to get detailed execution for: {}", executionId); try { com.snp.batch.global.dto.JobExecutionDetailDto detail = batchService.getExecutionDetailWithSteps(executionId); return ResponseEntity.ok(detail); } catch (Exception e) { log.error("Error getting detailed execution: {}", executionId, e); return ResponseEntity.notFound().build(); } } @PostMapping("/executions/{executionId}/stop") public ResponseEntity> stopExecution(@PathVariable Long executionId) { log.info("Received request to stop execution: {}", executionId); try { batchService.stopExecution(executionId); return ResponseEntity.ok(Map.of( "success", true, "message", "Execution stop requested" )); } catch (Exception e) { log.error("Error stopping execution: {}", executionId, e); return ResponseEntity.internalServerError().body(Map.of( "success", false, "message", "Failed to stop execution: " + e.getMessage() )); } } @Operation(summary = "스케줄 목록 조회", description = "등록된 모든 스케줄을 조회합니다") @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "조회 성공") }) @GetMapping("/schedules") public ResponseEntity> getSchedules() { log.info("Received request to get all schedules"); List schedules = scheduleService.getAllSchedules(); return ResponseEntity.ok(Map.of( "schedules", schedules, "count", schedules.size() )); } @GetMapping("/schedules/{jobName}") public ResponseEntity getSchedule(@PathVariable String jobName) { log.debug("Received request to get schedule for job: {}", jobName); try { ScheduleResponse schedule = scheduleService.getScheduleByJobName(jobName); return ResponseEntity.ok(schedule); } catch (IllegalArgumentException e) { // 스케줄이 없는 경우 - 정상적인 시나리오 (UI에서 존재 여부 확인용) log.debug("Schedule not found for job: {} (정상 - 존재 확인)", jobName); return ResponseEntity.notFound().build(); } catch (Exception e) { log.error("Error getting schedule for job: {}", jobName, e); return ResponseEntity.notFound().build(); } } @Operation(summary = "스케줄 생성", description = "새로운 배치 작업 스케줄을 등록합니다") @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "생성 성공"), @ApiResponse(responseCode = "500", description = "생성 실패") }) @PostMapping("/schedules") public ResponseEntity> createSchedule( @Parameter(description = "스케줄 생성 요청 데이터", required = true) @RequestBody ScheduleRequest request) { log.info("Received request to create schedule for job: {}", request.getJobName()); try { ScheduleResponse schedule = scheduleService.createSchedule(request); return ResponseEntity.ok(Map.of( "success", true, "message", "Schedule created successfully", "data", schedule )); } catch (Exception e) { log.error("Error creating schedule for job: {}", request.getJobName(), e); return ResponseEntity.internalServerError().body(Map.of( "success", false, "message", "Failed to create schedule: " + e.getMessage() )); } } @PostMapping("/schedules/{jobName}/update") public ResponseEntity> updateSchedule( @PathVariable String jobName, @RequestBody Map request) { log.info("Received request to update schedule for job: {}", jobName); try { String cronExpression = request.get("cronExpression"); String description = request.get("description"); ScheduleResponse schedule = scheduleService.updateSchedule(jobName, cronExpression, description); return ResponseEntity.ok(Map.of( "success", true, "message", "Schedule updated successfully", "data", schedule )); } catch (Exception e) { log.error("Error updating schedule for job: {}", jobName, e); return ResponseEntity.internalServerError().body(Map.of( "success", false, "message", "Failed to update schedule: " + e.getMessage() )); } } @Operation(summary = "스케줄 삭제", description = "배치 작업 스케줄을 삭제합니다") @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "삭제 성공"), @ApiResponse(responseCode = "500", description = "삭제 실패") }) @PostMapping("/schedules/{jobName}/delete") public ResponseEntity> deleteSchedule( @Parameter(description = "배치 작업 이름", required = true) @PathVariable String jobName) { log.info("Received request to delete schedule for job: {}", jobName); try { scheduleService.deleteSchedule(jobName); return ResponseEntity.ok(Map.of( "success", true, "message", "Schedule deleted successfully" )); } catch (Exception e) { log.error("Error deleting schedule for job: {}", jobName, e); return ResponseEntity.internalServerError().body(Map.of( "success", false, "message", "Failed to delete schedule: " + e.getMessage() )); } } @PostMapping("/schedules/{jobName}/toggle") public ResponseEntity> toggleSchedule( @PathVariable String jobName, @RequestBody Map request) { log.info("Received request to toggle schedule for job: {}", jobName); try { Boolean active = request.get("active"); ScheduleResponse schedule = scheduleService.toggleScheduleActive(jobName, active); return ResponseEntity.ok(Map.of( "success", true, "message", "Schedule toggled successfully", "data", schedule )); } catch (Exception e) { log.error("Error toggling schedule for job: {}", jobName, e); return ResponseEntity.internalServerError().body(Map.of( "success", false, "message", "Failed to toggle schedule: " + e.getMessage() )); } } @GetMapping("/timeline") public ResponseEntity getTimeline( @RequestParam String view, @RequestParam String date) { log.debug("Received request to get timeline: view={}, date={}", view, date); try { com.snp.batch.global.dto.TimelineResponse timeline = batchService.getTimeline(view, date); return ResponseEntity.ok(timeline); } catch (Exception e) { log.error("Error getting timeline", e); return ResponseEntity.internalServerError().build(); } } @GetMapping("/dashboard") public ResponseEntity getDashboard() { log.debug("Received request to get dashboard data"); try { com.snp.batch.global.dto.DashboardResponse dashboard = batchService.getDashboardData(); return ResponseEntity.ok(dashboard); } catch (Exception e) { log.error("Error getting dashboard data", e); return ResponseEntity.internalServerError().build(); } } @GetMapping("/timeline/period-executions") public ResponseEntity> getPeriodExecutions( @RequestParam String jobName, @RequestParam String view, @RequestParam String periodKey) { log.info("Received request to get period executions: jobName={}, view={}, periodKey={}", jobName, view, periodKey); try { List executions = batchService.getPeriodExecutions(jobName, view, periodKey); return ResponseEntity.ok(executions); } catch (Exception e) { log.error("Error getting period executions", e); return ResponseEntity.internalServerError().build(); } } // ── Step API 로그 페이징 조회 ───────────────────────────── @Operation(summary = "Step API 호출 로그 페이징 조회", description = "Step 실행의 개별 API 호출 로그를 페이징 + 상태 필터로 조회합니다") @GetMapping("/steps/{stepExecutionId}/api-logs") public ResponseEntity getStepApiLogs( @Parameter(description = "Step 실행 ID", required = true) @PathVariable Long stepExecutionId, @Parameter(description = "페이지 번호 (0부터)") @RequestParam(defaultValue = "0") int page, @Parameter(description = "페이지 크기") @RequestParam(defaultValue = "50") int size, @Parameter(description = "상태 필터 (ALL, SUCCESS, ERROR)") @RequestParam(defaultValue = "ALL") String status) { log.debug("Get step API logs: stepExecutionId={}, page={}, size={}, status={}", stepExecutionId, page, size, status); JobExecutionDetailDto.ApiLogPageResponse response = batchService.getStepApiLogs( stepExecutionId, status, PageRequest.of(page, size)); return ResponseEntity.ok(response); } // ── F1: 강제 종료(Abandon) API ───────────────────────────── @Operation(summary = "오래된 실행 중 목록 조회", description = "지정된 시간(분) 이상 STARTED/STARTING 상태인 실행 목록을 조회합니다") @GetMapping("/executions/stale") public ResponseEntity> getStaleExecutions( @Parameter(description = "임계 시간(분)", example = "60") @RequestParam(defaultValue = "60") int thresholdMinutes) { log.info("Received request to get stale executions: thresholdMinutes={}", thresholdMinutes); List executions = batchService.getStaleExecutions(thresholdMinutes); return ResponseEntity.ok(executions); } @Operation(summary = "실행 강제 종료", description = "특정 실행을 ABANDONED 상태로 강제 변경합니다") @PostMapping("/executions/{executionId}/abandon") public ResponseEntity> abandonExecution(@PathVariable Long executionId) { log.info("Received request to abandon execution: {}", executionId); try { batchService.abandonExecution(executionId); return ResponseEntity.ok(Map.of( "success", true, "message", "Execution abandoned successfully" )); } catch (IllegalArgumentException e) { return ResponseEntity.badRequest().body(Map.of( "success", false, "message", e.getMessage() )); } catch (Exception e) { log.error("Error abandoning execution: {}", executionId, e); return ResponseEntity.internalServerError().body(Map.of( "success", false, "message", "Failed to abandon execution: " + e.getMessage() )); } } @Operation(summary = "오래된 실행 전체 강제 종료", description = "지정된 시간(분) 이상 실행 중인 모든 Job을 ABANDONED로 변경합니다") @PostMapping("/executions/stale/abandon-all") public ResponseEntity> abandonAllStaleExecutions( @Parameter(description = "임계 시간(분)", example = "60") @RequestParam(defaultValue = "60") int thresholdMinutes) { log.info("Received request to abandon all stale executions: thresholdMinutes={}", thresholdMinutes); try { int count = batchService.abandonAllStaleExecutions(thresholdMinutes); return ResponseEntity.ok(Map.of( "success", true, "message", count + "건의 실행이 강제 종료되었습니다", "abandonedCount", count )); } catch (Exception e) { log.error("Error abandoning all stale executions", e); return ResponseEntity.internalServerError().body(Map.of( "success", false, "message", "Failed to abandon stale executions: " + e.getMessage() )); } } // ── F4: 실행 이력 검색 API ───────────────────────────────── @Operation(summary = "실행 이력 검색", description = "조건별 실행 이력 검색 (페이지네이션 지원)") @GetMapping("/executions/search") public ResponseEntity searchExecutions( @Parameter(description = "Job 이름 (콤마 구분, 복수 가능)") @RequestParam(required = false) String jobNames, @Parameter(description = "상태 (필터)", example = "COMPLETED") @RequestParam(required = false) String status, @Parameter(description = "시작일 (yyyy-MM-dd'T'HH:mm:ss)") @RequestParam(required = false) String startDate, @Parameter(description = "종료일 (yyyy-MM-dd'T'HH:mm:ss)") @RequestParam(required = false) String endDate, @Parameter(description = "페이지 번호 (0부터)") @RequestParam(defaultValue = "0") int page, @Parameter(description = "페이지 크기") @RequestParam(defaultValue = "50") int size) { log.debug("Search executions: jobNames={}, status={}, startDate={}, endDate={}, page={}, size={}", jobNames, status, startDate, endDate, page, size); List jobNameList = (jobNames != null && !jobNames.isBlank()) ? java.util.Arrays.stream(jobNames.split(",")) .map(String::trim).filter(s -> !s.isEmpty()).toList() : null; LocalDateTime start = startDate != null ? LocalDateTime.parse(startDate) : null; LocalDateTime end = endDate != null ? LocalDateTime.parse(endDate) : null; ExecutionSearchResponse response = batchService.searchExecutions(jobNameList, status, start, end, page, size); return ResponseEntity.ok(response); } // ── F7: Job 상세 목록 API ────────────────────────────────── @Operation(summary = "Job 상세 목록 조회", description = "모든 Job의 최근 실행 상태 및 스케줄 정보를 조회합니다") @GetMapping("/jobs/detail") public ResponseEntity> getJobsDetail() { log.debug("Received request to get jobs with detail"); List jobs = batchService.getJobsWithDetail(); return ResponseEntity.ok(jobs); } // ── F8: 실행 통계 API ────────────────────────────────────── @Operation(summary = "전체 실행 통계", description = "전체 배치 작업의 일별 실행 통계를 조회합니다") @GetMapping("/statistics") public ResponseEntity getStatistics( @Parameter(description = "조회 기간(일)", example = "30") @RequestParam(defaultValue = "30") int days) { log.debug("Received request to get statistics: days={}", days); ExecutionStatisticsDto stats = batchService.getStatistics(days); return ResponseEntity.ok(stats); } @Operation(summary = "Job별 실행 통계", description = "특정 배치 작업의 일별 실행 통계를 조회합니다") @GetMapping("/statistics/{jobName}") public ResponseEntity getJobStatistics( @Parameter(description = "Job 이름", required = true) @PathVariable String jobName, @Parameter(description = "조회 기간(일)", example = "30") @RequestParam(defaultValue = "30") int days) { log.debug("Received request to get statistics for job: {}, days={}", jobName, days); ExecutionStatisticsDto stats = batchService.getJobStatistics(jobName, days); return ResponseEntity.ok(stats); } // ── 재수집 이력 관리 API ───────────────────────────────────── @Operation(summary = "재수집 이력 목록 조회", description = "필터 조건으로 재수집 이력을 페이징 조회합니다") @GetMapping("/recollection-histories") public ResponseEntity> getRecollectionHistories( @Parameter(description = "API Key") @RequestParam(required = false) String apiKey, @Parameter(description = "Job 이름") @RequestParam(required = false) String jobName, @Parameter(description = "실행 상태") @RequestParam(required = false) String status, @Parameter(description = "시작일 (yyyy-MM-dd'T'HH:mm:ss)") @RequestParam(required = false) String fromDate, @Parameter(description = "종료일 (yyyy-MM-dd'T'HH:mm:ss)") @RequestParam(required = false) String toDate, @Parameter(description = "페이지 번호 (0부터)") @RequestParam(defaultValue = "0") int page, @Parameter(description = "페이지 크기") @RequestParam(defaultValue = "20") int size) { log.debug("Search recollection histories: apiKey={}, jobName={}, status={}, page={}, size={}", apiKey, jobName, status, page, size); LocalDateTime from = fromDate != null ? LocalDateTime.parse(fromDate) : null; LocalDateTime to = toDate != null ? LocalDateTime.parse(toDate) : null; Page histories = recollectionHistoryService .getHistories(apiKey, jobName, status, from, to, PageRequest.of(page, size)); // 목록의 jobExecutionId들로 실패건수 한번에 조회 List jobExecutionIds = histories.getContent().stream() .map(BatchRecollectionHistory::getJobExecutionId) .filter(Objects::nonNull) .toList(); Map failedRecordCounts = recollectionHistoryService .getFailedRecordCounts(jobExecutionIds); Map response = new HashMap<>(); response.put("content", histories.getContent()); response.put("totalElements", histories.getTotalElements()); response.put("totalPages", histories.getTotalPages()); response.put("number", histories.getNumber()); response.put("size", histories.getSize()); response.put("failedRecordCounts", failedRecordCounts); return ResponseEntity.ok(response); } @Operation(summary = "재수집 이력 상세 조회", description = "재수집 이력의 상세 정보 (Step Execution + Collection Period + 중복 이력 + API 통계 포함)") @GetMapping("/recollection-histories/{historyId}") public ResponseEntity> getRecollectionHistoryDetail( @Parameter(description = "이력 ID") @PathVariable Long historyId) { log.debug("Get recollection history detail: historyId={}", historyId); try { Map detail = recollectionHistoryService.getHistoryDetailWithSteps(historyId); return ResponseEntity.ok(detail); } catch (IllegalArgumentException e) { return ResponseEntity.notFound().build(); } } @Operation(summary = "재수집 통계 조회", description = "재수집 실행 통계 및 최근 10건 조회") @GetMapping("/recollection-histories/stats") public ResponseEntity> getRecollectionHistoryStats() { log.debug("Get recollection history stats"); Map stats = recollectionHistoryService.getHistoryStats(); stats.put("recentHistories", recollectionHistoryService.getRecentHistories()); return ResponseEntity.ok(stats); } // ── 마지막 수집 성공일시 모니터링 API ────────────────────────── @Operation(summary = "마지막 수집 성공일시 목록 조회", description = "모든 API의 마지막 수집 성공 일시를 조회합니다. 오래된 순으로 정렬됩니다.") @GetMapping("/last-collections") public ResponseEntity> getLastCollectionStatuses() { log.debug("Received request to get last collection statuses"); List statuses = batchService.getLastCollectionStatuses(); return ResponseEntity.ok(statuses); } // ── 수집 기간 관리 API ─────────────────────────────────────── @Operation(summary = "수집 기간 목록 조회", description = "모든 API의 수집 기간 설정을 조회합니다") @GetMapping("/collection-periods") public ResponseEntity> getCollectionPeriods() { log.debug("Get all collection periods"); return ResponseEntity.ok(recollectionHistoryService.getAllCollectionPeriods()); } @Operation(summary = "수집 기간 수정", description = "특정 API의 수집 기간을 수정합니다") @PostMapping("/collection-periods/{apiKey}/update") public ResponseEntity> updateCollectionPeriod( @Parameter(description = "API Key") @PathVariable String apiKey, @RequestBody Map request) { log.info("Update collection period: apiKey={}", apiKey); try { String rangeFromStr = request.get("rangeFromDate"); String rangeToStr = request.get("rangeToDate"); if (rangeFromStr == null || rangeToStr == null) { return ResponseEntity.badRequest().body(Map.of( "success", false, "message", "rangeFromDate와 rangeToDate는 필수입니다")); } LocalDateTime rangeFrom = LocalDateTime.parse(rangeFromStr); LocalDateTime rangeTo = LocalDateTime.parse(rangeToStr); if (rangeTo.isBefore(rangeFrom)) { return ResponseEntity.badRequest().body(Map.of( "success", false, "message", "rangeToDate는 rangeFromDate보다 이후여야 합니다")); } recollectionHistoryService.updateCollectionPeriod(apiKey, rangeFrom, rangeTo); return ResponseEntity.ok(Map.of( "success", true, "message", "수집 기간이 수정되었습니다")); } catch (Exception e) { log.error("Error updating collection period: apiKey={}", apiKey, e); return ResponseEntity.internalServerError().body(Map.of( "success", false, "message", "수집 기간 수정 실패: " + e.getMessage())); } } @Operation(summary = "수집 기간 초기화", description = "특정 API의 수집 기간을 null로 초기화합니다") @PostMapping("/collection-periods/{apiKey}/reset") public ResponseEntity> resetCollectionPeriod( @Parameter(description = "API Key") @PathVariable String apiKey) { log.info("Reset collection period: apiKey={}", apiKey); try { recollectionHistoryService.resetCollectionPeriod(apiKey); return ResponseEntity.ok(Map.of( "success", true, "message", "수집 기간이 초기화되었습니다")); } catch (Exception e) { log.error("Error resetting collection period: apiKey={}", apiKey, e); return ResponseEntity.internalServerError().body(Map.of( "success", false, "message", "수집 기간 초기화 실패: " + e.getMessage())); } } // ── 실패 레코드 관리 API ────────────────────────────────────── @Operation(summary = "실패 레코드 일괄 RESOLVED 처리", description = "특정 Job의 FAILED 상태 레코드를 일괄 RESOLVED 처리합니다") @PostMapping("/failed-records/resolve") public ResponseEntity> resolveFailedRecords( @RequestBody Map request) { @SuppressWarnings("unchecked") List rawIds = (List) request.get("ids"); if (rawIds == null || rawIds.isEmpty()) { return ResponseEntity.badRequest().body(Map.of( "success", false, "message", "ids는 필수이며 비어있을 수 없습니다")); } List ids = rawIds.stream().map(Integer::longValue).toList(); log.info("Resolve failed records: ids count={}", ids.size()); try { int resolved = batchFailedRecordService.resolveByIds(ids); return ResponseEntity.ok(Map.of( "success", true, "resolvedCount", resolved, "message", resolved + "건의 실패 레코드가 RESOLVED 처리되었습니다")); } catch (Exception e) { log.error("Error resolving failed records", e); return ResponseEntity.internalServerError().body(Map.of( "success", false, "message", "실패 레코드 RESOLVED 처리 실패: " + e.getMessage())); } } @Operation(summary = "실패 레코드 재시도 횟수 초기화", description = "재시도 횟수를 초과한 FAILED 레코드의 retryCount를 0으로 초기화하여 자동 재수집 대상으로 복원합니다") @PostMapping("/failed-records/reset-retry") public ResponseEntity> resetRetryCount( @RequestBody Map request) { @SuppressWarnings("unchecked") List rawIds = (List) request.get("ids"); if (rawIds == null || rawIds.isEmpty()) { return ResponseEntity.badRequest().body(Map.of( "success", false, "message", "ids는 필수이며 비어있을 수 없습니다")); } List ids = rawIds.stream().map(Integer::longValue).toList(); log.info("Reset retry count: ids count={}", ids.size()); try { int reset = batchFailedRecordService.resetRetryCount(ids); return ResponseEntity.ok(Map.of( "success", true, "resetCount", reset, "message", reset + "건의 실패 레코드 재시도 횟수가 초기화되었습니다")); } catch (Exception e) { log.error("Error resetting retry count", e); return ResponseEntity.internalServerError().body(Map.of( "success", false, "message", "재시도 횟수 초기화 실패: " + e.getMessage())); } } // ── 재수집 이력 CSV 내보내기 API ────────────────────────────── @Operation(summary = "재수집 이력 CSV 내보내기", description = "필터 조건으로 재수집 이력을 CSV 파일로 내보냅니다 (최대 10,000건)") @GetMapping("/recollection-histories/export") public void exportRecollectionHistories( @Parameter(description = "API Key") @RequestParam(required = false) String apiKey, @Parameter(description = "Job 이름") @RequestParam(required = false) String jobName, @Parameter(description = "실행 상태") @RequestParam(required = false) String status, @Parameter(description = "시작일 (yyyy-MM-dd'T'HH:mm:ss)") @RequestParam(required = false) String fromDate, @Parameter(description = "종료일 (yyyy-MM-dd'T'HH:mm:ss)") @RequestParam(required = false) String toDate, HttpServletResponse response) throws IOException { log.info("Export recollection histories: apiKey={}, jobName={}, status={}", apiKey, jobName, status); LocalDateTime from = fromDate != null ? LocalDateTime.parse(fromDate) : null; LocalDateTime to = toDate != null ? LocalDateTime.parse(toDate) : null; List histories = recollectionHistoryService .getHistoriesForExport(apiKey, jobName, status, from, to); response.setContentType("text/csv; charset=UTF-8"); response.setHeader("Content-Disposition", "attachment; filename=recollection-histories.csv"); // BOM for Excel UTF-8 response.getOutputStream().write(new byte[]{(byte) 0xEF, (byte) 0xBB, (byte) 0xBF}); DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); PrintWriter writer = response.getWriter(); writer.println("이력ID,API Key,작업명,Job실행ID,수집시작일,수집종료일,상태,실행시작,실행종료,소요시간(ms),읽기,쓰기,스킵,API호출,실행자,사유,실패사유,중복여부,생성일"); for (BatchRecollectionHistory h : histories) { writer.println(String.join(",", safeStr(h.getHistoryId()), safeStr(h.getApiKey()), safeStr(h.getJobName()), safeStr(h.getJobExecutionId()), h.getRangeFromDate() != null ? h.getRangeFromDate().format(formatter) : "", h.getRangeToDate() != null ? h.getRangeToDate().format(formatter) : "", safeStr(h.getExecutionStatus()), h.getExecutionStartTime() != null ? h.getExecutionStartTime().format(formatter) : "", h.getExecutionEndTime() != null ? h.getExecutionEndTime().format(formatter) : "", safeStr(h.getDurationMs()), safeStr(h.getReadCount()), safeStr(h.getWriteCount()), safeStr(h.getSkipCount()), safeStr(h.getApiCallCount()), escapeCsvField(h.getExecutor()), escapeCsvField(h.getRecollectionReason()), escapeCsvField(h.getFailureReason()), h.getHasOverlap() != null ? (h.getHasOverlap() ? "Y" : "N") : "", h.getCreatedAt() != null ? h.getCreatedAt().format(formatter) : "" )); } writer.flush(); } private String safeStr(Object value) { return value != null ? value.toString() : ""; } private String escapeCsvField(String value) { if (value == null) { return ""; } if (value.contains(",") || value.contains("\"") || value.contains("\n")) { return "\"" + value.replace("\"", "\"\"") + "\""; } return value; } // ── Job 한글 표시명 관리 API ────────────────────────────────── @Operation(summary = "Job 표시명 전체 조회", description = "등록된 모든 Job의 한글 표시명을 조회합니다") @GetMapping("/display-names") public ResponseEntity> 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> updateDisplayName( @Parameter(description = "배치 작업 이름", required = true) @PathVariable String jobName, @RequestBody Map 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())); } } }