Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions local_oab_auction_history_item_option.csv
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
option_value,option_value2,option_desc
최대생명력(15레벨:37.50 증가),,
최대마나(19레벨:95 증가),,
보호(5레벨:5 증가),,
돌진,,인간 및 엘프일 때 방패 없이 사용 가능
Comment on lines +1 to +5

Copilot AI Feb 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

현재 CSV는 코드/테스트에서 참조되는 위치가 아니고 파일명도 local_... 형태라 커밋된 로컬 실험 데이터로 보입니다. 저장이 필요하다면 src/test/resources 또는 docs/로 이동하고 사용 목적을 문서화하고, 아니라면 PR에서 제거하는 것이 저장소 관리에 유리합니다.

Copilot uses AI. Check for mistakes.
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand All @@ -38,6 +38,6 @@ List<AuctionHistory> 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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
// 두 패턴 모두 매칭되지 않으면 원본 값 유지
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
@RequiredArgsConstructor
public class AuctionHistoryDuplicateChecker {

private static final long KST_OFFSET_SECONDS = 32400;

private final AuctionHistoryRepositoryPort repository;

/**
Expand Down Expand Up @@ -89,7 +91,8 @@ public List<OpenApiAuctionHistoryResponse> filterExisting(

private boolean isDuplicate(
OpenApiAuctionHistoryResponse dto, Instant latestDate, Set<String> existingIds) {
Instant dtoDate = dto.dateAuctionBuy();
// DB에는 KST(+9h) 기준으로 저장되므로 비교 시 동일하게 변환
Instant dtoDate = toKst(dto.dateAuctionBuy());

if (dtoDate.isBefore(latestDate)) {
return true;
Expand All @@ -101,4 +104,8 @@ private boolean isDuplicate(

return false;
}

private Instant toKst(Instant utcTime) {
return utcTime != null ? utcTime.plusSeconds(KST_OFFSET_SECONDS) : null;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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());
}
// 두 패턴 모두 매칭되지 않으면 원본 값 유지
}
}
Original file line number Diff line number Diff line change
@@ -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)을 분리하는 유틸리티.
*
* <p>지원 패턴:
*
* <ul>
* <li>패턴 1: "스킬명 숫자 레벨" (예: "지력 2레벨", "행운 6 레벨")
* <li>패턴 2: "스킬명(숫자레벨:효과)" (예: "매그넘 샷 대미지(20레벨:200 % 증가)")
* <li>패턴 2 확장: "스킬명(설명)(숫자레벨:효과)" (예: "교역 중 이동 속도(교역 강화 의상)(19레벨:57 % 증가)")
* <li>패턴 3: "스킬명 설명텍스트" (예: "돌진 인간 및 엘프일 때 방패 없이 사용 가능")
* </ul>
*/
@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;
}
}
Original file line number Diff line number Diff line change
@@ -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<MetalwareAttributeInfoResponse> search(
MetalwareAttributeInfoSearchRequest request) {
return metalwareAttributeInfoRepository
.searchByMetalware(request.metalware(), request.toPageable())
.map(MetalwareAttributeInfoResponse::from);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -19,4 +20,18 @@ public List<MetalwareInfoResponse> findAll() {
List<String> metalwares = metalwareInfoRepository.findAllMetalwares();
return MetalwareInfoResponse.from(metalwares);
}

@Transactional
public MetalwareInfoSyncResponse syncFromAttributeInfo() {
int levelAttributeUpserted =
metalwareInfoRepository.upsertLevelAttributeFromAttributeInfo();
int limitBreakLevelUpserted =
metalwareInfoRepository.upsertLimitBreakLevelFromAttributeInfo();

Comment on lines +26 to +30

Copilot AI Feb 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

MySQL의 INSERT ... ON DUPLICATE KEY UPDATE는 JDBC update count가 '업서트된 레코드 수'와 일치하지 않을 수 있습니다(업데이트 시 2로 집계, no-op은 0 등). 이 값을 응답의 ...UpsertedCount로 노출하면 오해 소지가 있으니, 의미를 affectedRows로 명확히 하거나 커넥터 useAffectedRows=true 등으로 카운트 의미를 통일하는 방안을 검토해 주세요.

Copilot uses AI. Check for mistakes.
return MetalwareInfoSyncResponse.builder()
.levelAttributeUpsertedCount(levelAttributeUpserted)
.limitBreakLevelUpsertedCount(limitBreakLevelUpserted)
.totalUpsertedCount(levelAttributeUpserted + limitBreakLevelUpserted)
.build();
}
}
Original file line number Diff line number Diff line change
@@ -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<MetalwareAttributeInfoEntity> searchByMetalware(String metalware, Pageable pageable);
Comment on lines +5 to +11

Copilot AI Feb 11, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

도메인 레이어의 RepositoryPort가 infrastructure.persistence의 JPA Entity를 반환 타입으로 노출하고 있습니다. 포트는 도메인 모델(또는 별도 DTO/Projection)만 의존하도록 분리하고, JPA Entity는 infrastructure 계층 내부로 숨기는 구조가 유지보수/의존성 방향 측면에서 안전합니다.

Suggested change
import until.the.eternity.metalwareinfo.infrastructure.persistence.MetalwareAttributeInfoEntity;
public interface MetalwareAttributeInfoRepositoryPort {
int syncFromAuctionHistory();
Page<MetalwareAttributeInfoEntity> searchByMetalware(String metalware, Pageable pageable);
/**
* Domain-level view of metalware attribute information.
* <p>
* This type intentionally lives in the domain package and is decoupled from
* any JPA or infrastructure concerns. Implementations of the repository port
* are responsible for mapping from persistence entities to this type.
*/
class MetalwareAttributeInfoDomain {
// Add domain fields and behavior here as needed.
}
public interface MetalwareAttributeInfoRepositoryPort {
int syncFromAuctionHistory();
Page<MetalwareAttributeInfoDomain> searchByMetalware(String metalware, Pageable pageable);

Copilot uses AI. Check for mistakes.
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,8 @@

public interface MetalwareInfoRepositoryPort {
List<String> findAllMetalwares();

int upsertLevelAttributeFromAttributeInfo();

int upsertLimitBreakLevelFromAttributeInfo();
}
Original file line number Diff line number Diff line change
@@ -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;
}
Loading
Loading