엑셀 csv 형식으로 변경

전투시스템 종료시간 추가
메모리 버퍼 정리
This commit is contained in:
2025-06-27 09:32:15 +09:00
parent 1129f4017f
commit a06e625cbd
14 changed files with 526 additions and 103 deletions

View File

@@ -38,6 +38,11 @@ public class BattleController {
public ResponseEntity<BattleEventResponse> getBattleRewardList(){
return ResponseEntity.ok().body( battleEventService.getBattleRewardList());
}
@GetMapping("/game-mode/list")
public ResponseEntity<BattleEventResponse> getGameModeList(){
return ResponseEntity.ok().body( battleEventService.getGameModeList());
}
@PostMapping("/event")
public ResponseEntity<BattleEventResponse> postBattleEvent(

View File

@@ -31,6 +31,8 @@ public class BattleEvent {
// 시작 일자
@JsonProperty("event_start_dt")
private LocalDateTime eventStartDt;
@JsonProperty("event_end_time")
private LocalDateTime eventEndTime;
// 종료 일자
@JsonProperty("event_end_dt")
private LocalDateTime eventEndDt;
@@ -51,6 +53,8 @@ public class BattleEvent {
private Integer rewardGroupId;
@JsonProperty("instance_id")
private Integer instanceId;
@JsonProperty("game_mode_id")
private Integer gameModeId;
private boolean deleted;

View File

@@ -31,6 +31,8 @@ public class BattleEventRequest {
// 시작 일자
@JsonProperty("event_start_dt")
private LocalDateTime eventStartDt;
@JsonProperty("event_end_time")
private LocalDateTime eventEndTime;
// 종료 일자
@JsonProperty("event_end_dt")
private LocalDateTime eventEndDt;
@@ -51,6 +53,8 @@ public class BattleEventRequest {
private Integer rewardGroupId;
@JsonProperty("instance_id")
private Integer instanceId;
@JsonProperty("game_mode_id")
private Integer gameModeId;
@JsonProperty("create_by")
private Long createBy;

View File

@@ -4,6 +4,7 @@ import com.caliverse.admin.domain.entity.BattleEvent;
import com.caliverse.admin.domain.entity.LandAuction;
import com.caliverse.admin.domain.entity.metadata.MetaBattleConfigData;
import com.caliverse.admin.domain.entity.metadata.MetaBattleRewardData;
import com.caliverse.admin.domain.entity.metadata.MetaGameModeData;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.AllArgsConstructor;
@@ -41,6 +42,9 @@ public class BattleEventResponse {
@JsonProperty("battle_reward_list")
private List<MetaBattleRewardData> battleRewardList;
@JsonProperty("game_mode_list")
private List<MetaGameModeData> gameModeList;
private String message;
private int total;

View File

@@ -6,6 +6,8 @@ import com.caliverse.admin.domain.entity.BattleEvent;
import com.caliverse.admin.domain.entity.HISTORYTYPEDETAIL;
import com.caliverse.admin.domain.entity.metadata.MetaBattleConfigData;
import com.caliverse.admin.domain.entity.metadata.MetaBattleRewardData;
import com.caliverse.admin.domain.entity.metadata.MetaGameFFAConfigData;
import com.caliverse.admin.domain.entity.metadata.MetaGameModeData;
import com.caliverse.admin.domain.request.BattleEventRequest;
import com.caliverse.admin.domain.response.BattleEventResponse;
import com.caliverse.admin.dynamodb.service.DynamodbBattleEventService;
@@ -23,10 +25,7 @@ import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.RequestParam;
import java.time.DayOfWeek;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.*;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@@ -75,6 +74,21 @@ public class BattleEventService {
.build();
}
//전투시스템 게임모드 데이터
public BattleEventResponse getGameModeList(){
List<MetaGameModeData> list = metaDataHandler.getMetaGameModeListData();
return BattleEventResponse.builder()
.status(CommonCode.SUCCESS.getHttpStatus())
.result(CommonCode.SUCCESS.getResult())
.resultData(BattleEventResponse.ResultData.builder()
.gameModeList(list)
.build()
)
.build();
}
// 전투시스템 이벤트 조회
public BattleEventResponse getBattleEventList(@RequestParam Map<String, String> requestParam){
@@ -112,6 +126,7 @@ public class BattleEventService {
.build();
}
// 전투시스템 이벤트 저장
@Transactional(transactionManager = "transactionManager")
public BattleEventResponse postBattleEvent(BattleEventRequest battleEventRequest){
if(battleEventRequest.getRepeatType().equals(BattleEvent.BATTLE_REPEAT_TYPE.NONE)){
@@ -130,11 +145,8 @@ public class BattleEventService {
}
battleEventRequest.setInstanceId(CommonConstants.BATTLE_INSTANCE_ID); //고정값으로 넣고 추후 맵정보가 늘어나면 선택하는 걸로
if(battleEventRequest.getRoundTime().equals(0)
|| battleEventRequest.getHotTime().equals(0)
|| battleEventRequest.getConfigId().equals(0)
if(battleEventRequest.getGameModeId().equals(0)
|| battleEventRequest.getRoundCount().equals(0)
|| battleEventRequest.getRewardGroupId().equals(0)
){
return BattleEventResponse.builder()
.status(CommonCode.ERROR.getHttpStatus())
@@ -142,10 +154,15 @@ public class BattleEventService {
.build();
}
int operation_time = calcEndTime(battleEventRequest);
// int operation_time = ffACalcEndTime(battleEventRequest);
LocalTime startTime = battleEventRequest.getEventStartDt().toLocalTime();
LocalTime endTime = battleEventRequest.getEventEndTime().toLocalTime();
long duration = Duration.between(startTime, endTime).getSeconds();
int operation_time = (int) duration;
battleEventRequest.setEventOperationTime(operation_time);
// int is_time = battleMapper.chkTimeOver(battleEventRequest);
List<BattleEvent> existingList = battleMapper.getCheckBattleEventList(battleEventRequest);
boolean isTime = isTimeOverlapping(existingList, battleEventRequest);
if(isTime){
@@ -155,7 +172,7 @@ public class BattleEventService {
.result(ErrorCode.ERROR_BATTLE_EVENT_TIME_OVER.toString())
.build();
}
// dynamoDB 기준으로 id를 관리하려고했으나 종료된 데이터를 주기적으로 지워주다보니 id가 꼬일수 있어서 운영DB기준으로 변경
// int next_event_id = dynamodbBattleEventService.getEventId() + 1;
// battleEventRequest.setEventId(next_event_id);
@@ -188,6 +205,7 @@ public class BattleEventService {
.build();
}
// 전투시스템 이벤트 수정
@Transactional(transactionManager = "transactionManager")
public BattleEventResponse updateBattleEvent(Long id, BattleEventRequest battleEventRequest) {
battleEventRequest.setId(id);
@@ -212,7 +230,7 @@ public class BattleEventService {
.build();
}
int operation_time = calcEndTime(battleEventRequest);
int operation_time = ffACalcEndTime(battleEventRequest);
battleEventRequest.setEventOperationTime(operation_time);
// 일자만 필요해서 UTC시간으로 변경되다보니 한국시간(+9)을 더해서 마지막시간으로 설정
@@ -250,6 +268,7 @@ public class BattleEventService {
.build();
}
// 전투시스템 이벤트 중단
@Transactional(transactionManager = "transactionManager")
public BattleEventResponse updateStopBattleEvent(Long id){
Map<String,Object> map = new HashMap<>();
@@ -291,6 +310,7 @@ public class BattleEventService {
.build();
}
// 전투시스템 이벤트 삭제(사용안함)
@Transactional(transactionManager = "transactionManager")
public BattleEventResponse deleteBattleEvent(BattleEventRequest battleEventRequest){
Map<String,Object> map = new HashMap<>();
@@ -352,14 +372,25 @@ public class BattleEventService {
}
// 이벤트 동작 시간 계산
private int calcEndTime(BattleEventRequest battleEventRequest){
MetaBattleConfigData config = metaDataHandler.getMetaBattleConfigsListData().stream()
.filter(data -> data.getId().equals(battleEventRequest.getConfigId()))
private int ffACalcEndTime(BattleEventRequest battleEventRequest){
MetaGameModeData gameModeData = metaDataHandler.getMetaGameModeListData().stream()
.filter(data -> data.getId().equals(battleEventRequest.getGameModeId()))
.findFirst()
.orElse(null);
if(gameModeData == null) return 0;
MetaGameFFAConfigData config = metaDataHandler.getMetaGameFFAConfigListData().stream()
.filter(data -> data.getId().equals(gameModeData.getModeConfigId()))
.findFirst()
.orElse(null);
// MetaBattleConfigData config = metaDataHandler.getMetaBattleConfigsListData().stream()
// .filter(data -> data.getId().equals(battleEventRequest.getConfigId()))
// .findFirst()
// .orElse(null);
if(config == null) return 0;
int round_time = battleEventRequest.getRoundTime();
int round_time = config.getRoundTime();
int round_count = battleEventRequest.getRoundCount();
int round_wait_time = config.getNextRoundWaitTime();
int result_wait_time = config.getResultUIWaitTime();
@@ -380,12 +411,10 @@ public class BattleEventService {
BattleEvent.BATTLE_REPEAT_TYPE newRepeatType = battleEventRequest.getRepeatType();
return existingList.stream().anyMatch(existingEvent -> {
// 자기 자신은 제외 (수정 시)
if (battleEventRequest.getId() != null && battleEventRequest.getId().equals(existingEvent.getId())) {
return false;
}
// 기존 이벤트 정보
LocalDateTime existingStartDt = existingEvent.getEventStartDt();
LocalDateTime existingEndDt = existingEvent.getEventEndDt();
LocalDate existingStartDate = existingStartDt.toLocalDate();
@@ -395,60 +424,44 @@ public class BattleEventService {
BattleEvent.BATTLE_REPEAT_TYPE existingRepeatType = existingEvent.getRepeatType();
// 1. 두 이벤트가 모두 NONE 타입인 경우
if (newRepeatType == BattleEvent.BATTLE_REPEAT_TYPE.NONE &&
existingRepeatType == BattleEvent.BATTLE_REPEAT_TYPE.NONE) {
// 같은 날짜인지 확인
if (newRepeatType == BattleEvent.BATTLE_REPEAT_TYPE.NONE && existingRepeatType == BattleEvent.BATTLE_REPEAT_TYPE.NONE) {
if (newStartDate.equals(existingStartDate)) {
// 시간이 겹치는지 확인
return !existingStartTime.isAfter(newEndTime) && !newStartTime.isAfter(existingEndTime);
}
return false;
}
// 2. NONE 타입과 DAY 타입 간의 중복 체크
if (newRepeatType == BattleEvent.BATTLE_REPEAT_TYPE.NONE &&
existingRepeatType == BattleEvent.BATTLE_REPEAT_TYPE.DAY) {
// NONE 이벤트의 날짜가 DAY 이벤트의 기간 내에 있는지 확인
if (newRepeatType == BattleEvent.BATTLE_REPEAT_TYPE.NONE && existingRepeatType == BattleEvent.BATTLE_REPEAT_TYPE.DAY) {
if (isDateInRange(newStartDate, existingStartDate, existingEndDate)) {
// 시간이 겹치는지 확인
return !existingStartTime.isAfter(newEndTime) && !newStartTime.isAfter(existingEndTime);
}
return false;
}
// 3. DAY 타입과 NONE 타입 간의 중복 체크
if (newRepeatType == BattleEvent.BATTLE_REPEAT_TYPE.DAY &&
existingRepeatType == BattleEvent.BATTLE_REPEAT_TYPE.NONE) {
// NONE 이벤트의 날짜가 DAY 이벤트의 기간 내에 있는지 확인
if (newRepeatType == BattleEvent.BATTLE_REPEAT_TYPE.DAY && existingRepeatType == BattleEvent.BATTLE_REPEAT_TYPE.NONE) {
if (isDateInRange(existingStartDate, newStartDate, newEndDate)) {
// 시간이 겹치는지 확인
return !existingStartTime.isAfter(newEndTime) && !newStartTime.isAfter(existingEndTime);
}
return false;
}
// 4. 두 이벤트가 모두 DAY 타입인 경우
if (newRepeatType == BattleEvent.BATTLE_REPEAT_TYPE.DAY &&
existingRepeatType == BattleEvent.BATTLE_REPEAT_TYPE.DAY) {
// 날짜 범위가 겹치는지 확인
if (newRepeatType == BattleEvent.BATTLE_REPEAT_TYPE.DAY && existingRepeatType == BattleEvent.BATTLE_REPEAT_TYPE.DAY) {
if (datesOverlap(newStartDate, newEndDate, existingStartDate, existingEndDate)) {
// 시간이 겹치는지 확인
return !existingStartTime.isAfter(newEndTime) && !newStartTime.isAfter(existingEndTime);
}
return false;
}
// 5. NONE 타입과 요일 타입 간의 중복 체크
if (newRepeatType == BattleEvent.BATTLE_REPEAT_TYPE.NONE &&
isWeekdayType(existingRepeatType)) {
// NONE 이벤트의 날짜가 요일 이벤트의 기간 내에 있는지 확인
if (newRepeatType == BattleEvent.BATTLE_REPEAT_TYPE.NONE && isWeekdayType(existingRepeatType)) {
if (isDateInRange(newStartDate, existingStartDate, existingEndDate)) {
// NONE 이벤트의 요일이 요일 이벤트의 요일과 일치하는지 확인
DayOfWeek noneDayOfWeek = newStartDate.getDayOfWeek();
DayOfWeek weekdayType = getDayOfWeekFromRepeatType(existingRepeatType);
if (noneDayOfWeek == weekdayType) {
// 시간이 겹치는지 확인
return !existingStartTime.isAfter(newEndTime) && !newStartTime.isAfter(existingEndTime);
}
}
@@ -456,16 +469,12 @@ public class BattleEventService {
}
// 6. 요일 타입과 NONE 타입 간의 중복 체크
if (isWeekdayType(newRepeatType) &&
existingRepeatType == BattleEvent.BATTLE_REPEAT_TYPE.NONE) {
// NONE 이벤트의 날짜가 요일 이벤트의 기간 내에 있는지 확인
if (isWeekdayType(newRepeatType) && existingRepeatType == BattleEvent.BATTLE_REPEAT_TYPE.NONE) {
if (isDateInRange(existingStartDate, newStartDate, newEndDate)) {
// NONE 이벤트의 요일이 요일 이벤트의 요일과 일치하는지 확인
DayOfWeek noneDayOfWeek = existingStartDate.getDayOfWeek();
DayOfWeek weekdayType = getDayOfWeekFromRepeatType(newRepeatType);
if (noneDayOfWeek == weekdayType) {
// 시간이 겹치는지 확인
return !existingStartTime.isAfter(newEndTime) && !newStartTime.isAfter(existingEndTime);
}
}
@@ -473,13 +482,9 @@ public class BattleEventService {
}
// 7. DAY 타입과 요일 타입 간의 중복 체크
if (newRepeatType == BattleEvent.BATTLE_REPEAT_TYPE.DAY &&
isWeekdayType(existingRepeatType)) {
// 날짜 범위가 겹치는지 확인
if (newRepeatType == BattleEvent.BATTLE_REPEAT_TYPE.DAY && isWeekdayType(existingRepeatType)) {
if (datesOverlap(newStartDate, newEndDate, existingStartDate, existingEndDate)) {
// 날짜 범위 내에 해당 요일이 적어도 하나 있는지 확인
if (hasOverlappingWeekday(newStartDate, newEndDate, existingStartDate, existingEndDate, getDayOfWeekFromRepeatType(existingRepeatType))) {
// 시간이 겹치는지 확인
return !existingStartTime.isAfter(newEndTime) && !newStartTime.isAfter(existingEndTime);
}
}
@@ -487,13 +492,9 @@ public class BattleEventService {
}
// 8. 요일 타입과 DAY 타입 간의 중복 체크
if (isWeekdayType(newRepeatType) &&
existingRepeatType == BattleEvent.BATTLE_REPEAT_TYPE.DAY) {
// 날짜 범위가 겹치는지 확인
if (isWeekdayType(newRepeatType) && existingRepeatType == BattleEvent.BATTLE_REPEAT_TYPE.DAY) {
if (datesOverlap(newStartDate, newEndDate, existingStartDate, existingEndDate)) {
// 날짜 범위 내에 해당 요일이 적어도 하나 있는지 확인
if (hasOverlappingWeekday(newStartDate, newEndDate, existingStartDate, existingEndDate, getDayOfWeekFromRepeatType(newRepeatType))) {
// 시간이 겹치는지 확인
return !existingStartTime.isAfter(newEndTime) && !newStartTime.isAfter(existingEndTime);
}
}
@@ -502,11 +503,8 @@ public class BattleEventService {
// 9. 두 이벤트가 모두 요일 타입인 경우
if (isWeekdayType(newRepeatType) && isWeekdayType(existingRepeatType)) {
// 같은 요일인지 확인
if (newRepeatType == existingRepeatType) {
// 날짜 범위가 겹치는지 확인
if (datesOverlap(newStartDate, newEndDate, existingStartDate, existingEndDate)) {
// 시간이 겹치는지 확인
return !existingStartTime.isAfter(newEndTime) && !newStartTime.isAfter(existingEndTime);
}
}
@@ -558,18 +556,18 @@ public class BattleEventService {
LocalDate overlapEnd = end1.isBefore(end2) ? end1 : end2;
if (overlapStart.isAfter(overlapEnd)) {
return false; // 겹치는 날짜 범위 없음
return false;
}
// 겹치는 날짜 범위 내에 해당 요일이 있는지 확인
LocalDate current = overlapStart;
while (!current.isAfter(overlapEnd)) {
if (current.getDayOfWeek() == dayOfWeek) {
return true; // 해당 요일 발견
return true;
}
current = current.plusDays(1);
}
return false; // 해당 요일 없음
return false;
}
}

View File

@@ -12,8 +12,10 @@ import org.apache.poi.xssf.streaming.SXSSFWorkbook;
import org.apache.poi.xssf.usermodel.XSSFWorkbook;
import org.springframework.stereotype.Service;
import java.io.BufferedWriter;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.OutputStreamWriter;
import java.lang.reflect.Field;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
@@ -26,10 +28,16 @@ import java.util.zip.ZipOutputStream;
@Slf4j
@Service
@RequiredArgsConstructor
public class ExcelService {
private final ExcelProgressTracker progressTracker;
private final DiagnosisService diagnosisService;
// CSV 구분자
private static final String CSV_DELIMITER = ",";
private static final String CSV_QUOTE = "\"";
private static final String CSV_LINE_SEPARATOR = "\n";
private static final Set<String> EXCLUDED_HEADERS = Set.of(
"header.TranId",
"header.Actor.AccountId",
@@ -42,11 +50,6 @@ public class ExcelService {
"body.Infos[0].Domain"
);
public ExcelService(ExcelProgressTracker progressTracker, DiagnosisService diagnosisService) {
this.progressTracker = progressTracker;
this.diagnosisService = diagnosisService;
}
// 멀티 스레드 처리를 위한 스레드 풀
private final ExecutorService executorService = Executors.newFixedThreadPool(
Runtime.getRuntime().availableProcessors()
@@ -73,21 +76,290 @@ public class ExcelService {
int dataSize = data.size();
log.info("Processing {} records for Excel download", dataSize);
if (dataSize <= 10000) {
// 1만건 이하: 단일 엑셀 파일 (XSSFWorkbook 사용)
if (dataSize <= 50000) {
// 5만건 이하: 단일 CSV 파일
log.info("Creating single Excel file");
generateStreamingExcel(response, data, fileName, sheetName, taskId);
} else if (dataSize <= 200000) {
// 1만건 초과 ~ 20만건: 1만건씩 분할하여 ZIP 파일
// generateStreamingExcel(response, data, fileName, sheetName, taskId);
generateStreamingCsv(response, data, fileName, taskId);
} else if (dataSize <= 500000) {
// 5만건 초과 ~ 50만건: 5만건씩 분할하여 ZIP 파일
log.info("Creating multiple Excel files (split by 10,000 records)");
generateMultipleExcelFilesAsZipParallel(response, data, fileName, sheetName, 10000, taskId);
// generateMultipleExcelFilesAsZipParallel(response, data, fileName, sheetName, 10000, taskId);
generateMultipleCsvFilesAsZip(response, data, fileName, 50000, taskId);
} else {
// 20만건 초과: 에러
// 50만건 초과: 에러
throw new IllegalArgumentException(
String.format("데이터가 너무 많습니다. 최대 200,000건까지만 지원합니다. (현재: %,d건)", dataSize));
String.format("데이터가 너무 많습니다. 최대 500,000건까지만 지원합니다. (현재: %,d건)", dataSize));
}
}
/**
* 메인 CSV 생성 메서드
* @param response HttpServletResponse 객체
* @param data 변환할 데이터 객체 리스트
* @param fileName 다운로드될 파일명
* @param taskId 진행률 추적 ID
*/
public void generateCsvToResponse(HttpServletResponse response, List<?> data,
String fileName, String taskId) throws IOException {
if (data == null || data.isEmpty()) {
throw new IllegalArgumentException("데이터가 비어있습니다.");
}
int dataSize = data.size();
log.info("Processing {} records for CSV download", dataSize);
if (dataSize <= 50000) {
// 5만건 이하: 단일 CSV 파일
log.info("Creating single CSV file");
generateStreamingCsv(response, data, fileName, taskId);
} else if (dataSize <= 500000) {
// 5만건 초과 ~ 50만건: 5만건씩 분할하여 ZIP 파일
log.info("Creating multiple CSV files (split by 50,000 records)");
generateMultipleCsvFilesAsZip(response, data, fileName, 50000, taskId);
} else {
// 50만건 초과: 에러
throw new IllegalArgumentException(
String.format("데이터가 너무 많습니다. 최대 500,000건까지만 지원합니다. (현재: %,d건)", dataSize));
}
}
/**
* 스트리밍 방식으로 CSV 생성 (메모리 효율적)
*/
private void generateStreamingCsv(HttpServletResponse response, List<?> data,
String fileName, String taskId) throws IOException {
setupCsvResponseHeaders(response, fileName);
progressTracker.updateProgress(taskId, 35, 100, "헤더 정보 생성중...");
// 헤더 생성
Map<String, String> headers = generateOptimizedHeaders(data);
String[] fieldNames = headers.keySet().toArray(new String[0]);
progressTracker.updateProgress(taskId, 40, 100, "CSV 파일 생성 시작...");
try (OutputStreamWriter writer = new OutputStreamWriter(
response.getOutputStream(), StandardCharsets.UTF_8);
BufferedWriter bufferedWriter = new BufferedWriter(writer, 8192)) {
// UTF-8 BOM 추가 (Excel에서 한글 깨짐 방지)
bufferedWriter.write('\ufeff');
// 헤더 행 작성
writeCsvHeader(bufferedWriter, headers.values());
progressTracker.updateProgress(taskId, 42, 100, "헤더 작성 완료. 데이터 처리 시작...");
// 배치 처리로 데이터 작성
int batchSize = 10000;
int totalDataSize = data.size();
int processedDataSize = 0;
for (int i = 0; i < data.size(); i += batchSize) {
int endIdx = Math.min(i + batchSize, data.size());
List<?> batch = data.subList(i, endIdx);
try {
// 배치를 미리 변환
List<Map<String, Object>> flattenedBatch = preProcessBatch(batch);
// CSV 행 작성
for (Map<String, Object> flatMap : flattenedBatch) {
if (flatMap != null) {
writeCsvRow(bufferedWriter, flatMap, fieldNames);
}
}
processedDataSize += batch.size();
// 진행률 업데이트
int progress = 42 + (int) ((processedDataSize * 53.0) / totalDataSize);
progressTracker.updateProgress(taskId, progress, 100,
String.format("데이터 처리 중... (%,d/%,d)", processedDataSize, totalDataSize));
// 주기적으로 flush
if (processedDataSize % 50000 == 0) {
bufferedWriter.flush();
}
} catch (Exception e) {
log.error("Batch processing failed at index {}: {}", i, e.getMessage(), e);
processedDataSize += batch.size(); // 오류 발생한 배치도 처리된 것으로 간주
}
}
bufferedWriter.flush();
progressTracker.updateProgress(taskId, 100, 100,
String.format("CSV 다운로드 완료! (총 %,d건)", totalDataSize));
log.info("CSV generation completed successfully for taskId: {} ({} rows)",
taskId, totalDataSize);
} catch (IOException e) {
log.error("CSV generation failed for taskId: {}", taskId, e);
progressTracker.updateProgress(taskId, -1, 100, "CSV 생성 실패: " + e.getMessage());
throw e;
} catch (Exception e) {
log.error("Unexpected error during CSV generation for taskId: {}", taskId, e);
progressTracker.updateProgress(taskId, -1, 100, "예상치 못한 오류 발생: " + e.getMessage());
throw new IOException("CSV 생성 중 예상치 못한 오류가 발생했습니다: " + e.getMessage(), e);
}
}
/**
* 병렬 처리로 다중 CSV 파일 ZIP 생성
*/
private void generateMultipleCsvFilesAsZip(HttpServletResponse response, List<?> data,
String fileName, int maxRowsPerFile, String taskId) throws IOException {
setupZipResponseHeaders(response, fileName + ".zip");
progressTracker.updateProgress(taskId, 35, 100, "헤더 정보 생성중...");
Map<String, String> headers = generateOptimizedHeaders(data);
try (ZipOutputStream zipOut = new ZipOutputStream(response.getOutputStream())) {
int totalFiles = (int) Math.ceil((double) data.size() / maxRowsPerFile);
progressTracker.updateProgress(taskId, 40, 100,
String.format("ZIP 파일 생성 준비 중... (총 %d개 파일)", totalFiles));
AtomicInteger completedFiles = new AtomicInteger(0);
// 청크를 병렬로 처리
List<CompletableFuture<CsvFileData>> futures = new ArrayList<>();
for (int fileIndex = 0; fileIndex < totalFiles; fileIndex++) {
int startIndex = fileIndex * maxRowsPerFile;
int endIndex = Math.min(startIndex + maxRowsPerFile, data.size());
List<?> chunk = data.subList(startIndex, endIndex);
final int currentFileIndex = fileIndex;
CompletableFuture<CsvFileData> future = CompletableFuture.supplyAsync(() -> {
try {
String csvFileName = String.format("%s_part%d.csv",
fileName, currentFileIndex + 1);
byte[] csvData = createSingleCsvFileBytes(chunk, headers);
int completed = completedFiles.incrementAndGet();
progressTracker.updateProgress(taskId,
40 + (int) ((completed * 50.0) / totalFiles), 100,
String.format("CSV 파일 완료... (%d/%d)", completed, totalFiles));
return new CsvFileData(csvFileName, csvData, currentFileIndex);
} catch (IOException e) {
throw new RuntimeException(e);
}
}, executorService);
futures.add(future);
}
// 완료된 순서대로 ZIP에 추가
for (int i = 0; i < futures.size(); i++) {
try {
CsvFileData fileData = futures.get(i).get();
ZipEntry zipEntry = new ZipEntry(fileData.fileName);
zipOut.putNextEntry(zipEntry);
zipOut.write(fileData.data);
zipOut.closeEntry();
int zipProgress = 90 + (int) (((i + 1) * 10.0) / totalFiles);
progressTracker.updateProgress(taskId, zipProgress, 100,
String.format("ZIP 압축 완료... (%d/%d)", i + 1, totalFiles));
} catch (Exception e) {
log.error("Error processing file {}: {}", i, e.getMessage(), e);
throw new IOException("CSV file creation failed", e);
}
}
// 인덱스 파일 생성
createIndexFile(zipOut, fileName, totalFiles, data.size(), maxRowsPerFile);
zipOut.finish();
progressTracker.updateProgress(taskId, 100, 100, "ZIP 파일 생성 완료!");
}
}
/**
* CSV 헤더 작성
*/
private void writeCsvHeader(BufferedWriter writer, Collection<String> headers) throws IOException {
boolean first = true;
for (String header : headers) {
if (!first) {
writer.write(CSV_DELIMITER);
}
writer.write(escapeCsvValue(header));
first = false;
}
writer.write(CSV_LINE_SEPARATOR);
}
/**
* CSV 데이터 행 작성
*/
private void writeCsvRow(BufferedWriter writer, Map<String, Object> flatMap, String[] fieldNames) throws IOException {
boolean first = true;
for (String fieldName : fieldNames) {
if (!first) {
writer.write(CSV_DELIMITER);
}
Object value = flatMap.get(fieldName);
writer.write(escapeCsvValue(value != null ? value.toString() : ""));
first = false;
}
writer.write(CSV_LINE_SEPARATOR);
}
/**
* CSV 값 이스케이프 처리
*/
private String escapeCsvValue(String value) {
if (value == null || value.isEmpty()) {
return "";
}
// 쉼표, 개행, 큰따옴표가 포함된 경우 큰따옴표로 감싸기
if (value.contains(CSV_DELIMITER) || value.contains(CSV_LINE_SEPARATOR) || value.contains(CSV_QUOTE)) {
// 큰따옴표는 두 개로 이스케이프
value = value.replace(CSV_QUOTE, CSV_QUOTE + CSV_QUOTE);
return CSV_QUOTE + value + CSV_QUOTE;
}
return value;
}
/**
* 단일 CSV 파일 바이트 생성
*/
private byte[] createSingleCsvFileBytes(List<?> data, Map<String, String> headers) throws IOException {
try (ByteArrayOutputStream baos = new ByteArrayOutputStream();
OutputStreamWriter writer = new OutputStreamWriter(baos, StandardCharsets.UTF_8);
BufferedWriter bufferedWriter = new BufferedWriter(writer)) {
// UTF-8 BOM 추가
bufferedWriter.write('\ufeff');
// 헤더 작성
writeCsvHeader(bufferedWriter, headers.values());
// 데이터 작성
String[] fieldNames = headers.keySet().toArray(new String[0]);
List<Map<String, Object>> flattenedData = preProcessBatch(data);
for (Map<String, Object> flatMap : flattenedData) {
writeCsvRow(bufferedWriter, flatMap, fieldNames);
}
bufferedWriter.flush();
return baos.toByteArray();
}
}
/**
* Object 리스트로부터 엑셀 파일을 HttpServletResponse로 직접 출력
* @param response HttpServletResponse 객체
@@ -142,7 +414,6 @@ public class ExcelService {
String[] fieldNames = headers.keySet().toArray(new String[0]);
progressTracker.updateProgress(taskId, 37, 100, "헤더 정보 완료...");
// SXSSFWorkbook으로 스트리밍 처리
try {
workbook = new XSSFWorkbook();
progressTracker.updateProgress(taskId, 38, 100, "workbook 생성 완료...");
@@ -485,14 +756,40 @@ public class ExcelService {
response.setHeader("Cache-Control", "max-age=0");
}
/**
* CSV 응답 헤더 설정
*/
private void setupCsvResponseHeaders(HttpServletResponse response, String fileName) throws IOException {
try {
String cleanFileName = fileName + ".csv";
String encodedFileName = URLEncoder.encode(fileName + ".csv", StandardCharsets.UTF_8)
.replaceAll("\\+", "%20");
response.setContentType("text/csv; charset=UTF-8");
response.setCharacterEncoding("UTF-8");
response.setHeader("Content-Disposition",
String.format("attachment; filename=\"%s\"; filename*=UTF-8''%s",
cleanFileName, encodedFileName));
response.setHeader("Cache-Control", "max-age=0");
response.setHeader("Pragma", "no-cache");
response.setHeader("Expires", "0");
} catch (Exception e) {
log.error("Failed to setup CSV response headers for fileName: {}", fileName, e);
throw new IOException("응답 헤더 설정 실패: " + e.getMessage(), e);
}
}
/**
* ZIP 파일 응답 헤더 설정
*/
private void setupZipResponseHeaders(HttpServletResponse response, String fileName) throws IOException {
String cleanFileName = fileName.endsWith(".zip") ? fileName : fileName + ".zip";
String encodedFileName = URLEncoder.encode(fileName, StandardCharsets.UTF_8).replaceAll("\\+", "%20");
response.setContentType("application/zip");
response.setHeader("Content-Disposition", "attachment; filename*=UTF-8''" + encodedFileName);
response.setHeader("Content-Disposition",
String.format("attachment; filename=\"%s\"; filename*=UTF-8''%s",
cleanFileName, encodedFileName));
response.setHeader("Cache-Control", "max-age=0");
}
@@ -999,7 +1296,7 @@ public class ExcelService {
int totalRecords, int maxRowsPerFile) throws IOException {
StringBuilder indexContent = new StringBuilder();
indexContent.append("=== 엑셀 파일 다운로드 정보 ===\n\n");
indexContent.append("=== CSV 파일 다운로드 정보 ===\n\n");
indexContent.append("파일명: ").append(baseFileName).append("\n");
indexContent.append("총 데이터 건수: ").append(String.format("%,d", totalRecords)).append("\n");
indexContent.append("분할된 파일 수: ").append(totalFiles).append("\n");
@@ -1017,13 +1314,26 @@ public class ExcelService {
}
indexContent.append("=== 사용 안내 ===\n");
indexContent.append("• 각 엑셀 파일을 개별적으로 열어서 사용하세요.\n");
indexContent.append("• 각 CSV 파일을 개별적으로 열어서 사용하세요.\n");
indexContent.append("• 파일명의 숫자는 데이터 순서를 나타냅니다.\n");
indexContent.append("• 모든 파일의 헤더 구조는 동일합니다.\n");
indexContent.append("• Excel에서 열 때는 '데이터 > 텍스트 나누기'로 열어주세요.\n");
ZipEntry indexEntry = new ZipEntry("📋 파일정보_및_사용안내.txt");
zipOut.putNextEntry(indexEntry);
zipOut.write(indexContent.toString().getBytes(StandardCharsets.UTF_8));
zipOut.closeEntry();
}
private static class CsvFileData {
final String fileName;
final byte[] data;
final int fileIndex;
CsvFileData(String fileName, byte[] data, int fileIndex) {
this.fileName = fileName;
this.data = data;
this.fileIndex = fileIndex;
}
}
}

View File

@@ -31,6 +31,12 @@ public class S3Service {
@Value("${amazon.aws.region}")
private String region;
@Value("${amazon.s3.cloud-front}")
private String cloudFrontUrl;
@Value("${spring.profiles.active}")
private String activeProfile;
public Optional<GetObjectAttributesResponse> getObjectMetadata(String key) {
try {
GetObjectAttributesRequest request = GetObjectAttributesRequest.builder()
@@ -47,10 +53,10 @@ public class S3Service {
}
public String uploadFile(File file, String directoryName, String contentType) throws IOException, S3Exception {
String fileName = UUID.randomUUID().toString() + "-" + file.getName();
String fileName = UUID.randomUUID() + "-" + file.getName();
// S3 객체 키 (경로 + 파일명)
String objectKey = directoryName + "/" + fileName;
String objectKey = String.format("%s/%s/%s", activeProfile, directoryName, fileName);
try {
@@ -65,11 +71,8 @@ public class S3Service {
putObjectRequest,
RequestBody.fromBytes(Files.readAllBytes(file.toPath()))
);
//https://metaverse-myhomeugc-test.s3.us-west-2.amazonaws.com/0002896883264fc9af5be62134939ce4/552ec8a4302348edb3fbf1496811d75f.ugcinfo
return "https://" + bucketName + ".s3." +
s3Client.serviceClientConfiguration().region().toString() +
".amazonaws.com/" + objectKey;
// return "s3://" + bucketName + objectKey;
return cloudFrontUrl + objectKey;
}catch (S3Exception | IOException e) {
throw e;
}

View File

@@ -34,9 +34,13 @@ public class BattleEventAttrib extends DynamoDBAttribBase {
@JsonProperty("start_min")
private Integer startMin;
@JsonProperty("open_duration_minutes")
private Integer openDurationMinutes;
@JsonProperty("end_date")
private String endDate;
//추후 사용안함
@JsonProperty("instance_id")
private Integer instanceId;
@@ -46,16 +50,22 @@ public class BattleEventAttrib extends DynamoDBAttribBase {
@JsonProperty("day_of_week_type")
private HashSet<EDayOfWeekType> dayOfWeekType;
//추후 사용안함
@JsonProperty("ffa_config_data_id")
private Integer configDataId;
//추후 사용안함
@JsonProperty("ffa_reward_group_id")
private Integer rewardGroupId;
@JsonProperty("ffa_hot_time")
private Integer hotTime;
//추후 사용안함
@JsonProperty("round_count")
private Integer roundCount;
@JsonProperty("game_mode_id")
private Integer gameModeId;
}

View File

@@ -23,6 +23,7 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import software.amazon.awssdk.enhanced.dynamodb.Key;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.Comparator;
import java.util.HashSet;
@@ -103,6 +104,8 @@ public class BattleEventRepositoryImpl extends BaseDynamoDBRepository<BattleEven
attrib.setHotTime(battleEventRequest.getHotTime());
attrib.setRoundCount(battleEventRequest.getRoundCount());
attrib.setActive(true);
attrib.setGameModeId(battleEventRequest.getGameModeId());
attrib.setOpenDurationMinutes((int) Duration.ofSeconds(battleEventRequest.getEventOperationTime()).toMinutes());
BattleEventDoc doc = new BattleEventDoc();
doc.setPK(DynamoDBConstants.PK_KEY_BATTLE_EVENT);
@@ -153,6 +156,8 @@ public class BattleEventRepositoryImpl extends BaseDynamoDBRepository<BattleEven
attrib.setHotTime(battleEventRequest.getHotTime());
attrib.setRoundCount(battleEventRequest.getRoundCount());
attrib.setActive(true);
attrib.setGameModeId(battleEventRequest.getGameModeId());
attrib.setOpenDurationMinutes((int) Duration.ofSeconds(battleEventRequest.getEventOperationTime()).toMinutes());
afterDoc.setAttribValue(objectMapper.writeValueAsString(attrib));
afterDoc.setUpdatedDateTime(CommonUtils.convertUTCDate(nowDate));

View File

@@ -109,7 +109,7 @@ public class DynamicScheduler {
if(!baseDate.isBefore(end_dt.toLocalDate())){
change_status = BattleEvent.BATTLE_STATUS.END;
}else{
change_status = BattleEvent.BATTLE_STATUS.WAIT;
change_status = status.equals(BattleEvent.BATTLE_STATUS.STOP) ? BattleEvent.BATTLE_STATUS.STOP : BattleEvent.BATTLE_STATUS.WAIT;
}
log.info("battle event_id: {}, start_dt: {}, end_dt: {}, todayStart: {}, todayEnd: {} STATUS CHANGE {}", event.getId(), start_dt, end_dt, todayStart, todayEnd, change_status);
map.put("status", change_status);

View File

@@ -2,16 +2,19 @@ package com.caliverse.admin.scheduler;
import com.caliverse.admin.domain.entity.InGame;
import com.caliverse.admin.domain.entity.Mail;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.scheduling.config.ScheduledTask;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.util.Iterator;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ScheduledFuture;
@Service
@Slf4j
public class ScheduleService {
private final Map<String, ScheduledFuture<?>> scheduledTasks = new ConcurrentHashMap<>();
@@ -19,6 +22,9 @@ public class ScheduleService {
private final Map<Long, Mail> mailTask = new ConcurrentHashMap<>();
private final Map<Long, InGame> noticeTask = new ConcurrentHashMap<>();
private static final int MAX_MAIL_TASKS = 500;
private static final int MAX_NOTICE_TASKS = 500;
public boolean isTaskExist(String key){
return scheduledTasks.containsKey(key);
}
@@ -35,22 +41,60 @@ public class ScheduleService {
}
}
// public boolean isTaskExist(String type, Long id) {
// switch (type) {
// case "mail" -> {
// return mailTask.containsKey(id);
// }
// case "notice" -> {
// return noticeTask.containsKey(id);
// }
// default -> {
// return false;
// }
// }
// }
//
// public void createTask(Mail mail) {
// mailTask.put(mail.getId(), mail);
// }
public void cleanupCompletedTasks() {
try {
Iterator<Map.Entry<String, ScheduledFuture<?>>> iterator = scheduledTasks.entrySet().iterator();
int removedCount = 0;
while (iterator.hasNext()) {
Map.Entry<String, ScheduledFuture<?>> entry = iterator.next();
ScheduledFuture<?> future = entry.getValue();
if (future.isDone() || future.isCancelled()) {
iterator.remove();
removedCount++;
}
}
if (removedCount > 0) {
log.info("Cleaned up {} completed scheduled tasks. Current task count: {}",
removedCount, scheduledTasks.size());
}
} catch (Exception e) {
log.error("Error during cleanup of completed tasks", e);
}
}
public void cleanupMailTasks() {
if (mailTask.size() > MAX_MAIL_TASKS) {
int toRemove = mailTask.size() - MAX_MAIL_TASKS;
Iterator<Long> iterator = mailTask.keySet().iterator();
int removed = 0;
while (iterator.hasNext() && removed < toRemove) {
iterator.next();
iterator.remove();
removed++;
}
// log.info("Cleaned up {} mail tasks due to size limit", removed);
}
}
public void cleanupNoticeTasks() {
if (noticeTask.size() > MAX_NOTICE_TASKS) {
int toRemove = noticeTask.size() - MAX_NOTICE_TASKS;
Iterator<Long> iterator = noticeTask.keySet().iterator();
int removed = 0;
while (iterator.hasNext() && removed < toRemove) {
iterator.next();
iterator.remove();
removed++;
}
// log.info("Cleaned up {} notice tasks due to size limit", removed);
}
}
}

View File

@@ -9,5 +9,6 @@ public enum SchedulerType {
BATTLE_EVENT,
WEB3,
LAND_OWNER_CHANGES,
DATA_INITIALIZE
DATA_INITIALIZE,
CLEAN_UP
}

View File

@@ -68,6 +68,11 @@ public class ScheduleRunnerPolling {
schedulerManager.executeScheduler(SchedulerType.DATA_INITIALIZE);
}
@Scheduled(fixedRate = 300000)
public void cleanupJob(){
schedulerManager.executeScheduler(SchedulerType.CLEAN_UP);
}
/*
매일 UTC 기준 00시 50분 00초에 실행, (한국 시간 9시 50분) 30분에 돌릴경우 데이터가 다 넘어 오지 않는 경우 있어서 수정처리
이게 가장 먼저 실행 되어야 한다.

View File

@@ -0,0 +1,30 @@
package com.caliverse.admin.scheduler.polling.service;
import com.caliverse.admin.scheduler.CommonScheduler;
import com.caliverse.admin.scheduler.ScheduleService;
import com.caliverse.admin.scheduler.entity.SchedulerType;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
@Slf4j
@Component
public class cleanupScheduler extends CommonScheduler {
private final ScheduleService scheduleService;
public cleanupScheduler(ScheduleService scheduleService) {
this.scheduleService = scheduleService;
}
@Override
protected void executeInternal() {
scheduleService.cleanupCompletedTasks();
scheduleService.cleanupMailTasks();
scheduleService.cleanupNoticeTasks();
}
@Override
public SchedulerType getSchedulerType() {
return SchedulerType.CLEAN_UP;
}
}