diff --git a/local_oab_auction_history_item_option.csv b/local_oab_auction_history_item_option.csv new file mode 100644 index 00000000..3ed800e1 --- /dev/null +++ b/local_oab_auction_history_item_option.csv @@ -0,0 +1,5 @@ +option_value,option_value2,option_desc +최대생명력(15레벨:37.50 증가),, +최대마나(19레벨:95 증가),, +보호(5레벨:5 증가),, +돌진,,인간 및 엘프일 때 방패 없이 사용 가능 diff --git a/src/main/java/until/the/eternity/auctionhistory/domain/mapper/OpenApiAuctionHistoryMapper.java b/src/main/java/until/the/eternity/auctionhistory/domain/mapper/OpenApiAuctionHistoryMapper.java index 7df5d25b..0b2b3bf9 100644 --- a/src/main/java/until/the/eternity/auctionhistory/domain/mapper/OpenApiAuctionHistoryMapper.java +++ b/src/main/java/until/the/eternity/auctionhistory/domain/mapper/OpenApiAuctionHistoryMapper.java @@ -6,13 +6,13 @@ import until.the.eternity.common.enums.ItemCategory; import java.time.Instant; -import java.time.temporal.ChronoUnit; import java.util.List; @Mapper(componentModel = "spring", uses = OpenApiItemOptionMapper.class) public interface OpenApiAuctionHistoryMapper { String UNKNOWN_ITEM_NAME = "(Unknown)"; + long KST_OFFSET_SECONDS = 32400; @Named("toEntity(OpenApiAuctionHistoryResponse, ItemCategory)") @Mapping(source = "dateAuctionBuy", target = "dateAuctionBuy", qualifiedByName = "utcToKst") @@ -38,6 +38,6 @@ List toEntityList( @Named("utcToKst") default Instant utcToKst(Instant utcTime) { // API에서 받은 UTC 시간에 9시간을 더하여 KST로 변환 - return utcTime != null ? utcTime.plus(9, ChronoUnit.HOURS) : null; + return utcTime != null ? utcTime.plusSeconds(KST_OFFSET_SECONDS) : null; } } diff --git a/src/main/java/until/the/eternity/auctionhistory/domain/mapper/OpenApiItemOptionMapper.java b/src/main/java/until/the/eternity/auctionhistory/domain/mapper/OpenApiItemOptionMapper.java index 43776379..822131e7 100644 --- a/src/main/java/until/the/eternity/auctionhistory/domain/mapper/OpenApiItemOptionMapper.java +++ b/src/main/java/until/the/eternity/auctionhistory/domain/mapper/OpenApiItemOptionMapper.java @@ -6,53 +6,25 @@ import org.mapstruct.MappingTarget; import until.the.eternity.auctionitemoption.domain.dto.external.OpenApiAuctionItemOptionResponse; import until.the.eternity.auctionitemoption.domain.entity.AuctionHistoryItemOption; - -import java.util.regex.Matcher; -import java.util.regex.Pattern; +import until.the.eternity.common.util.SegongOptionParser; @Mapper(componentModel = "spring") public interface OpenApiItemOptionMapper { - String SEGONG_OPTION_TYPE = "세공 옵션"; - - // 패턴 1: "스킬명 숫자 레벨" 또는 "스킬명 숫자레벨" 형식 (예: "천옷만들기 품질 보너스 3 레벨", "지력 2레벨") - // 그룹1: 스킬명, 그룹2: "숫자 레벨" 또는 "숫자레벨" (option_desc), 그룹3: 숫자 (option_value2) - Pattern PATTERN_LEVEL_SUFFIX = Pattern.compile("^(.+?) ((\\d+) ?레벨)$"); - - // 패턴 2: "스킬명(숫자레벨:효과)" 형식 (예: "매그넘 샷 대미지(20레벨:200 % 증가)") - // 그룹1: 스킬명, 그룹2: 괄호 전체 (option_desc), 그룹3: 숫자 (option_value2) - Pattern PATTERN_LEVEL_PARENTHESIS = Pattern.compile("^(.+?)(\\((\\d+)레벨:.+\\))$"); - @Mapping(target = "id", ignore = true) // PK 자동 생성 @Mapping(target = "auctionHistory", ignore = true) AuctionHistoryItemOption toEntity(OpenApiAuctionItemOptionResponse itemOption); @AfterMapping default void afterMapping( - OpenApiAuctionItemOptionResponse dto, @MappingTarget AuctionHistoryItemOption entity) { - // option_type이 "세공 옵션"인 경우에만 파싱 수행 - if (!SEGONG_OPTION_TYPE.equals(entity.getOptionType()) || entity.getOptionValue() == null) { - return; - } - - String originalValue = entity.getOptionValue(); - - // 패턴 1: "스킬명 숫자 레벨" 또는 "스킬명 숫자레벨" 형식 - Matcher matcher1 = PATTERN_LEVEL_SUFFIX.matcher(originalValue); - if (matcher1.matches()) { - entity.setOptionValue(matcher1.group(1)); - entity.setOptionValue2(matcher1.group(3)); - entity.setOptionDesc(matcher1.group(2)); - return; - } - - // 패턴 2: "스킬명(숫자레벨:효과)" 형식 - Matcher matcher2 = PATTERN_LEVEL_PARENTHESIS.matcher(originalValue); - if (matcher2.matches()) { - entity.setOptionValue(matcher2.group(1)); - entity.setOptionValue2(matcher2.group(3)); - entity.setOptionDesc(matcher2.group(2)); + OpenApiAuctionItemOptionResponse dto, + @MappingTarget AuctionHistoryItemOption.AuctionHistoryItemOptionBuilder entity) { + SegongOptionParser.ParseResult result = + SegongOptionParser.parse(dto.optionType(), dto.optionValue()); + if (result != null) { + entity.optionValue(result.optionValue()); + entity.optionValue2(result.optionValue2()); + entity.optionDesc(result.optionDesc()); } - // 두 패턴 모두 매칭되지 않으면 원본 값 유지 } } diff --git a/src/main/java/until/the/eternity/auctionhistory/domain/service/AuctionHistoryDuplicateChecker.java b/src/main/java/until/the/eternity/auctionhistory/domain/service/AuctionHistoryDuplicateChecker.java index bdb29aaf..dd43726c 100644 --- a/src/main/java/until/the/eternity/auctionhistory/domain/service/AuctionHistoryDuplicateChecker.java +++ b/src/main/java/until/the/eternity/auctionhistory/domain/service/AuctionHistoryDuplicateChecker.java @@ -18,6 +18,8 @@ @RequiredArgsConstructor public class AuctionHistoryDuplicateChecker { + private static final long KST_OFFSET_SECONDS = 32400; + private final AuctionHistoryRepositoryPort repository; /** @@ -89,7 +91,8 @@ public List filterExisting( private boolean isDuplicate( OpenApiAuctionHistoryResponse dto, Instant latestDate, Set existingIds) { - Instant dtoDate = dto.dateAuctionBuy(); + // DB에는 KST(+9h) 기준으로 저장되므로 비교 시 동일하게 변환 + Instant dtoDate = toKst(dto.dateAuctionBuy()); if (dtoDate.isBefore(latestDate)) { return true; @@ -101,4 +104,8 @@ private boolean isDuplicate( return false; } + + private Instant toKst(Instant utcTime) { + return utcTime != null ? utcTime.plusSeconds(KST_OFFSET_SECONDS) : null; + } } diff --git a/src/main/java/until/the/eternity/auctionrealtime/domain/mapper/OpenApiRealtimeItemOptionMapper.java b/src/main/java/until/the/eternity/auctionrealtime/domain/mapper/OpenApiRealtimeItemOptionMapper.java index ae30e87f..494c6a8f 100644 --- a/src/main/java/until/the/eternity/auctionrealtime/domain/mapper/OpenApiRealtimeItemOptionMapper.java +++ b/src/main/java/until/the/eternity/auctionrealtime/domain/mapper/OpenApiRealtimeItemOptionMapper.java @@ -6,54 +6,26 @@ import org.mapstruct.MappingTarget; import until.the.eternity.auctionitem.domain.entity.AuctionRealtimeItemOption; import until.the.eternity.auctionitemoption.domain.dto.external.OpenApiAuctionItemOptionResponse; - -import java.util.regex.Matcher; -import java.util.regex.Pattern; +import until.the.eternity.common.util.SegongOptionParser; /** OpenApiAuctionItemOptionResponse → AuctionRealtimeItemOption Entity 변환 Mapper. */ @Mapper(componentModel = "spring") public interface OpenApiRealtimeItemOptionMapper { - String SEGONG_OPTION_TYPE = "세공 옵션"; - - // 패턴 1: "스킬명 숫자 레벨" 또는 "스킬명 숫자레벨" 형식 (예: "천옷만들기 품질 보너스 3 레벨", "지력 2레벨") - // 그룹1: 스킬명, 그룹2: "숫자 레벨" 또는 "숫자레벨" (option_desc), 그룹3: 숫자 (option_value2) - Pattern PATTERN_LEVEL_SUFFIX = Pattern.compile("^(.+?) ((\\d+) ?레벨)$"); - - // 패턴 2: "스킬명(숫자레벨:효과)" 형식 (예: "매그넘 샷 대미지(20레벨:200 % 증가)") - // 그룹1: 스킬명, 그룹2: 괄호 전체 (option_desc), 그룹3: 숫자 (option_value2) - Pattern PATTERN_LEVEL_PARENTHESIS = Pattern.compile("^(.+?)(\\((\\d+)레벨:.+\\))$"); - @Mapping(target = "id", ignore = true) // PK 자동 생성 @Mapping(target = "auctionRealtimeItem", ignore = true) AuctionRealtimeItemOption toEntity(OpenApiAuctionItemOptionResponse itemOption); @AfterMapping default void afterMapping( - OpenApiAuctionItemOptionResponse dto, @MappingTarget AuctionRealtimeItemOption entity) { - // option_type이 "세공 옵션"인 경우에만 파싱 수행 - if (!SEGONG_OPTION_TYPE.equals(entity.getOptionType()) || entity.getOptionValue() == null) { - return; - } - - String originalValue = entity.getOptionValue(); - - // 패턴 1: "스킬명 숫자 레벨" 또는 "스킬명 숫자레벨" 형식 - Matcher matcher1 = PATTERN_LEVEL_SUFFIX.matcher(originalValue); - if (matcher1.matches()) { - entity.setOptionValue(matcher1.group(1)); - entity.setOptionValue2(matcher1.group(3)); - entity.setOptionDesc(matcher1.group(2)); - return; - } - - // 패턴 2: "스킬명(숫자레벨:효과)" 형식 - Matcher matcher2 = PATTERN_LEVEL_PARENTHESIS.matcher(originalValue); - if (matcher2.matches()) { - entity.setOptionValue(matcher2.group(1)); - entity.setOptionValue2(matcher2.group(3)); - entity.setOptionDesc(matcher2.group(2)); + OpenApiAuctionItemOptionResponse dto, + @MappingTarget AuctionRealtimeItemOption.AuctionRealtimeItemOptionBuilder entity) { + SegongOptionParser.ParseResult result = + SegongOptionParser.parse(dto.optionType(), dto.optionValue()); + if (result != null) { + entity.optionValue(result.optionValue()); + entity.optionValue2(result.optionValue2()); + entity.optionDesc(result.optionDesc()); } - // 두 패턴 모두 매칭되지 않으면 원본 값 유지 } } diff --git a/src/main/java/until/the/eternity/common/util/SegongOptionParser.java b/src/main/java/until/the/eternity/common/util/SegongOptionParser.java new file mode 100644 index 00000000..55fd9a26 --- /dev/null +++ b/src/main/java/until/the/eternity/common/util/SegongOptionParser.java @@ -0,0 +1,94 @@ +package until.the.eternity.common.util; + +import lombok.extern.slf4j.Slf4j; + +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * 세공 옵션의 option_value를 파싱하여 스킬명(option_value), 레벨(option_value2), 설명(option_desc)을 분리하는 유틸리티. + * + *

지원 패턴: + * + *

    + *
  • 패턴 1: "스킬명 숫자 레벨" (예: "지력 2레벨", "행운 6 레벨") + *
  • 패턴 2: "스킬명(숫자레벨:효과)" (예: "매그넘 샷 대미지(20레벨:200 % 증가)") + *
  • 패턴 2 확장: "스킬명(설명)(숫자레벨:효과)" (예: "교역 중 이동 속도(교역 강화 의상)(19레벨:57 % 증가)") + *
  • 패턴 3: "스킬명 설명텍스트" (예: "돌진 인간 및 엘프일 때 방패 없이 사용 가능") + *
+ */ +@Slf4j +public final class SegongOptionParser { + + public static final String SEGONG_OPTION_TYPE = "세공 옵션"; + + // 패턴 1: "스킬명 숫자 레벨" 또는 "스킬명 숫자레벨" 형식 + // 그룹1: 스킬명, 그룹2: "숫자 레벨" 또는 "숫자레벨" (option_desc), 그룹3: 숫자 (option_value2) + private static final Pattern PATTERN_LEVEL_SUFFIX = Pattern.compile("^(.+?) ((\\d+) ?레벨)$"); + + // 패턴 2: "스킬명(숫자레벨:효과)" 또는 "스킬명(설명)(숫자레벨:효과)" 형식 + // lazy(.+?)가 이중 괄호 케이스에서 첫 번째 괄호까지 확장하여 매칭 + // 그룹1: 스킬명(또는 스킬명+설명괄호), 그룹2: 괄호 전체 (option_desc), 그룹3: 숫자 (option_value2) + private static final Pattern PATTERN_LEVEL_PARENTHESIS = + Pattern.compile("^(.+?)(\\((\\d+)레벨:.+\\))$"); + + // 패턴 3: "스킬명 설명텍스트" 형식 (레벨 정보 없이 텍스트 설명만 존재) + // 그룹1: 스킬명, 그룹2: 설명 텍스트 (option_desc) + private static final Pattern PATTERN_DESCRIPTION_ONLY = Pattern.compile("^(.+?) (.+)$"); + + private SegongOptionParser() {} + + public record ParseResult(String optionValue, String optionValue2, String optionDesc) {} + + /** + * 세공 옵션의 option_value를 파싱하여 스킬명, 레벨, 설명을 분리한다. + * + * @param optionType 옵션 타입 + * @param optionValue 원본 옵션 값 + * @return 파싱 결과. 세공 옵션이 아니거나 파싱 불가능한 경우 null + */ + public static ParseResult parse(String optionType, String optionValue) { + if (!SEGONG_OPTION_TYPE.equals(optionType) || optionValue == null) { + if (log.isDebugEnabled()) { + log.debug( + "[SegongOptionParser] Skip parse: optionType='{}', optionValue='{}'", + optionType, + optionValue); + } + return null; + } + + // 패턴 1: "스킬명 숫자 레벨" 또는 "스킬명 숫자레벨" + Matcher matcher1 = PATTERN_LEVEL_SUFFIX.matcher(optionValue); + if (matcher1.matches()) { + if (log.isDebugEnabled()) { + log.debug("[SegongOptionParser] Matched LEVEL_SUFFIX: '{}'", optionValue); + } + return new ParseResult(matcher1.group(1), matcher1.group(3), matcher1.group(2)); + } + + // 패턴 2: "스킬명(숫자레벨:효과)" 또는 "스킬명(설명)(숫자레벨:효과)" + Matcher matcher2 = PATTERN_LEVEL_PARENTHESIS.matcher(optionValue); + if (matcher2.matches()) { + if (log.isDebugEnabled()) { + log.debug("[SegongOptionParser] Matched LEVEL_PARENTHESIS: '{}'", optionValue); + } + return new ParseResult(matcher2.group(1), matcher2.group(3), matcher2.group(2)); + } + + // 패턴 3: "스킬명 설명텍스트" (레벨 정보 없는 텍스트 설명) + Matcher matcher3 = PATTERN_DESCRIPTION_ONLY.matcher(optionValue); + if (matcher3.matches()) { + if (log.isDebugEnabled()) { + log.debug("[SegongOptionParser] Matched DESCRIPTION_ONLY: '{}'", optionValue); + } + return new ParseResult(matcher3.group(1), null, matcher3.group(2)); + } + + if (log.isDebugEnabled()) { + log.debug("[SegongOptionParser] No pattern matched for value: '{}'", optionValue); + } + + return null; + } +} diff --git a/src/main/java/until/the/eternity/metalwareinfo/application/service/MetalwareAttributeInfoService.java b/src/main/java/until/the/eternity/metalwareinfo/application/service/MetalwareAttributeInfoService.java new file mode 100644 index 00000000..edf08164 --- /dev/null +++ b/src/main/java/until/the/eternity/metalwareinfo/application/service/MetalwareAttributeInfoService.java @@ -0,0 +1,29 @@ +package until.the.eternity.metalwareinfo.application.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import until.the.eternity.metalwareinfo.domain.repository.MetalwareAttributeInfoRepositoryPort; +import until.the.eternity.metalwareinfo.interfaces.rest.dto.request.MetalwareAttributeInfoSearchRequest; +import until.the.eternity.metalwareinfo.interfaces.rest.dto.response.MetalwareAttributeInfoResponse; + +@Service +@RequiredArgsConstructor +public class MetalwareAttributeInfoService { + + private final MetalwareAttributeInfoRepositoryPort metalwareAttributeInfoRepository; + + @Transactional + public int sync() { + return metalwareAttributeInfoRepository.syncFromAuctionHistory(); + } + + @Transactional(readOnly = true) + public Page search( + MetalwareAttributeInfoSearchRequest request) { + return metalwareAttributeInfoRepository + .searchByMetalware(request.metalware(), request.toPageable()) + .map(MetalwareAttributeInfoResponse::from); + } +} diff --git a/src/main/java/until/the/eternity/metalwareinfo/application/service/MetalwareInfoService.java b/src/main/java/until/the/eternity/metalwareinfo/application/service/MetalwareInfoService.java index 8135be62..8bc05200 100644 --- a/src/main/java/until/the/eternity/metalwareinfo/application/service/MetalwareInfoService.java +++ b/src/main/java/until/the/eternity/metalwareinfo/application/service/MetalwareInfoService.java @@ -5,6 +5,7 @@ import org.springframework.transaction.annotation.Transactional; import until.the.eternity.metalwareinfo.domain.repository.MetalwareInfoRepositoryPort; import until.the.eternity.metalwareinfo.interfaces.rest.dto.response.MetalwareInfoResponse; +import until.the.eternity.metalwareinfo.interfaces.rest.dto.response.MetalwareInfoSyncResponse; import java.util.List; @@ -19,4 +20,18 @@ public List findAll() { List metalwares = metalwareInfoRepository.findAllMetalwares(); return MetalwareInfoResponse.from(metalwares); } + + @Transactional + public MetalwareInfoSyncResponse syncFromAttributeInfo() { + int levelAttributeUpserted = + metalwareInfoRepository.upsertLevelAttributeFromAttributeInfo(); + int limitBreakLevelUpserted = + metalwareInfoRepository.upsertLimitBreakLevelFromAttributeInfo(); + + return MetalwareInfoSyncResponse.builder() + .levelAttributeUpsertedCount(levelAttributeUpserted) + .limitBreakLevelUpsertedCount(limitBreakLevelUpserted) + .totalUpsertedCount(levelAttributeUpserted + limitBreakLevelUpserted) + .build(); + } } diff --git a/src/main/java/until/the/eternity/metalwareinfo/domain/repository/MetalwareAttributeInfoRepositoryPort.java b/src/main/java/until/the/eternity/metalwareinfo/domain/repository/MetalwareAttributeInfoRepositoryPort.java new file mode 100644 index 00000000..f76d7cf7 --- /dev/null +++ b/src/main/java/until/the/eternity/metalwareinfo/domain/repository/MetalwareAttributeInfoRepositoryPort.java @@ -0,0 +1,12 @@ +package until.the.eternity.metalwareinfo.domain.repository; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import until.the.eternity.metalwareinfo.infrastructure.persistence.MetalwareAttributeInfoEntity; + +public interface MetalwareAttributeInfoRepositoryPort { + + int syncFromAuctionHistory(); + + Page searchByMetalware(String metalware, Pageable pageable); +} diff --git a/src/main/java/until/the/eternity/metalwareinfo/domain/repository/MetalwareInfoRepositoryPort.java b/src/main/java/until/the/eternity/metalwareinfo/domain/repository/MetalwareInfoRepositoryPort.java index 2a193791..839af2a5 100644 --- a/src/main/java/until/the/eternity/metalwareinfo/domain/repository/MetalwareInfoRepositoryPort.java +++ b/src/main/java/until/the/eternity/metalwareinfo/domain/repository/MetalwareInfoRepositoryPort.java @@ -4,4 +4,8 @@ public interface MetalwareInfoRepositoryPort { List findAllMetalwares(); + + int upsertLevelAttributeFromAttributeInfo(); + + int upsertLimitBreakLevelFromAttributeInfo(); } diff --git a/src/main/java/until/the/eternity/metalwareinfo/infrastructure/persistence/MetalwareAttributeInfoEntity.java b/src/main/java/until/the/eternity/metalwareinfo/infrastructure/persistence/MetalwareAttributeInfoEntity.java new file mode 100644 index 00000000..e7cfc98a --- /dev/null +++ b/src/main/java/until/the/eternity/metalwareinfo/infrastructure/persistence/MetalwareAttributeInfoEntity.java @@ -0,0 +1,27 @@ +package until.the.eternity.metalwareinfo.infrastructure.persistence; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table( + name = "metalware_attribute_info", + uniqueConstraints = @UniqueConstraint(columnNames = {"metalware", "level"})) +@Getter +@NoArgsConstructor +public class MetalwareAttributeInfoEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "metalware", nullable = false, length = 50) + private String metalware; + + @Column(name = "level") + private Byte level; + + @Column(name = "attribute", length = 100) + private String attribute; +} diff --git a/src/main/java/until/the/eternity/metalwareinfo/infrastructure/persistence/MetalwareAttributeInfoJpaRepository.java b/src/main/java/until/the/eternity/metalwareinfo/infrastructure/persistence/MetalwareAttributeInfoJpaRepository.java new file mode 100644 index 00000000..98cf4758 --- /dev/null +++ b/src/main/java/until/the/eternity/metalwareinfo/infrastructure/persistence/MetalwareAttributeInfoJpaRepository.java @@ -0,0 +1,32 @@ +package until.the.eternity.metalwareinfo.infrastructure.persistence; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; + +public interface MetalwareAttributeInfoJpaRepository + extends JpaRepository { + + @Modifying + @Query( + value = + """ + INSERT INTO metalware_attribute_info (metalware, level, attribute) + SELECT DISTINCT + option_value, + CAST(option_value2 AS UNSIGNED), + REGEXP_REPLACE(REGEXP_REPLACE(option_desc, '[()]', ''), '[0-9]+ ?레벨:? ?', '') + FROM auction_history_item_option + WHERE option_type = '세공 옵션' + AND option_value2 IS NOT NULL AND option_value2 != '' + AND REGEXP_REPLACE(REGEXP_REPLACE(option_desc, '[()]', ''), '[0-9]+ ?레벨:? ?', '') != '' + ON DUPLICATE KEY UPDATE attribute = VALUES(attribute) + """, + nativeQuery = true) + int syncFromAuctionHistory(); + + Page findByMetalwareContaining( + String metalware, Pageable pageable); +} diff --git a/src/main/java/until/the/eternity/metalwareinfo/infrastructure/persistence/MetalwareAttributeInfoRepositoryPortImpl.java b/src/main/java/until/the/eternity/metalwareinfo/infrastructure/persistence/MetalwareAttributeInfoRepositoryPortImpl.java new file mode 100644 index 00000000..10ff8cf8 --- /dev/null +++ b/src/main/java/until/the/eternity/metalwareinfo/infrastructure/persistence/MetalwareAttributeInfoRepositoryPortImpl.java @@ -0,0 +1,26 @@ +package until.the.eternity.metalwareinfo.infrastructure.persistence; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; +import until.the.eternity.metalwareinfo.domain.repository.MetalwareAttributeInfoRepositoryPort; + +@Repository +@RequiredArgsConstructor +public class MetalwareAttributeInfoRepositoryPortImpl + implements MetalwareAttributeInfoRepositoryPort { + + private final MetalwareAttributeInfoJpaRepository jpaRepository; + + @Override + public int syncFromAuctionHistory() { + return jpaRepository.syncFromAuctionHistory(); + } + + @Override + public Page searchByMetalware( + String metalware, Pageable pageable) { + return jpaRepository.findByMetalwareContaining(metalware, pageable); + } +} diff --git a/src/main/java/until/the/eternity/metalwareinfo/infrastructure/persistence/MetalwareInfoJpaRepository.java b/src/main/java/until/the/eternity/metalwareinfo/infrastructure/persistence/MetalwareInfoJpaRepository.java index 202802f5..bcb58052 100644 --- a/src/main/java/until/the/eternity/metalwareinfo/infrastructure/persistence/MetalwareInfoJpaRepository.java +++ b/src/main/java/until/the/eternity/metalwareinfo/infrastructure/persistence/MetalwareInfoJpaRepository.java @@ -1,6 +1,7 @@ package until.the.eternity.metalwareinfo.infrastructure.persistence; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import java.util.List; @@ -9,4 +10,34 @@ public interface MetalwareInfoJpaRepository extends JpaRepository findAllMetalwares(); + + @Modifying + @Query( + value = + """ + INSERT INTO metalware_info (metalware, level_attribute) + SELECT + metalware, + attribute + FROM metalware_attribute_info + WHERE level = 1 + ON DUPLICATE KEY UPDATE level_attribute = VALUES(level_attribute) + """, + nativeQuery = true) + int upsertLevelAttributeFromAttributeInfo(); + + @Modifying + @Query( + value = + """ + INSERT INTO metalware_info (metalware, limit_break_level) + SELECT + metalware, + MAX(level) AS limit_break_level + FROM metalware_attribute_info + GROUP BY metalware + ON DUPLICATE KEY UPDATE limit_break_level = VALUES(limit_break_level) + """, + nativeQuery = true) + int upsertLimitBreakLevelFromAttributeInfo(); } diff --git a/src/main/java/until/the/eternity/metalwareinfo/infrastructure/persistence/MetalwareInfoRepositoryPortImpl.java b/src/main/java/until/the/eternity/metalwareinfo/infrastructure/persistence/MetalwareInfoRepositoryPortImpl.java index 3ee9a1a3..60ccfde6 100644 --- a/src/main/java/until/the/eternity/metalwareinfo/infrastructure/persistence/MetalwareInfoRepositoryPortImpl.java +++ b/src/main/java/until/the/eternity/metalwareinfo/infrastructure/persistence/MetalwareInfoRepositoryPortImpl.java @@ -15,4 +15,14 @@ public class MetalwareInfoRepositoryPortImpl implements MetalwareInfoRepositoryP public List findAllMetalwares() { return jpaRepository.findAllMetalwares(); } + + @Override + public int upsertLevelAttributeFromAttributeInfo() { + return jpaRepository.upsertLevelAttributeFromAttributeInfo(); + } + + @Override + public int upsertLimitBreakLevelFromAttributeInfo() { + return jpaRepository.upsertLimitBreakLevelFromAttributeInfo(); + } } diff --git a/src/main/java/until/the/eternity/metalwareinfo/interfaces/rest/controller/MetalwareAttributeInfoController.java b/src/main/java/until/the/eternity/metalwareinfo/interfaces/rest/controller/MetalwareAttributeInfoController.java new file mode 100644 index 00000000..2320eed1 --- /dev/null +++ b/src/main/java/until/the/eternity/metalwareinfo/interfaces/rest/controller/MetalwareAttributeInfoController.java @@ -0,0 +1,36 @@ +package until.the.eternity.metalwareinfo.interfaces.rest.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springdoc.core.annotations.ParameterObject; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; +import until.the.eternity.common.response.PageResponseDto; +import until.the.eternity.metalwareinfo.application.service.MetalwareAttributeInfoService; +import until.the.eternity.metalwareinfo.interfaces.rest.dto.request.MetalwareAttributeInfoSearchRequest; +import until.the.eternity.metalwareinfo.interfaces.rest.dto.response.MetalwareAttributeInfoResponse; + +@RestController +@RequestMapping("/api/metalware-attribute-infos") +@RequiredArgsConstructor +@Tag(name = "Metalware Attribute Info", description = "세공 능력치 정보 조회 및 동기화 API") +public class MetalwareAttributeInfoController { + + private final MetalwareAttributeInfoService metalwareAttributeInfoService; + + @Operation(summary = "세공 능력치 정보 동기화", description = "경매 기록에서 세공 능력치 정보를 추출하여 동기화합니다.") + @PostMapping("/sync") + public ResponseEntity sync() { + int syncedCount = metalwareAttributeInfoService.sync(); + return ResponseEntity.ok(syncedCount); + } + + @Operation(summary = "세공 능력치 정보 검색", description = "세공 이름으로 능력치 정보를 검색합니다.") + @GetMapping + public ResponseEntity> search( + @ParameterObject @ModelAttribute @Valid MetalwareAttributeInfoSearchRequest request) { + return ResponseEntity.ok(PageResponseDto.of(metalwareAttributeInfoService.search(request))); + } +} diff --git a/src/main/java/until/the/eternity/metalwareinfo/interfaces/rest/controller/MetalwareInfoController.java b/src/main/java/until/the/eternity/metalwareinfo/interfaces/rest/controller/MetalwareInfoController.java index 271a7e35..47e6c9a3 100644 --- a/src/main/java/until/the/eternity/metalwareinfo/interfaces/rest/controller/MetalwareInfoController.java +++ b/src/main/java/until/the/eternity/metalwareinfo/interfaces/rest/controller/MetalwareInfoController.java @@ -3,11 +3,14 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import until.the.eternity.metalwareinfo.application.service.MetalwareInfoService; import until.the.eternity.metalwareinfo.interfaces.rest.dto.response.MetalwareInfoResponse; +import until.the.eternity.metalwareinfo.interfaces.rest.dto.response.MetalwareInfoSyncResponse; import java.util.List; @@ -25,4 +28,14 @@ public class MetalwareInfoController { public List getAllMetalwareInfos() { return metalwareInfoService.findAll(); } + + @Operation( + summary = "세공 정보 동기화", + description = + "metalware_attribute_info를 기반으로 metalware_info를 업서트합니다. " + + "레벨 1 attribute 동기화와 금속별 최대 레벨(limit_break_level) 동기화를 한 번에 수행합니다.") + @PostMapping("/sync") + public ResponseEntity syncMetalwareInfo() { + return ResponseEntity.ok(metalwareInfoService.syncFromAttributeInfo()); + } } diff --git a/src/main/java/until/the/eternity/metalwareinfo/interfaces/rest/dto/request/MetalwareAttributeInfoSearchRequest.java b/src/main/java/until/the/eternity/metalwareinfo/interfaces/rest/dto/request/MetalwareAttributeInfoSearchRequest.java new file mode 100644 index 00000000..056efcd0 --- /dev/null +++ b/src/main/java/until/the/eternity/metalwareinfo/interfaces/rest/dto/request/MetalwareAttributeInfoSearchRequest.java @@ -0,0 +1,35 @@ +package until.the.eternity.metalwareinfo.interfaces.rest.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import until.the.eternity.common.enums.SortDirection; + +@Schema(description = "세공 능력치 검색 요청 파라미터") +public record MetalwareAttributeInfoSearchRequest( + @Schema(description = "세공 이름 (필수)", example = "행운") @NotBlank String metalware, + @Schema(description = "요청할 페이지 번호 (1부터 시작)", example = "1") @Min(1) Integer page, + @Schema(description = "페이지당 항목 수", example = "25") @Min(1) @Max(100) Integer size, + @Schema(description = "레벨 정렬 방향 (ASC, DESC)", example = "ASC") SortDirection direction) { + + private static final int DEFAULT_PAGE = 1; + private static final int DEFAULT_SIZE = 25; + private static final SortDirection DEFAULT_DIRECTION = SortDirection.ASC; + + public Pageable toPageable() { + int resolvedPage = page != null ? page - 1 : DEFAULT_PAGE - 1; + int resolvedSize = size != null ? size : DEFAULT_SIZE; + SortDirection resolvedDirection = direction != null ? direction : DEFAULT_DIRECTION; + + return PageRequest.of( + resolvedPage, + resolvedSize, + Sort.by( + Sort.Order.asc("metalware"), + new Sort.Order(resolvedDirection.toSpringDirection(), "level"))); + } +} diff --git a/src/main/java/until/the/eternity/metalwareinfo/interfaces/rest/dto/response/MetalwareAttributeInfoResponse.java b/src/main/java/until/the/eternity/metalwareinfo/interfaces/rest/dto/response/MetalwareAttributeInfoResponse.java new file mode 100644 index 00000000..05395076 --- /dev/null +++ b/src/main/java/until/the/eternity/metalwareinfo/interfaces/rest/dto/response/MetalwareAttributeInfoResponse.java @@ -0,0 +1,21 @@ +package until.the.eternity.metalwareinfo.interfaces.rest.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; +import until.the.eternity.metalwareinfo.infrastructure.persistence.MetalwareAttributeInfoEntity; + +@Builder +@Schema(description = "세공 능력치 정보 응답 DTO") +public record MetalwareAttributeInfoResponse( + @Schema(description = "세공", example = "행운") String metalware, + @Schema(description = "레벨", example = "8") Byte level, + @Schema(description = "능력치 효과", example = "12.00 증가") String attribute) { + + public static MetalwareAttributeInfoResponse from(MetalwareAttributeInfoEntity entity) { + return MetalwareAttributeInfoResponse.builder() + .metalware(entity.getMetalware()) + .level(entity.getLevel()) + .attribute(entity.getAttribute()) + .build(); + } +} diff --git a/src/main/java/until/the/eternity/metalwareinfo/interfaces/rest/dto/response/MetalwareInfoSyncResponse.java b/src/main/java/until/the/eternity/metalwareinfo/interfaces/rest/dto/response/MetalwareInfoSyncResponse.java new file mode 100644 index 00000000..77f030b9 --- /dev/null +++ b/src/main/java/until/the/eternity/metalwareinfo/interfaces/rest/dto/response/MetalwareInfoSyncResponse.java @@ -0,0 +1,12 @@ +package until.the.eternity.metalwareinfo.interfaces.rest.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +@Builder +public record MetalwareInfoSyncResponse( + @Schema(description = "레벨별 능력치(level_attribute) 업서트 건수", example = "120") + int levelAttributeUpsertedCount, + @Schema(description = "한계 돌파 레벨(limit_break_level) 업서트 건수", example = "120") + int limitBreakLevelUpsertedCount, + @Schema(description = "총 업서트 건수", example = "240") int totalUpsertedCount) {} diff --git a/src/main/resources/db/migration/V18__migrate_unparsed_segong_option.sql b/src/main/resources/db/migration/V18__migrate_unparsed_segong_option.sql new file mode 100644 index 00000000..17aed5f5 --- /dev/null +++ b/src/main/resources/db/migration/V18__migrate_unparsed_segong_option.sql @@ -0,0 +1,81 @@ +-- V18: 세공 옵션 미파싱 데이터 마이그레이션 +-- V16 이후 신규 유입된 데이터 중 option_value2/option_desc가 NULL 또는 빈 문자열인 세공 옵션을 파싱 +-- +-- 지원 패턴: +-- 패턴 1: "스킬명 숫자 레벨" (예: "지력 2레벨", "행운 6 레벨") +-- 패턴 2: "스킬명(숫자레벨:효과)" (예: "최대생명력(15레벨:37.50 증가)", "매그넘 샷 대미지(20레벨:200 % 증가)") +-- 이중 괄호도 처리: "스킬명(설명)(숫자레벨:효과)" (예: "교역 중 이동 속도(교역 강화 의상)(19레벨:57 % 증가)") +-- 패턴 3: "스킬명 설명텍스트" (예: "돌진 인간 및 엘프일 때 방패 없이 사용 가능") + +-- ============================================================ +-- 1. auction_history_item_option 테이블 +-- ============================================================ + +-- 패턴 1: "스킬명 숫자 레벨" 또는 "스킬명 숫자레벨" 형식 +UPDATE auction_history_item_option +SET option_desc = REGEXP_SUBSTR(option_value, '[0-9]+ ?레벨$'), + option_value2 = REGEXP_SUBSTR(option_value, '[0-9]+(?= ?레벨$)'), + option_value = REGEXP_REPLACE(option_value, ' [0-9]+ ?레벨$', '') +WHERE option_type = '세공 옵션' + AND (option_value2 IS NULL OR option_value2 = '') + AND (option_desc IS NULL OR option_desc = '') + AND option_value REGEXP '^.+ [0-9]+ ?레벨$'; + +-- 패턴 2: "스킬명(숫자레벨:효과)" 또는 "스킬명(설명)(숫자레벨:효과)" 형식 +-- 이중 괄호 케이스(교역 중 이동 속도(교역 강화 의상)(19레벨:57 % 증가))도 +-- REGEXP의 greedy/backtrack 특성으로 마지막 "(숫자레벨:" 패턴을 찾아 정상 처리됨 +UPDATE auction_history_item_option +SET option_desc = REGEXP_SUBSTR(option_value, '\\([0-9]+레벨:.+\\)$'), + option_value2 = REGEXP_SUBSTR(option_value, '[0-9]+(?=레벨:)'), + option_value = REGEXP_REPLACE(option_value, '\\([0-9]+레벨:.+\\)$', '') +WHERE option_type = '세공 옵션' + AND (option_value2 IS NULL OR option_value2 = '') + AND (option_desc IS NULL OR option_desc = '') + AND option_value REGEXP '^.+\\([0-9]+레벨:.+\\)$'; + +-- 패턴 3: "스킬명 설명텍스트" 형식 (레벨 정보 없이 텍스트 설명만 존재) +-- 첫 번째 공백 기준으로 스킬명과 설명을 분리 +UPDATE auction_history_item_option +SET option_desc = SUBSTRING(option_value, LOCATE(' ', option_value) + 1), + option_value = SUBSTRING_INDEX(option_value, ' ', 1) +WHERE option_type = '세공 옵션' + AND (option_value2 IS NULL OR option_value2 = '') + AND (option_desc IS NULL OR option_desc = '') + AND option_value NOT REGEXP '[0-9]+ ?레벨' + AND option_value NOT REGEXP '\\([0-9]+레벨:' + AND LOCATE(' ', option_value) > 0; + +-- ============================================================ +-- 2. auction_realtime_item_option 테이블 +-- ============================================================ + +-- 패턴 1: "스킬명 숫자 레벨" 또는 "스킬명 숫자레벨" 형식 +UPDATE auction_realtime_item_option +SET option_desc = REGEXP_SUBSTR(option_value, '[0-9]+ ?레벨$'), + option_value2 = REGEXP_SUBSTR(option_value, '[0-9]+(?= ?레벨$)'), + option_value = REGEXP_REPLACE(option_value, ' [0-9]+ ?레벨$', '') +WHERE option_type = '세공 옵션' + AND (option_value2 IS NULL OR option_value2 = '') + AND (option_desc IS NULL OR option_desc = '') + AND option_value REGEXP '^.+ [0-9]+ ?레벨$'; + +-- 패턴 2: "스킬명(숫자레벨:효과)" 또는 "스킬명(설명)(숫자레벨:효과)" 형식 +UPDATE auction_realtime_item_option +SET option_desc = REGEXP_SUBSTR(option_value, '\\([0-9]+레벨:.+\\)$'), + option_value2 = REGEXP_SUBSTR(option_value, '[0-9]+(?=레벨:)'), + option_value = REGEXP_REPLACE(option_value, '\\([0-9]+레벨:.+\\)$', '') +WHERE option_type = '세공 옵션' + AND (option_value2 IS NULL OR option_value2 = '') + AND (option_desc IS NULL OR option_desc = '') + AND option_value REGEXP '^.+\\([0-9]+레벨:.+\\)$'; + +-- 패턴 3: "스킬명 설명텍스트" 형식 (레벨 정보 없이 텍스트 설명만 존재) +UPDATE auction_realtime_item_option +SET option_desc = SUBSTRING(option_value, LOCATE(' ', option_value) + 1), + option_value = SUBSTRING_INDEX(option_value, ' ', 1) +WHERE option_type = '세공 옵션' + AND (option_value2 IS NULL OR option_value2 = '') + AND (option_desc IS NULL OR option_desc = '') + AND option_value NOT REGEXP '[0-9]+ ?레벨' + AND option_value NOT REGEXP '\\([0-9]+레벨:' + AND LOCATE(' ', option_value) > 0; diff --git a/src/main/resources/db/migration/V19__create_metalware_attribute_info.sql b/src/main/resources/db/migration/V19__create_metalware_attribute_info.sql new file mode 100644 index 00000000..90311b0c --- /dev/null +++ b/src/main/resources/db/migration/V19__create_metalware_attribute_info.sql @@ -0,0 +1,9 @@ +-- V19: 세공 능력치 정보 테이블 생성 +CREATE TABLE metalware_attribute_info +( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + metalware VARCHAR(50) NOT NULL COMMENT '세공', + level TINYINT NULL COMMENT '레벨', + attribute VARCHAR(100) NULL COMMENT '능력치 효과', + UNIQUE KEY uk_metalware_level (metalware, level) +) COMMENT '세공 능력치 정보 테이블'; diff --git a/src/test/java/until/the/eternity/auctionhistory/domain/service/AuctionHistoryDuplicateCheckerTest.java b/src/test/java/until/the/eternity/auctionhistory/domain/service/AuctionHistoryDuplicateCheckerTest.java index 6cfdc813..2591d675 100644 --- a/src/test/java/until/the/eternity/auctionhistory/domain/service/AuctionHistoryDuplicateCheckerTest.java +++ b/src/test/java/until/the/eternity/auctionhistory/domain/service/AuctionHistoryDuplicateCheckerTest.java @@ -24,24 +24,29 @@ @ExtendWith(MockitoExtension.class) class AuctionHistoryDuplicateCheckerTest { + private static final long KST_OFFSET_SECONDS = 32400; @Mock AuctionHistoryRepositoryPort repository; @InjectMocks AuctionHistoryDuplicateChecker checker; private static final ItemCategory CATEGORY = ItemCategory.SWORD; - private OpenApiAuctionHistoryResponse dto(String id, Instant dateAuctionBuy) { + private OpenApiAuctionHistoryResponse dto(String id, Instant dateAuctionBuyUtc) { return new OpenApiAuctionHistoryResponse( "페러시우스 타이탄 블레이드", "신성한 페러시우스 타이탄 블레이드", CATEGORY.getSubCategory(), 1L, 100L, - dateAuctionBuy, + dateAuctionBuyUtc, id, null); } + private OpenApiAuctionHistoryResponse dtoKst(String id, Instant dateAuctionBuyKst) { + return dto(id, dateAuctionBuyKst.minusSeconds(KST_OFFSET_SECONDS)); + } + @Nested @DisplayName("checkDuplicateInBatch 테스트") class CheckDuplicateInBatchTest { @@ -51,7 +56,7 @@ class CheckDuplicateInBatchTest { void noDuplicateWhenNoDataInDb() { // given Instant now = Instant.now(); - var batch = List.of(dto("1", now), dto("2", now.minusSeconds(10))); + var batch = List.of(dtoKst("1", now), dtoKst("2", now.minusSeconds(10))); when(repository.findLatestDateWithIdsBySubCategory(CATEGORY)) .thenReturn(Optional.empty()); @@ -81,7 +86,7 @@ void noDuplicateWhenAllDataAfterLatestDate() { // given Instant latestDate = Instant.parse("2024-01-01T00:00:00Z"); Instant afterLatest = latestDate.plusSeconds(100); - var batch = List.of(dto("1", afterLatest), dto("2", afterLatest.plusSeconds(10))); + var batch = List.of(dtoKst("1", afterLatest), dtoKst("2", afterLatest.plusSeconds(10))); when(repository.findLatestDateWithIdsBySubCategory(CATEGORY)) .thenReturn( @@ -102,7 +107,10 @@ void duplicateFoundWhenDataBeforeLatestDate() { Instant afterLatest = latestDate.plusSeconds(100); Instant beforeLatest = latestDate.minusSeconds(100); var batch = - List.of(dto("1", afterLatest), dto("2", afterLatest), dto("3", beforeLatest)); + List.of( + dtoKst("1", afterLatest), + dtoKst("2", afterLatest), + dtoKst("3", beforeLatest)); when(repository.findLatestDateWithIdsBySubCategory(CATEGORY)) .thenReturn(Optional.of(new LatestDateWithIds(latestDate, Set.of()))); @@ -119,7 +127,7 @@ void duplicateFoundWhenDataBeforeLatestDate() { void sameDateDifferentIdIsNew() { // given Instant latestDate = Instant.parse("2024-01-01T00:00:00Z"); - var batch = List.of(dto("new-id-1", latestDate), dto("new-id-2", latestDate)); + var batch = List.of(dtoKst("new-id-1", latestDate), dtoKst("new-id-2", latestDate)); when(repository.findLatestDateWithIdsBySubCategory(CATEGORY)) .thenReturn( @@ -139,7 +147,7 @@ void sameDateDifferentIdIsNew() { void sameDateSameIdIsDuplicate() { // given Instant latestDate = Instant.parse("2024-01-01T00:00:00Z"); - var batch = List.of(dto("new-id", latestDate), dto("existing-1", latestDate)); + var batch = List.of(dtoKst("new-id", latestDate), dtoKst("existing-1", latestDate)); when(repository.findLatestDateWithIdsBySubCategory(CATEGORY)) .thenReturn( @@ -160,7 +168,7 @@ void duplicateAtFirstIndex() { // given Instant latestDate = Instant.parse("2024-01-01T00:00:00Z"); Instant beforeLatest = latestDate.minusSeconds(100); - var batch = List.of(dto("1", beforeLatest), dto("2", latestDate)); + var batch = List.of(dtoKst("1", beforeLatest), dtoKst("2", latestDate)); when(repository.findLatestDateWithIdsBySubCategory(CATEGORY)) .thenReturn(Optional.of(new LatestDateWithIds(latestDate, Set.of()))); @@ -171,6 +179,25 @@ void duplicateAtFirstIndex() { // then assertThat(result).hasValue(0); } + + @Test + @DisplayName("API UTC 원본이어도 KST 변환 후 동일 날짜, 기존 ID는 중복으로 판정") + void sameDateAfterKstConversionIsDuplicate() { + // given + Instant latestDate = Instant.parse("2024-01-01T00:00:00Z"); + Instant sameDateInApiUtc = latestDate.minusSeconds(KST_OFFSET_SECONDS); + var batch = List.of(dto("existing-1", sameDateInApiUtc)); + + when(repository.findLatestDateWithIdsBySubCategory(CATEGORY)) + .thenReturn( + Optional.of(new LatestDateWithIds(latestDate, Set.of("existing-1")))); + + // when + OptionalInt result = checker.checkDuplicateInBatch(batch, CATEGORY); + + // then + assertThat(result).hasValue(0); + } } @Nested @@ -182,7 +209,7 @@ class FilterExistingTest { void returnAllWhenNoDataInDb() { // given Instant now = Instant.now(); - var dtos = List.of(dto("1", now), dto("2", now.minusSeconds(10))); + var dtos = List.of(dtoKst("1", now), dtoKst("2", now.minusSeconds(10))); when(repository.findLatestDateWithIdsBySubCategory(CATEGORY)) .thenReturn(Optional.empty()); @@ -214,7 +241,10 @@ void filterOutDataBeforeLatestDate() { Instant afterLatest = latestDate.plusSeconds(100); Instant beforeLatest = latestDate.minusSeconds(100); var dtos = - List.of(dto("1", afterLatest), dto("2", beforeLatest), dto("3", afterLatest)); + List.of( + dtoKst("1", afterLatest), + dtoKst("2", beforeLatest), + dtoKst("3", afterLatest)); when(repository.findLatestDateWithIdsBySubCategory(CATEGORY)) .thenReturn(Optional.of(new LatestDateWithIds(latestDate, Set.of()))); @@ -234,7 +264,7 @@ void filterOutDataBeforeLatestDate() { void includeSameDateNewId() { // given Instant latestDate = Instant.parse("2024-01-01T00:00:00Z"); - var dtos = List.of(dto("new-1", latestDate), dto("new-2", latestDate)); + var dtos = List.of(dtoKst("new-1", latestDate), dtoKst("new-2", latestDate)); when(repository.findLatestDateWithIdsBySubCategory(CATEGORY)) .thenReturn( @@ -254,9 +284,9 @@ void filterOutSameDateExistingId() { Instant latestDate = Instant.parse("2024-01-01T00:00:00Z"); var dtos = List.of( - dto("new-1", latestDate), - dto("existing-1", latestDate), - dto("new-2", latestDate)); + dtoKst("new-1", latestDate), + dtoKst("existing-1", latestDate), + dtoKst("new-2", latestDate)); when(repository.findLatestDateWithIdsBySubCategory(CATEGORY)) .thenReturn( @@ -282,11 +312,11 @@ void complexScenario() { var dtos = List.of( - dto("after-1", afterLatest), // 신규: 포함 - dto("before-1", beforeLatest), // 과거: 제외 - dto("same-new", latestDate), // 동일 날짜, 신규 ID: 포함 - dto("existing-1", latestDate), // 동일 날짜, 기존 ID: 제외 - dto("after-2", afterLatest) // 신규: 포함 + dtoKst("after-1", afterLatest), // 신규: 포함 + dtoKst("before-1", beforeLatest), // 과거: 제외 + dtoKst("same-new", latestDate), // 동일 날짜, 신규 ID: 포함 + dtoKst("existing-1", latestDate), // 동일 날짜, 기존 ID: 제외 + dtoKst("after-2", afterLatest) // 신규: 포함 ); when(repository.findLatestDateWithIdsBySubCategory(CATEGORY)) diff --git a/src/test/java/until/the/eternity/common/util/SegongOptionParserTest.java b/src/test/java/until/the/eternity/common/util/SegongOptionParserTest.java new file mode 100644 index 00000000..8f0e956d --- /dev/null +++ b/src/test/java/until/the/eternity/common/util/SegongOptionParserTest.java @@ -0,0 +1,138 @@ +package until.the.eternity.common.util; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import static org.assertj.core.api.Assertions.assertThat; + +class SegongOptionParserTest { + + private static final String SEGONG = "세공 옵션"; + + @Nested + @DisplayName("패턴 1: 스킬명 N 레벨") + class Pattern1 { + + @ParameterizedTest + @ValueSource( + strings = { + "풍년가 버프 준비시간 1 레벨", + "액스 마스터리 최소 대미지 1 레벨", + "애로우 리볼버 최소 대미지 1 레벨", + "매그넘 샷 대미지 3 레벨", + "마리오네트 마법방어 1 레벨", + "의지 1 레벨", + "행운 6 레벨", + "행운 2 레벨", + "솜씨 2 레벨", + "보호 3 레벨", + "방어 2 레벨", + "지력 4 레벨", + "최소부상률 5 레벨", + "최대마나 3 레벨", + "최대 공격력 2 레벨", + "크리티컬 1 레벨", + "캐스팅 속도 20 레벨", + "교역 중 이동 속도 7 레벨", + "핸즈 오브 카오스 [변신 중] 부상률 8 레벨", + "스피리트 오브 오더 [변신 중] 보호 15 레벨", + "스펠 오브 피시스 [변신 중] 최대 마나 10 레벨", + "데몬 오브 피시스 [변신 중] 최대 대미지 1 레벨", + "잿빛 연막술 방어, 마법방어 감소 수치 3 레벨", + "연속기 : 차징 피스트 풀차지 도달 시간 감소 1 레벨", + "7막: 광란의 질주 시전시간 3 레벨", + }) + void shouldParsePattern1(String input) { + SegongOptionParser.ParseResult result = SegongOptionParser.parse(SEGONG, input); + + assertThat(result).isNotNull(); + assertThat(result.optionValue2()).isNotNull(); + assertThat(result.optionDesc()).isNotNull(); + } + } + + @Nested + @DisplayName("패턴 2: 스킬명(N레벨:효과)") + class Pattern2 { + + @ParameterizedTest + @ValueSource( + strings = { + "행운(8레벨:12.00 증가)", + "행운(1레벨:1.50 증가)", + "합성 수련 경험치(8레벨:1.80 배 수련 경험치 증가)", + "플레이머 지속 시간(15레벨:3.00 초 증가)", + "퓨리 오브 콘누스 [변신 중] 최대 스태미나(2레벨:15.00 증가)", + "퓨리 오브 라이트 대미지(19레벨:57 % 증가)", + "타운트 유인한 적 1명 당 자신의 방어/마법방어 증가(5레벨:5 증가)", + "크리티컬(6레벨:6 % 증가)", + "최대생명력(15레벨:37.50 증가)", + "윈드밀 대미지(18레벨:54 % 증가)", + "데몬 오브 피시스 [변신 중] 최소 대미지(4레벨:4 증가)", + "잿빛 연막술 방어, 마법방어 감소 수치(1레벨:1 추가 감소)", + "연속기 : 드롭킥 스플래시 대미지(12레벨:12 % 증가)", + "7막: 광란의 질주 대미지 배율(17레벨:136 % 증가)", + "수리검 폭쇄 적 이동 속도 감소 시간(16레벨:16.00 초 추가 감소)", + }) + void shouldParsePattern2(String input) { + SegongOptionParser.ParseResult result = SegongOptionParser.parse(SEGONG, input); + + assertThat(result).isNotNull(); + assertThat(result.optionValue2()).isNotNull(); + assertThat(result.optionDesc()).isNotNull(); + } + } + + @Nested + @DisplayName("패턴 2 확장: 스킬명(설명)(N레벨:효과)") + class Pattern2Extended { + + @ParameterizedTest + @ValueSource( + strings = { + "교역 중 이동 속도(교역 강화 의상)(19레벨:57 % 증가)", + "랜스 차지 범위 폭(자이언트)(19레벨:95.00 cm 증가)", + }) + void shouldParseDoubleParenthesis(String input) { + SegongOptionParser.ParseResult result = SegongOptionParser.parse(SEGONG, input); + + assertThat(result).isNotNull(); + assertThat(result.optionValue2()).isNotNull(); + assertThat(result.optionDesc()).isNotNull(); + } + } + + @Nested + @DisplayName("패턴 3: 스킬명 설명텍스트") + class Pattern3 { + + @Test + void shouldParseDescriptionOnly() { + SegongOptionParser.ParseResult result = + SegongOptionParser.parse(SEGONG, "돌진 인간 및 엘프일 때 방패 없이 사용 가능"); + + assertThat(result).isNotNull(); + assertThat(result.optionValue()).isEqualTo("돌진"); + assertThat(result.optionValue2()).isNull(); + assertThat(result.optionDesc()).isEqualTo("인간 및 엘프일 때 방패 없이 사용 가능"); + } + } + + @Nested + @DisplayName("세공 옵션이 아닌 경우") + class NonSegong { + + @Test + void shouldReturnNullForNonSegongType() { + assertThat(SegongOptionParser.parse("일반 옵션", "행운 6 레벨")).isNull(); + } + + @Test + void shouldReturnNullForNullValue() { + assertThat(SegongOptionParser.parse(SEGONG, null)).isNull(); + } + } +} diff --git a/src/test/java/until/the/eternity/metalwareinfo/application/service/MetalwareInfoServiceTest.java b/src/test/java/until/the/eternity/metalwareinfo/application/service/MetalwareInfoServiceTest.java index 6c56c0d6..b357e1cf 100644 --- a/src/test/java/until/the/eternity/metalwareinfo/application/service/MetalwareInfoServiceTest.java +++ b/src/test/java/until/the/eternity/metalwareinfo/application/service/MetalwareInfoServiceTest.java @@ -8,12 +8,12 @@ import org.mockito.junit.jupiter.MockitoExtension; import until.the.eternity.metalwareinfo.domain.repository.MetalwareInfoRepositoryPort; import until.the.eternity.metalwareinfo.interfaces.rest.dto.response.MetalwareInfoResponse; +import until.the.eternity.metalwareinfo.interfaces.rest.dto.response.MetalwareInfoSyncResponse; import java.util.List; import static org.assertj.core.api.Assertions.assertThat; -import static org.mockito.Mockito.verify; -import static org.mockito.Mockito.when; +import static org.mockito.Mockito.*; @ExtendWith(MockitoExtension.class) class MetalwareInfoServiceTest { @@ -53,4 +53,22 @@ void findAll_should_return_empty_list_when_no_data() { assertThat(result).isEmpty(); verify(repositoryPort).findAllMetalwares(); } + + @Test + @DisplayName("metalware_attribute_info 기반 동기화 시 두 업서트를 모두 수행하고 집계 결과를 반환한다") + void syncFromAttributeInfo_should_execute_both_upserts() { + // given + when(repositoryPort.upsertLevelAttributeFromAttributeInfo()).thenReturn(10); + when(repositoryPort.upsertLimitBreakLevelFromAttributeInfo()).thenReturn(7); + + // when + MetalwareInfoSyncResponse result = service.syncFromAttributeInfo(); + + // then + assertThat(result.levelAttributeUpsertedCount()).isEqualTo(10); + assertThat(result.limitBreakLevelUpsertedCount()).isEqualTo(7); + assertThat(result.totalUpsertedCount()).isEqualTo(17); + verify(repositoryPort, times(1)).upsertLevelAttributeFromAttributeInfo(); + verify(repositoryPort, times(1)).upsertLimitBreakLevelFromAttributeInfo(); + } }