This commit is contained in:
2026-01-06 21:44:36 +09:00
parent ceec1ad7a9
commit 716cf63f73
98 changed files with 6997 additions and 538 deletions

View File

@@ -0,0 +1,453 @@
package research.loghunter.service;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import research.loghunter.entity.*;
import research.loghunter.repository.*;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.function.Consumer;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@Service
@RequiredArgsConstructor
@Slf4j
public class ScanService {
private final ServerRepository serverRepository;
private final ServerLogPathRepository logPathRepository;
private final PatternRepository patternRepository;
private final ScanHistoryRepository scanHistoryRepository;
private final ErrorLogRepository errorLogRepository;
private final ScannedFileRepository scannedFileRepository;
private final SftpService sftpService;
private final LogParserService logParserService;
// 진행 상황 저장 (serverId -> ScanProgress)
private final ConcurrentHashMap<Long, ScanProgress> progressMap = new ConcurrentHashMap<>();
// 로그 시간 파싱용 패턴들
private static final List<DateTimeFormatter> DATE_FORMATTERS = List.of(
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss,SSS"), // 2026-01-06 09:57:01,114
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss.SSS"), // 2026-01-06 09:57:01.114
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"), // 2026-01-06 09:57:01
DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSS"), // ISO format
DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss"),
DateTimeFormatter.ofPattern("dd-MMM-yyyy HH:mm:ss"), // 06-Jan-2026 09:57:01
DateTimeFormatter.ofPattern("MMM dd, yyyy HH:mm:ss"), // Jan 06, 2026 09:57:01
DateTimeFormatter.ofPattern("MMM dd HH:mm:ss") // Jan 06 09:57:01 (년도 없음)
);
// 로그 라인에서 날짜 추출용 정규식
private static final Pattern DATE_PATTERN = Pattern.compile(
"(\\d{4}-\\d{2}-\\d{2}[T ]\\d{2}:\\d{2}:\\d{2}[,.]?\\d{0,3})" +
"|(\\d{2}-[A-Za-z]{3}-\\d{4} \\d{2}:\\d{2}:\\d{2})" +
"|([A-Za-z]{3} \\d{2},? \\d{4} \\d{2}:\\d{2}:\\d{2})" +
"|([A-Za-z]{3} \\d{2} \\d{2}:\\d{2}:\\d{2})"
);
/**
* 단일 서버 스캔
*/
@Transactional
public ScanResult scanServer(Long serverId, Consumer<ScanProgress> progressCallback) {
Server server = serverRepository.findById(serverId)
.orElseThrow(() -> new RuntimeException("Server not found: " + serverId));
return executeScan(server, progressCallback);
}
/**
* 모든 활성 서버 스캔
*/
@Transactional
public List<ScanResult> scanAllServers(Consumer<ScanProgress> progressCallback) {
List<Server> servers = serverRepository.findByActiveTrue();
return servers.stream()
.map(server -> executeScan(server, progressCallback))
.toList();
}
/**
* 스캔 실행
*/
private ScanResult executeScan(Server server, Consumer<ScanProgress> progressCallback) {
ScanProgress progress = new ScanProgress(server.getId(), server.getName());
progressMap.put(server.getId(), progress);
LocalDateTime scanStartTime = LocalDateTime.now();
// 스캔 이력 생성
ScanHistory history = ScanHistory.builder()
.server(server)
.startedAt(scanStartTime)
.status("RUNNING")
.build();
history = scanHistoryRepository.save(history);
try {
// 1. 서버 시간 체크 (시차 확인)
progress.setMessage("서버 시간 확인 중...");
notifyProgress(progressCallback, progress);
int timeOffsetMinutes = checkAndUpdateServerTimeOffset(server);
log.info("Server {} time offset: {} minutes", server.getId(), timeOffsetMinutes);
// 2. 활성 로그 경로 조회
List<ServerLogPath> logPaths = logPathRepository.findByServerIdAndActiveTrue(server.getId());
if (logPaths.isEmpty()) {
throw new RuntimeException("등록된 로그 경로가 없습니다.");
}
// 3. 활성 패턴 조회
List<research.loghunter.entity.Pattern> patterns = patternRepository.findByActiveTrue();
if (patterns.isEmpty()) {
throw new RuntimeException("등록된 패턴이 없습니다.");
}
progress.setTotalPaths(logPaths.size());
notifyProgress(progressCallback, progress);
AtomicInteger totalFilesScanned = new AtomicInteger(0);
AtomicInteger totalFilesSkipped = new AtomicInteger(0);
AtomicInteger totalErrorsFound = new AtomicInteger(0);
for (ServerLogPath logPath : logPaths) {
progress.setCurrentPath(logPath.getPath());
progress.setMessage("파일 목록 조회 중...");
notifyProgress(progressCallback, progress);
try {
// 4. 해당 경로의 모든 파일 조회 (패턴 매칭)
List<SftpService.RemoteFile> allFiles = sftpService.listAllFiles(server, logPath);
// 5. 스캔 대상 파일 필터링 (중복 제외)
List<SftpService.RemoteFile> filesToScan = new ArrayList<>();
for (SftpService.RemoteFile file : allFiles) {
if (shouldScanFile(server.getId(), logPath.getId(), file)) {
filesToScan.add(file);
} else {
totalFilesSkipped.incrementAndGet();
}
}
progress.setTotalFiles(progress.getTotalFiles() + filesToScan.size());
progress.setMessage(String.format("전체 %d개 중 %d개 스캔 대상 (기존 %d개 스킵)",
allFiles.size(), filesToScan.size(), allFiles.size() - filesToScan.size()));
notifyProgress(progressCallback, progress);
for (SftpService.RemoteFile file : filesToScan) {
progress.setCurrentFile(file.name());
notifyProgress(progressCallback, progress);
try {
// 파일 내용 다운로드
String content = sftpService.downloadFileContent(server, file.path());
// 패턴 매칭
List<LogParserService.MatchResult> matches =
logParserService.parseAndMatch(content, patterns, file.path());
// 에러 저장
for (LogParserService.MatchResult match : matches) {
// 로그에서 시간 파싱 (시차 보정 적용)
LocalDateTime occurredAt = parseLogTime(
match.context(),
file.modifiedAt(),
timeOffsetMinutes
);
ErrorLog errorLog = ErrorLog.builder()
.server(server)
.pattern(match.pattern())
.scanHistory(history)
.filePath(match.filePath())
.lineNumber(match.lineNumber())
.summary(match.summary())
.context(match.context())
.severity(match.severity())
.occurredAt(occurredAt) // 로그 내 시간 (보정됨)
.scannedAt(scanStartTime) // 스캔 시작 시간
.build();
errorLogRepository.save(errorLog);
totalErrorsFound.incrementAndGet();
}
// 스캔 완료된 파일 기록
saveScannedFile(server.getId(), logPath.getId(), file, matches.size());
totalFilesScanned.incrementAndGet();
progress.setScannedFiles(totalFilesScanned.get());
progress.setErrorsFound(totalErrorsFound.get());
notifyProgress(progressCallback, progress);
} catch (Exception e) {
log.warn("Failed to process file {}: {}", file.path(), e.getMessage());
}
}
} catch (Exception e) {
log.warn("Failed to process log path {}: {}", logPath.getPath(), e.getMessage());
}
progress.setScannedPaths(progress.getScannedPaths() + 1);
notifyProgress(progressCallback, progress);
}
// 스캔 완료
history.setStatus("SUCCESS");
history.setFinishedAt(LocalDateTime.now());
history.setFilesScanned(totalFilesScanned.get());
history.setErrorsFound(totalErrorsFound.get());
scanHistoryRepository.save(history);
// 서버 마지막 스캔 시간 업데이트
server.setLastScanAt(LocalDateTime.now());
if (totalErrorsFound.get() > 0) {
server.setLastErrorAt(LocalDateTime.now());
}
serverRepository.save(server);
progress.setStatus("SUCCESS");
progress.setMessage(String.format("완료! (스캔: %d, 스킵: %d, 에러: %d)",
totalFilesScanned.get(), totalFilesSkipped.get(), totalErrorsFound.get()));
notifyProgress(progressCallback, progress);
return new ScanResult(server.getId(), server.getName(), true,
totalFilesScanned.get(), totalErrorsFound.get(), null);
} catch (Exception e) {
log.error("Scan failed for server {}: {}", server.getId(), e.getMessage());
history.setStatus("FAILED");
history.setFinishedAt(LocalDateTime.now());
history.setErrorMessage(e.getMessage());
scanHistoryRepository.save(history);
progress.setStatus("FAILED");
progress.setMessage("스캔 실패: " + e.getMessage());
notifyProgress(progressCallback, progress);
return new ScanResult(server.getId(), server.getName(), false, 0, 0, e.getMessage());
} finally {
progressMap.remove(server.getId());
}
}
/**
* 서버 시간 오프셋 확인 및 업데이트
*/
private int checkAndUpdateServerTimeOffset(Server server) {
try {
SftpService.TimeCheckResult result = sftpService.checkServerTime(server);
if (result.success()) {
server.setTimeOffsetMinutes(result.offsetMinutes());
server.setLastTimeSyncAt(LocalDateTime.now());
serverRepository.save(server);
log.info("Server {} time sync - Server: {}, Local: {}, Offset: {} min",
server.getName(), result.serverTime(), result.localTime(), result.offsetMinutes());
return result.offsetMinutes();
}
} catch (Exception e) {
log.warn("Failed to check server time for {}: {}", server.getId(), e.getMessage());
}
// 실패 시 기존 값 사용 (없으면 0)
return server.getTimeOffsetMinutes() != null ? server.getTimeOffsetMinutes() : 0;
}
/**
* 파일을 스캔해야 하는지 확인 (중복 체크)
* - 파일경로 + 크기가 동일하면 스킵
* - 파일경로는 같은데 크기가 다르면 재스캔
*/
private boolean shouldScanFile(Long serverId, Long logPathId, SftpService.RemoteFile file) {
Optional<ScannedFile> existing = scannedFileRepository.findByServerIdAndFilePathAndFileSize(
serverId, file.path(), file.size());
if (existing.isPresent()) {
log.debug("Skipping already scanned file: {} (size: {})", file.path(), file.size());
return false;
}
// 파일경로는 같은데 크기가 다른 경우 확인 (변경된 파일)
List<ScannedFile> previousVersions = scannedFileRepository.findByServerIdAndFilePath(serverId, file.path());
if (!previousVersions.isEmpty()) {
log.info("File size changed, will rescan: {} (old: {}, new: {})",
file.path(), previousVersions.get(0).getFileSize(), file.size());
}
return true;
}
/**
* 스캔 완료된 파일 정보 저장
*/
private void saveScannedFile(Long serverId, Long logPathId, SftpService.RemoteFile file, int errorCount) {
ScannedFile scannedFile = new ScannedFile();
scannedFile.setServerId(serverId);
scannedFile.setLogPathId(logPathId);
scannedFile.setFilePath(file.path());
scannedFile.setFileName(file.name());
scannedFile.setFileSize(file.size());
scannedFile.setScannedAt(LocalDateTime.now());
scannedFile.setErrorCount(errorCount);
scannedFileRepository.save(scannedFile);
log.debug("Saved scanned file record: {} (size: {}, errors: {})", file.path(), file.size(), errorCount);
}
/**
* 로그 내용에서 시간 파싱 (시차 보정 적용)
*/
private LocalDateTime parseLogTime(String logContent, LocalDateTime fallback, int offsetMinutes) {
if (logContent == null || logContent.isEmpty()) {
return applyOffset(fallback, offsetMinutes);
}
// 첫 줄에서 시간 추출 시도
String[] lines = logContent.split("\n");
for (String line : lines) {
LocalDateTime parsed = extractDateTime(line);
if (parsed != null) {
return applyOffset(parsed, offsetMinutes);
}
}
return applyOffset(fallback, offsetMinutes);
}
/**
* 문자열에서 날짜/시간 추출
*/
private LocalDateTime extractDateTime(String text) {
Matcher matcher = DATE_PATTERN.matcher(text);
if (!matcher.find()) {
return null;
}
String dateStr = matcher.group();
for (DateTimeFormatter formatter : DATE_FORMATTERS) {
try {
return LocalDateTime.parse(dateStr, formatter);
} catch (DateTimeParseException e) {
// 다음 포맷 시도
}
}
return null;
}
/**
* 시간에 오프셋 적용
* offset > 0: 로컬이 서버보다 앞섬 (로그시간 + offset = 로컬시간)
*/
private LocalDateTime applyOffset(LocalDateTime time, int offsetMinutes) {
if (time == null || offsetMinutes == 0) {
return time;
}
return time.plusMinutes(offsetMinutes);
}
/**
* 현재 진행 상황 조회
*/
public ScanProgress getProgress(Long serverId) {
return progressMap.get(serverId);
}
/**
* 스캔 이력 조회
*/
public List<ScanHistory> getHistory(Long serverId) {
return scanHistoryRepository.findByServerIdOrderByStartedAtDesc(serverId);
}
/**
* 스캔된 파일 목록 조회 (서버별)
*/
public List<ScannedFile> getScannedFiles(Long serverId) {
return scannedFileRepository.findByServerId(serverId);
}
/**
* 스캔된 파일 기록 초기화 (재스캔 허용)
*/
@Transactional
public void resetScannedFiles(Long serverId) {
List<ScannedFile> files = scannedFileRepository.findByServerId(serverId);
scannedFileRepository.deleteAll(files);
log.info("Reset {} scanned file records for server {}", files.size(), serverId);
}
private void notifyProgress(Consumer<ScanProgress> callback, ScanProgress progress) {
if (callback != null) {
callback.accept(progress);
}
}
// DTOs
public static class ScanProgress {
private Long serverId;
private String serverName;
private String status = "RUNNING";
private String message;
private String currentPath;
private String currentFile;
private int totalPaths;
private int scannedPaths;
private int totalFiles;
private int scannedFiles;
private int errorsFound;
public ScanProgress(Long serverId, String serverName) {
this.serverId = serverId;
this.serverName = serverName;
}
// Getters and Setters
public Long getServerId() { return serverId; }
public String getServerName() { return serverName; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
public String getMessage() { return message; }
public void setMessage(String message) { this.message = message; }
public String getCurrentPath() { return currentPath; }
public void setCurrentPath(String currentPath) { this.currentPath = currentPath; }
public String getCurrentFile() { return currentFile; }
public void setCurrentFile(String currentFile) { this.currentFile = currentFile; }
public int getTotalPaths() { return totalPaths; }
public void setTotalPaths(int totalPaths) { this.totalPaths = totalPaths; }
public int getScannedPaths() { return scannedPaths; }
public void setScannedPaths(int scannedPaths) { this.scannedPaths = scannedPaths; }
public int getTotalFiles() { return totalFiles; }
public void setTotalFiles(int totalFiles) { this.totalFiles = totalFiles; }
public int getScannedFiles() { return scannedFiles; }
public void setScannedFiles(int scannedFiles) { this.scannedFiles = scannedFiles; }
public int getErrorsFound() { return errorsFound; }
public void setErrorsFound(int errorsFound) { this.errorsFound = errorsFound; }
}
public record ScanResult(
Long serverId,
String serverName,
boolean success,
int filesScanned,
int errorsFound,
String error
) {}
}