아이템 백과사전 export 처리

This commit is contained in:
2025-09-04 14:19:25 +09:00
parent 4d7d4bb266
commit 5c7782de7a
3 changed files with 524 additions and 84 deletions

View File

@@ -1,8 +1,10 @@
package com.caliverse.admin.domain.api;
import com.caliverse.admin.domain.request.LogGenericRequest;
import com.caliverse.admin.domain.response.DictionaryResponse;
import com.caliverse.admin.domain.service.MetaDataService;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@@ -26,4 +28,10 @@ public class DictionaryController {
@RequestParam Map<String, String> requestParams){
return ResponseEntity.ok().body( metaDataService.getItemDictList(requestParams));
}
@GetMapping("/item/excel-export")
public void itemExcelExport(HttpServletResponse response,
@RequestParam Map<String, String> requestParams){
metaDataService.itemExcelExport(response, requestParams);
}
}

View File

@@ -701,9 +701,34 @@ public class ExcelService {
}
private String translateFieldName(String fieldName) {
if (fieldName == null || fieldName.trim().isEmpty()) {
return fieldName;
}
Map<String, String> translations = getFieldTranslations();
return translations.getOrDefault(fieldName.toLowerCase(), fieldName);
// 띄어쓰기로 구분된 경우 각각 번역 후 합치기
if (fieldName.contains(" ")) {
String[] parts = fieldName.split("\\s+"); // 하나 이상의 공백으로 분리
StringBuilder result = new StringBuilder();
for (int i = 0; i < parts.length; i++) {
if (i > 0) {
result.append(" ");
}
String part = parts[i].trim();
if (!part.isEmpty()) {
String translated = translations.getOrDefault(part.toLowerCase(), part);
result.append(translated);
}
}
return result.toString();
}
// 띄어쓰기가 없는 경우 기존 로직
return translations.getOrDefault(fieldName, fieldName);
}
/**
@@ -741,6 +766,42 @@ public class ExcelService {
translations.put("amount", "금액");
translations.put("quantity", "수량");
translations.put("count", "개수");
translations.put("itemId", "아이템 ID");
translations.put("itemName", "아이템명");
translations.put("sellType", "판매시 재화");
translations.put("sellPrice", "판매시 재화량");
translations.put("buyType", "구매시 재화");
translations.put("buyPrice", "구매시 재화량");
translations.put("buyDiscountRate", "구매시 할인율");
translations.put("brand", "개수");
translations.put("typeLarge", "개수");
translations.put("typeSmall", "개수");
translations.put("attib", "속성");
translations.put("attribRandomGroup", "랜덤 그룹");
translations.put("buff", "버프");
translations.put("defaultAttrib", "기본 속성");
translations.put("itemSet", "아이템 세트");
translations.put("rarity", "희귀도");
translations.put("etc", "기타");
translations.put("dressSlotType", "착용 부위");
translations.put("gachaGroupId", "랜덤박스 그룹 ID");
translations.put("linkedLand", "연결된 랜드");
translations.put("productLink", "제품 URL");
translations.put("propSmallType", "제작 아이템 그룹");
translations.put("ugqAction", "UGQ 사용 가능 여부");
translations.put("expire", "만료");
translations.put("expireEndDt", "만료 종료 시간");
translations.put("expireStartDt", "만료 시작 시간");
translations.put("expireTimeSec", "만료 시간 연장 여부");
translations.put("expireType", "아이템 만료 타입");
translations.put("trade", "거래");
translations.put("cartBuy", "상점에서 구매 가능 여부");
translations.put("systemTradable", "상점에서 판매 가능 여부");
translations.put("throwable", "버리기 가능 여부");
translations.put("userTradable", "유저 간 거래 가능 여부");
translations.put("country", "Count");
translations.put("maxCount", "최대 보유 가능 수량");
translations.put("stackMaxCount", "최대 스택 가능 수량");
return translations;
}
@@ -954,6 +1015,9 @@ public class ExcelService {
.stream()
.map(list -> list.get(0))
.collect(Collectors.toList());
}else {
// 일반 객체의 경우 다양성을 위한 스마트 샘플링
sampleData = createSmartSampleForGeneralObjects(sampleData);
}
return generateHeadersFromSampleObjects(sampleData);
@@ -964,38 +1028,186 @@ public class ExcelService {
}
/**
* 최적화된 객체 플래튼화 (캐시 활용)
* 일반 객체를 위한 스마트 샘플링
* - 객체의 다양성을 최대화하여 모든 가능한 필드를 찾아내기 위함
*/
private Map<String, Object> flattenObjectToMapOptimized(Object item) {
Map<String, Object> flatMap = new LinkedHashMap<>();
private List<?> createSmartSampleForGeneralObjects(List<?> data) {
if (data == null || data.isEmpty()) {
return data;
}
if (item == null) return flatMap;
// 데이터가 50개 미만이면 모두 사용
if (data.size() <= 50) {
return data.stream().filter(Objects::nonNull).collect(Collectors.toList());
}
Class<?> clazz = item.getClass();
List<Field> allFields = getCachedFields(clazz);
Set<Object> uniqueSamples = new LinkedHashSet<>();
int step = Math.max(1, data.size() / 20); // 최대 20개 샘플 추출
for (Field field : allFields) {
if (shouldSkipField(field)) continue;
try {
field.setAccessible(true);
Object fieldValue = field.get(item);
String fieldName = field.getName();
if ("message".equals(fieldName)) continue;
if (fieldValue instanceof Map) {
Map<String, Object> nestedMap = (Map<String, Object>) fieldValue;
flattenMapOptimized(nestedMap, fieldName, flatMap);
} else if (fieldValue != null) {
flatMap.put(fieldName, fieldValue);
}
} catch (Exception e) {
// 접근할 수 없는 필드는 무시
// 균등하게 분포된 샘플 선택
for (int i = 0; i < data.size() && uniqueSamples.size() < 20; i += step) {
Object item = data.get(i);
if (item != null) {
uniqueSamples.add(item);
}
}
return flatMap;
// 첫 번째와 마지막 요소는 반드시 포함 (경계값 처리)
if (!data.isEmpty() && data.get(0) != null) {
uniqueSamples.add(data.get(0));
}
if (data.size() > 1 && data.get(data.size() - 1) != null) {
uniqueSamples.add(data.get(data.size() - 1));
}
// 중간 지점들도 추가 (복잡한 객체 구조 발견을 위해)
int quarterPoint = data.size() / 4;
int halfPoint = data.size() / 2;
int threeQuarterPoint = (data.size() * 3) / 4;
if (quarterPoint < data.size() && data.get(quarterPoint) != null) {
uniqueSamples.add(data.get(quarterPoint));
}
if (halfPoint < data.size() && data.get(halfPoint) != null) {
uniqueSamples.add(data.get(halfPoint));
}
if (threeQuarterPoint < data.size() && data.get(threeQuarterPoint) != null) {
uniqueSamples.add(data.get(threeQuarterPoint));
}
return new ArrayList<>(uniqueSamples);
}
/**
* 최적화된 객체 플래튼화 (캐시 활용)
*/
private Map<String, Object> flattenObjectToMapOptimized(Object item) {
if (item == null) {
return new HashMap<>();
}
Map<String, Object> result = new HashMap<>();
try {
Class<?> clazz = item.getClass();
List<Field> fields = getCachedFields(clazz);
for (Field field : fields) {
if (shouldSkipField(field)) {
continue;
}
field.setAccessible(true);
Object value = field.get(item);
String fieldName = field.getName();
if (value == null) {
result.put(fieldName, null);
continue;
}
// Map 타입 처리
if (value instanceof Map) {
Map<String, Object> mapValue = (Map<String, Object>) value;
flattenMapOptimized(mapValue, fieldName, result);
}
// Collection 타입 처리
else if (value instanceof Collection) {
Collection<?> collection = (Collection<?>) value;
handleCollectionFieldOptimized(collection, fieldName, result);
}
// 배열 타입 처리
else if (value.getClass().isArray()) {
handleArrayFieldOptimized(value, fieldName, result);
}
// 중첩 객체 처리
else if (isComplexObject(value)) {
Map<String, Object> nestedMap = flattenObjectToMapOptimized(value);
for (Map.Entry<String, Object> entry : nestedMap.entrySet()) {
result.put(fieldName + "." + entry.getKey(), entry.getValue());
}
}
// 기본 타입 및 단순 객체
else {
result.put(fieldName, value);
}
}
} catch (IllegalAccessException e) {
log.error("Error accessing field during optimized object flattening", e);
}
return result;
}
private void handleCollectionFieldOptimized(Collection<?> collection, String fieldName, Map<String, Object> result) {
if (collection.isEmpty()) {
result.put(fieldName, "[]");
return;
}
int index = 0;
int maxItems = 50; // 성능을 위해 50개로 제한
for (Object item : collection) {
if (index >= maxItems) {
result.put(fieldName + "[...]", String.format("더 많은 항목이 있음 (총 %d개 중 %d개만 표시)",
collection.size(), maxItems));
break;
}
if (item == null) {
result.put(fieldName + "[" + index + "]", null);
} else if (item instanceof Map) {
Map<String, Object> mapItem = (Map<String, Object>) item;
flattenMapOptimized(mapItem, fieldName + "[" + index + "]", result);
} else if (isComplexObject(item)) {
Map<String, Object> nestedMap = flattenObjectToMapOptimized(item);
for (Map.Entry<String, Object> entry : nestedMap.entrySet()) {
result.put(fieldName + "[" + index + "]." + entry.getKey(), entry.getValue());
}
} else {
result.put(fieldName + "[" + index + "]", item);
}
index++;
}
}
/**
* 최적화된 배열 필드 처리
*/
private void handleArrayFieldOptimized(Object arrayValue, String fieldName, Map<String, Object> result) {
int length = java.lang.reflect.Array.getLength(arrayValue);
if (length == 0) {
result.put(fieldName, "[]");
return;
}
int maxItems = 50; // 성능을 위해 50개로 제한
for (int i = 0; i < Math.min(length, maxItems); i++) {
Object item = java.lang.reflect.Array.get(arrayValue, i);
if (item == null) {
result.put(fieldName + "[" + i + "]", null);
} else if (item instanceof Map) {
Map<String, Object> mapItem = (Map<String, Object>) item;
flattenMapOptimized(mapItem, fieldName + "[" + i + "]", result);
} else if (isComplexObject(item)) {
Map<String, Object> nestedMap = flattenObjectToMapOptimized(item);
for (Map.Entry<String, Object> entry : nestedMap.entrySet()) {
result.put(fieldName + "[" + i + "]." + entry.getKey(), entry.getValue());
}
} else {
result.put(fieldName + "[" + i + "]", item);
}
}
if (length > maxItems) {
result.put(fieldName + "[...]", String.format("더 많은 항목이 있음 (총 %d개 중 %d개만 표시)",
length, maxItems));
}
}
/**
@@ -1168,34 +1380,79 @@ public class ExcelService {
/**
* Object 리스트의 모든 객체를 검사하여 헤더 생성 (Map 필드는 자동으로 펼치기)
* @param data 데이터 리스트
* @return 모든 필드를 포함한 헤더 Map
*/
private Map<String, String> generateHeadersFromSampleObjects(List<?> data) {
LinkedHashMap<String, String> headers = new LinkedHashMap<>();
Set<String> allFieldKeys = new LinkedHashSet<>();
if (data == null || data.isEmpty()) {
return headers;
private Map<String, String> generateHeadersFromSampleObjects(List<?> sampleData) {
if (sampleData == null || sampleData.isEmpty()) {
log.warn("Sample data is null or empty for header generation");
return new LinkedHashMap<>();
}
for (Object item : data) {
if (item != null) {
Map<String, Object> flatMap = flattenObjectToMapOptimized(item);
allFieldKeys.addAll(flatMap.keySet());
Set<String> allKeys = new LinkedHashSet<>();
try {
// 각 샘플 객체를 펼쳐서 모든 가능한 키 수집
for (Object item : sampleData) {
if (item != null) {
Map<String, Object> flattened = flattenObjectToMapOptimized(item);
// 제외할 헤더들 필터링
Set<String> filteredKeys = flattened.keySet().stream()
.filter(key -> !EXCLUDED_HEADERS.contains(key))
.collect(Collectors.toSet());
allKeys.addAll(filteredKeys);
}
}
// 키를 읽기 쉬운 헤더명으로 변환
Map<String, String> headers = new LinkedHashMap<>();
// 키를 논리적 순서로 정렬 (기본 필드 -> 중첩 객체 필드 -> 배열/컬렉션)
List<String> sortedKeys = allKeys.stream()
.sorted(this::compareHeaderKeys)
.toList();
for (String key : sortedKeys) {
String headerName = convertFlatKeyToHeader(key);
headers.put(key, headerName);
}
log.info("Generated {} headers from {} sample objects", headers.size(), sampleData.size());
return headers;
} catch (Exception e) {
log.error("Error generating headers from sample objects", e);
return generateFallbackHeaders(sampleData);
}
for (String key : allFieldKeys) {
if (EXCLUDED_HEADERS.contains(key)) continue;
String headerName = convertFlatKeyToHeader(key);
headers.put(key, headerName);
}
return headers;
}
private int compareHeaderKeys(String key1, String key2) {
// 기본 필드 우선 (점이나 대괄호가 없는 것)
boolean isSimple1 = !key1.contains(".") && !key1.contains("[");
boolean isSimple2 = !key2.contains(".") && !key2.contains("[");
if (isSimple1 && !isSimple2) return -1;
if (!isSimple1 && isSimple2) return 1;
// 중첩 레벨 비교 (점의 개수)
int dotCount1 = (int) key1.chars().filter(ch -> ch == '.').count();
int dotCount2 = (int) key2.chars().filter(ch -> ch == '.').count();
if (dotCount1 != dotCount2) {
return Integer.compare(dotCount1, dotCount2);
}
// 배열 인덱스는 마지막에 (대괄호 포함)
boolean hasArray1 = key1.contains("[");
boolean hasArray2 = key2.contains("[");
if (!hasArray1 && hasArray2) return -1;
if (hasArray1 && !hasArray2) return 1;
// 마지막으로 알파벳 순서
return key1.compareTo(key2);
}
/**
* 펼쳐진 키를 읽기 쉬운 헤더명으로 변환
@@ -1208,58 +1465,185 @@ public class ExcelService {
String[] parts = key.split("\\.");
StringBuilder result = new StringBuilder();
for (int i = 0; i < parts.length; i++) {
if (i > 0) result.append(" ");
try {
for (int i = 0; i < parts.length; i++) {
if (i > 0) result.append(" ");
String part = parts[i].replaceAll("\\[\\d+\\]", "");
String translatedPart = translateFieldName(part);
result.append(translatedPart);
String part = parts[i].replaceAll("\\[\\d+\\]", "");
String translatedPart = translateFieldName(part);
result.append(translatedPart);
}
return result.toString();
}catch (Exception e) {
log.error("Error converting key {} to header name", key, e);
return key;
}
return result.toString();
}
/**
* 객체를 펼친 Map으로 변환
*/
private Map<String, Object> flattenObjectToMap(Object item) {
Map<String, Object> flatMap = new LinkedHashMap<>();
if (item == null) {
return new HashMap<>();
}
if (item == null) return flatMap;
Map<String, Object> result = new HashMap<>();
Class<?> clazz = item.getClass();
List<Field> allFields = getAllFields(clazz);
try {
Class<?> clazz = item.getClass();
List<Field> fields = getCachedFields(clazz);
for (Field field : allFields) {
if (java.lang.reflect.Modifier.isStatic(field.getModifiers()) ||
java.lang.reflect.Modifier.isFinal(field.getModifiers())) {
continue;
}
try {
field.setAccessible(true);
Object fieldValue = field.get(item);
String fieldName = field.getName();
// message 필드는 제외 (이미 header, body로 분리됨)
if ("message".equals(fieldName)) {
for (Field field : fields) {
if (shouldSkipField(field)) {
continue;
}
if (fieldValue instanceof Map) {
// Map 필드를 펼치기
Map<String, Object> nestedMap = (Map<String, Object>) fieldValue;
flattenMap(nestedMap, fieldName, flatMap);
} else if (fieldValue != null) {
// 일반 필드는 그대로 추가
flatMap.put(fieldName, fieldValue);
field.setAccessible(true);
Object value = field.get(item);
String fieldName = field.getName();
if (value == null) {
result.put(fieldName, null);
continue;
}
} catch (Exception e) {
// 접근할 수 없는 필드는 무시
// Map 타입 처리
if (value instanceof Map) {
Map<String, Object> mapValue = (Map<String, Object>) value;
flattenMapOptimized(mapValue, fieldName, result);
}
// Collection 타입 처리
else if (value instanceof Collection) {
Collection<?> collection = (Collection<?>) value;
handleCollectionField(collection, fieldName, result);
}
// 배열 타입 처리
else if (value.getClass().isArray()) {
handleArrayField(value, fieldName, result);
}
// 중첩 객체 처리 (기본 타입이 아닌 경우)
else if (isComplexObject(value)) {
Map<String, Object> nestedMap = flattenObjectToMap(value);
// 중첩 객체의 필드들을 상위 필드명과 함께 펼치기
for (Map.Entry<String, Object> entry : nestedMap.entrySet()) {
result.put(fieldName + "." + entry.getKey(), entry.getValue());
}
}
// 기본 타입 및 단순 객체
else {
result.put(fieldName, value);
}
}
} catch (IllegalAccessException e) {
log.error("Error accessing field during object flattening", e);
}
return result;
}
private void handleCollectionField(Collection<?> collection, String fieldName, Map<String, Object> result) {
if (collection.isEmpty()) {
result.put(fieldName, "[]");
return;
}
int index = 0;
for (Object item : collection) {
if (item == null) {
result.put(fieldName + "[" + index + "]", null);
} else if (item instanceof Map) {
Map<String, Object> mapItem = (Map<String, Object>) item;
flattenMapOptimized(mapItem, fieldName + "[" + index + "]", result);
} else if (isComplexObject(item)) {
Map<String, Object> nestedMap = flattenObjectToMap(item);
for (Map.Entry<String, Object> entry : nestedMap.entrySet()) {
result.put(fieldName + "[" + index + "]." + entry.getKey(), entry.getValue());
}
} else {
result.put(fieldName + "[" + index + "]", item);
}
index++;
// 너무 많은 아이템이 있으면 제한 (성능상 이유)
if (index >= 100) {
result.put(fieldName + "[...]", "더 많은 항목이 있음 (생략됨)");
break;
}
}
}
private void handleArrayField(Object arrayValue, String fieldName, Map<String, Object> result) {
int length = java.lang.reflect.Array.getLength(arrayValue);
if (length == 0) {
result.put(fieldName, "[]");
return;
}
for (int i = 0; i < Math.min(length, 100); i++) { // 최대 100개까지만 처리
Object item = java.lang.reflect.Array.get(arrayValue, i);
if (item == null) {
result.put(fieldName + "[" + i + "]", null);
} else if (item instanceof Map) {
Map<String, Object> mapItem = (Map<String, Object>) item;
flattenMapOptimized(mapItem, fieldName + "[" + i + "]", result);
} else if (isComplexObject(item)) {
Map<String, Object> nestedMap = flattenObjectToMap(item);
for (Map.Entry<String, Object> entry : nestedMap.entrySet()) {
result.put(fieldName + "[" + i + "]." + entry.getKey(), entry.getValue());
}
} else {
result.put(fieldName + "[" + i + "]", item);
}
}
return flatMap;
if (length > 100) {
result.put(fieldName + "[...]", "더 많은 항목이 있음 (생략됨)");
}
}
private boolean isComplexObject(Object value) {
if (value == null) {
return false;
}
Class<?> clazz = value.getClass();
// 기본 타입들
if (clazz.isPrimitive()) {
return false;
}
// 래퍼 타입들과 자주 사용되는 타입들
if (clazz == String.class ||
clazz == Boolean.class ||
clazz == Integer.class ||
clazz == Long.class ||
clazz == Double.class ||
clazz == Float.class ||
clazz == Short.class ||
clazz == Byte.class ||
clazz == Character.class ||
java.util.Date.class.isAssignableFrom(clazz) ||
java.time.temporal.Temporal.class.isAssignableFrom(clazz)) {
return false;
}
// Enum 타입
if (clazz.isEnum()) {
return false;
}
// java.* 패키지의 클래스들은 대부분 기본 타입으로 취급
if (clazz.getName().startsWith("java.")) {
return false;
}
return true;
}
/**

View File

@@ -2,22 +2,29 @@ package com.caliverse.admin.domain.service;
import com.caliverse.admin.domain.datacomponent.MetaDataHandler;
import com.caliverse.admin.domain.entity.*;
import com.caliverse.admin.domain.entity.excel.ExcelBusinessLog;
import com.caliverse.admin.domain.entity.log.GenericLog;
import com.caliverse.admin.domain.entity.metadata.MetaBattleConfigData;
import com.caliverse.admin.domain.entity.metadata.MetaBrandData;
import com.caliverse.admin.domain.entity.metadata.MetaItemData;
import com.caliverse.admin.domain.request.LogGenericRequest;
import com.caliverse.admin.domain.response.BattleEventResponse;
import com.caliverse.admin.domain.response.DictionaryResponse;
import com.caliverse.admin.global.common.annotation.RequestLog;
import com.caliverse.admin.global.common.code.CommonCode;
import com.caliverse.admin.global.common.code.ErrorCode;
import com.caliverse.admin.global.common.exception.RestApiException;
import com.caliverse.admin.global.component.tracker.ExcelProgressTracker;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.mongodb.UncategorizedMongoDbException;
import org.springframework.stereotype.Service;
import org.springframework.web.bind.annotation.RequestParam;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.time.LocalDateTime;
import java.util.*;
import java.util.stream.Collectors;
@Service
@@ -27,6 +34,8 @@ public class MetaDataService {
@Autowired
private MetaDataHandler metaDataHandler;
private final ExcelService excelService;
private final ExcelProgressTracker progressTracker;
public DictionaryResponse getBrandList(){
@@ -85,7 +94,6 @@ public class MetaDataService {
}
}
// 페이징 처리
int currentPageNo = 1;
int currentPageSize = 10; // 기본값
@@ -145,6 +153,46 @@ public class MetaDataService {
.build();
}
public void itemExcelExport(HttpServletResponse response, @RequestParam Map<String, String> requestParam){
String searchType = requestParam.get("search_type");
String searchData = requestParam.get("search_data").trim();
String largeType = requestParam.get("large_type");
String smallType = requestParam.get("small_type");
String brand = requestParam.get("brand");
String gender = requestParam.get("gender");
String taskId = requestParam.get("task_id");
String lang = requestParam.get("lang");
if(taskId == null || taskId.isEmpty() || taskId.equals("undefined")){
log.error("itemExcelExport Excel Export taskId is null or empty");
throw new RestApiException(CommonCode.ERROR.getHttpStatus(), ErrorCode.ERROR_EXCEL_DOWN.toString());
}
LANGUAGETYPE languageType = LANGUAGETYPE.valueOf(lang.toUpperCase());
progressTracker.updateProgress(taskId, 5, 100, "엑셀 생성 준비 중...");
List<MetaItemData> items = metaDataHandler.getMetaItemListData();
List<ItemDict> itemDictList = createItemDictList(items, languageType, searchType, searchData, largeType, smallType, brand, gender);
progressTracker.updateProgress(taskId, 30, 100, "데이터 생성완료");
try{
excelService.generateExcelToResponse(
response,
itemDictList,
"Item Dictionary Data",
"sheet1",
taskId
);
}catch (Exception e){
log.error("Excel Export Create Error", e);
throw new RestApiException(CommonCode.ERROR.getHttpStatus(), ErrorCode.ERROR_EXCEL_DOWN.toString());
}
}
private List<ItemDict> createItemDictList(List<MetaItemData> items, LANGUAGETYPE languageType,
String searchType, String searchData, String largeType,
String smallType, String brand, String gender) {