diff --git a/src/main/java/until/the/eternity/enchantinfo/application/service/EnchantInfoService.java b/src/main/java/until/the/eternity/enchantinfo/application/service/EnchantInfoService.java new file mode 100644 index 0000000..c39ca8f --- /dev/null +++ b/src/main/java/until/the/eternity/enchantinfo/application/service/EnchantInfoService.java @@ -0,0 +1,33 @@ +package until.the.eternity.enchantinfo.application.service; + +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import until.the.eternity.enchantinfo.domain.repository.EnchantInfoRepositoryPort; +import until.the.eternity.enchantinfo.interfaces.rest.dto.response.EnchantInfoResponse; +import until.the.eternity.enchantinfo.interfaces.rest.dto.response.EnchantInfoSyncResponse; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class EnchantInfoService { + + private final EnchantInfoRepositoryPort enchantInfoRepository; + + public Page findAll(Pageable pageable) { + return enchantInfoRepository.findAll(pageable).map(EnchantInfoResponse::from); + } + + public List findAllFullnames() { + return enchantInfoRepository.findAllFullnames(); + } + + @Transactional + public EnchantInfoSyncResponse syncFromAuctionHistory() { + int upserted = enchantInfoRepository.upsertFromAuctionHistory(); + return new EnchantInfoSyncResponse(upserted); + } +} diff --git a/src/main/java/until/the/eternity/enchantinfo/domain/repository/EnchantInfoRepositoryPort.java b/src/main/java/until/the/eternity/enchantinfo/domain/repository/EnchantInfoRepositoryPort.java new file mode 100644 index 0000000..937ca12 --- /dev/null +++ b/src/main/java/until/the/eternity/enchantinfo/domain/repository/EnchantInfoRepositoryPort.java @@ -0,0 +1,15 @@ +package until.the.eternity.enchantinfo.domain.repository; + +import java.util.List; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import until.the.eternity.enchantinfo.infrastructure.persistence.EnchantInfoEntity; + +public interface EnchantInfoRepositoryPort { + + Page findAll(Pageable pageable); + + List findAllFullnames(); + + int upsertFromAuctionHistory(); +} diff --git a/src/main/java/until/the/eternity/enchantinfo/infrastructure/persistence/EnchantInfoEntity.java b/src/main/java/until/the/eternity/enchantinfo/infrastructure/persistence/EnchantInfoEntity.java new file mode 100644 index 0000000..6078344 --- /dev/null +++ b/src/main/java/until/the/eternity/enchantinfo/infrastructure/persistence/EnchantInfoEntity.java @@ -0,0 +1,48 @@ +package until.the.eternity.enchantinfo.infrastructure.persistence; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "enchant_info") +@Getter +@NoArgsConstructor +public class EnchantInfoEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "fullname", nullable = false, length = 100) + private String fullname; + + @Column(name = "name", nullable = false, length = 50) + private String name; + + @Column(name = "enchant_rank", nullable = false, length = 1) + private String enchantRank; + + @Column(name = "affix_position", nullable = false, length = 10) + private String affixPosition; + + @Column(name = "effect", length = 100) + private String effect; + + @Column(name = "acquired_info", length = 200) + private String acquiredInfo; + + @Column(name = "full_option_rate") + private Integer fullOptionRate; + + @Column(name = "is_exclusive") + private Boolean isExclusive; + + @Column(name = "is_common_part") + private Boolean isCommonPart; +} diff --git a/src/main/java/until/the/eternity/enchantinfo/infrastructure/persistence/EnchantInfoJpaRepository.java b/src/main/java/until/the/eternity/enchantinfo/infrastructure/persistence/EnchantInfoJpaRepository.java new file mode 100644 index 0000000..18fe98d --- /dev/null +++ b/src/main/java/until/the/eternity/enchantinfo/infrastructure/persistence/EnchantInfoJpaRepository.java @@ -0,0 +1,29 @@ +package until.the.eternity.enchantinfo.infrastructure.persistence; + +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; + +public interface EnchantInfoJpaRepository extends JpaRepository { + + @Query("SELECT e.fullname FROM EnchantInfoEntity e ORDER BY e.id ASC") + List findAllFullnames(); + + @Modifying + @Query( + value = + """ + INSERT INTO enchant_info (fullname, name, enchant_rank, affix_position) + SELECT DISTINCT + option_value, + REGEXP_REPLACE(option_value, ' ?[(]랭크.*', '') AS name, + REGEXP_SUBSTR(REGEXP_SUBSTR(option_value, '랭크 [A-Za-z0-9]', 1, 1), '[A-Za-z0-9]+', 1, 1), + option_sub_type + FROM auction_history_item_option + WHERE option_type = '인챈트' + ON DUPLICATE KEY UPDATE fullname = VALUES(fullname) + """, + nativeQuery = true) + int upsertFromAuctionHistory(); +} diff --git a/src/main/java/until/the/eternity/enchantinfo/infrastructure/persistence/EnchantInfoRepositoryPortImpl.java b/src/main/java/until/the/eternity/enchantinfo/infrastructure/persistence/EnchantInfoRepositoryPortImpl.java new file mode 100644 index 0000000..6417f72 --- /dev/null +++ b/src/main/java/until/the/eternity/enchantinfo/infrastructure/persistence/EnchantInfoRepositoryPortImpl.java @@ -0,0 +1,30 @@ +package until.the.eternity.enchantinfo.infrastructure.persistence; + +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; +import until.the.eternity.enchantinfo.domain.repository.EnchantInfoRepositoryPort; + +@Repository +@RequiredArgsConstructor +public class EnchantInfoRepositoryPortImpl implements EnchantInfoRepositoryPort { + + private final EnchantInfoJpaRepository jpaRepository; + + @Override + public Page findAll(Pageable pageable) { + return jpaRepository.findAll(pageable); + } + + @Override + public List findAllFullnames() { + return jpaRepository.findAllFullnames(); + } + + @Override + public int upsertFromAuctionHistory() { + return jpaRepository.upsertFromAuctionHistory(); + } +} diff --git a/src/main/java/until/the/eternity/enchantinfo/interfaces/rest/controller/EnchantInfoController.java b/src/main/java/until/the/eternity/enchantinfo/interfaces/rest/controller/EnchantInfoController.java new file mode 100644 index 0000000..0a63690 --- /dev/null +++ b/src/main/java/until/the/eternity/enchantinfo/interfaces/rest/controller/EnchantInfoController.java @@ -0,0 +1,61 @@ +package until.the.eternity.enchantinfo.interfaces.rest.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +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.enchantinfo.application.service.EnchantInfoService; +import until.the.eternity.enchantinfo.interfaces.rest.dto.request.EnchantInfoPageRequestDto; +import until.the.eternity.enchantinfo.interfaces.rest.dto.response.EnchantInfoResponse; +import until.the.eternity.enchantinfo.interfaces.rest.dto.response.EnchantInfoSyncResponse; + +@RestController +@RequestMapping("/api/enchant-infos") +@RequiredArgsConstructor +@Tag(name = "Enchant Info", description = "인챈트 정보 API") +public class EnchantInfoController { + + private final EnchantInfoService enchantInfoService; + + @Operation( + summary = "인챈트 정보 페이지네이션 조회", + description = + "인챈트 정보를 페이지네이션과 함께 조회합니다. " + + "정렬 기준은 id(ASC/DESC)이며 기본값은 ASC입니다. " + + "page는 1 이상, size는 10~50 사이 값만 허용됩니다.") + @GetMapping + public ResponseEntity> getEnchantInfos( + @Valid @ModelAttribute EnchantInfoPageRequestDto pageRequest) { + return ResponseEntity.ok(enchantInfoService.findAll(pageRequest.toPageable())); + } + + @Operation( + summary = "모든 인챈트 fullname 조회", + description = "페이지네이션 없이 저장된 모든 인챈트의 fullname(이름 및 랭크)을 한 번에 조회합니다.") + @GetMapping("/fullnames") + public List getAllEnchantFullnames() { + return enchantInfoService.findAllFullnames(); + } + + @Operation( + summary = "인챈트 정보 동기화", + description = + "auction_history_item_option을 기반으로 enchant_info를 업서트합니다. " + + "**[ADMIN, SUPER_ADMIN 전용]**") + @ApiResponse(responseCode = "403", description = "권한 없음 (ADMIN, SUPER_ADMIN 전용)") + @PreAuthorize("hasAnyRole('ADMIN', 'SUPER_ADMIN')") + @PostMapping("/sync") + public ResponseEntity syncEnchantInfo() { + return ResponseEntity.ok(enchantInfoService.syncFromAuctionHistory()); + } +} diff --git a/src/main/java/until/the/eternity/enchantinfo/interfaces/rest/dto/request/EnchantInfoPageRequestDto.java b/src/main/java/until/the/eternity/enchantinfo/interfaces/rest/dto/request/EnchantInfoPageRequestDto.java new file mode 100644 index 0000000..508e694 --- /dev/null +++ b/src/main/java/until/the/eternity/enchantinfo/interfaces/rest/dto/request/EnchantInfoPageRequestDto.java @@ -0,0 +1,33 @@ +package until.the.eternity.enchantinfo.interfaces.rest.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +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 EnchantInfoPageRequestDto( + @Schema(description = "요청할 페이지 번호 (1부터 시작)", example = "1") @Min(1) Integer page, + @Schema(description = "페이지당 항목 수 (10~50)", example = "20") @Min(10) @Max(50) Integer size, + @Schema(description = "정렬 방향 (id 기준 ASC, DESC)", example = "ASC") SortDirection direction) { + private static final int DEFAULT_PAGE = 1; + private static final int DEFAULT_SIZE = 20; + private static final int MIN_SIZE = 10; + private static final int MAX_SIZE = 50; + private static final SortDirection DEFAULT_DIRECTION = SortDirection.ASC; + private static final String SORT_FIELD = "id"; + + public Pageable toPageable() { + int resolvedPage = this.page != null ? this.page - 1 : DEFAULT_PAGE - 1; + int resolvedSize = this.size != null ? this.size : DEFAULT_SIZE; + resolvedSize = Math.max(MIN_SIZE, Math.min(MAX_SIZE, resolvedSize)); + SortDirection resolvedDirection = + this.direction != null ? this.direction : DEFAULT_DIRECTION; + + return PageRequest.of( + resolvedPage, resolvedSize, Sort.by(resolvedDirection.toSpringDirection(), SORT_FIELD)); + } +} diff --git a/src/main/java/until/the/eternity/enchantinfo/interfaces/rest/dto/response/EnchantInfoResponse.java b/src/main/java/until/the/eternity/enchantinfo/interfaces/rest/dto/response/EnchantInfoResponse.java new file mode 100644 index 0000000..e305cb1 --- /dev/null +++ b/src/main/java/until/the/eternity/enchantinfo/interfaces/rest/dto/response/EnchantInfoResponse.java @@ -0,0 +1,32 @@ +package until.the.eternity.enchantinfo.interfaces.rest.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import until.the.eternity.enchantinfo.infrastructure.persistence.EnchantInfoEntity; + +@Schema(description = "인챈트 정보 응답 DTO") +public record EnchantInfoResponse( + @Schema(description = "ID") Long id, + @Schema(description = "인챈트 이름 및 랭크", example = "파괴의(랭크 A)") String fullname, + @Schema(description = "인챈트 이름", example = "파괴의") String name, + @Schema(description = "랭크", example = "A") String enchantRank, + @Schema(description = "접두 접미 구분", example = "접두") String affixPosition, + @Schema(description = "효과") String effect, + @Schema(description = "입수 정보") String acquiredInfo, + @Schema(description = "성공시 풀옵션 확률") Integer fullOptionRate, + @Schema(description = "전용 인챈트 여부") Boolean isExclusive, + @Schema(description = "전 부위 공용 인챈트 여부") Boolean isCommonPart) { + + public static EnchantInfoResponse from(EnchantInfoEntity entity) { + return new EnchantInfoResponse( + entity.getId(), + entity.getFullname(), + entity.getName(), + entity.getEnchantRank(), + entity.getAffixPosition(), + entity.getEffect(), + entity.getAcquiredInfo(), + entity.getFullOptionRate(), + entity.getIsExclusive(), + entity.getIsCommonPart()); + } +} diff --git a/src/main/java/until/the/eternity/enchantinfo/interfaces/rest/dto/response/EnchantInfoSyncResponse.java b/src/main/java/until/the/eternity/enchantinfo/interfaces/rest/dto/response/EnchantInfoSyncResponse.java new file mode 100644 index 0000000..e20922e --- /dev/null +++ b/src/main/java/until/the/eternity/enchantinfo/interfaces/rest/dto/response/EnchantInfoSyncResponse.java @@ -0,0 +1,7 @@ +package until.the.eternity.enchantinfo.interfaces.rest.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; + +@Schema(description = "인챈트 정보 동기화 응답 DTO") +public record EnchantInfoSyncResponse( + @Schema(description = "업서트된 인챈트 건수", example = "120") int upsertedCount) {} diff --git a/src/main/resources/db/migration/V23__create_enchant_info.sql b/src/main/resources/db/migration/V23__create_enchant_info.sql new file mode 100644 index 0000000..6912c06 --- /dev/null +++ b/src/main/resources/db/migration/V23__create_enchant_info.sql @@ -0,0 +1,15 @@ +-- V21: 인챈트 정보 테이블 생성 +CREATE TABLE enchant_info +( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + fullname VARCHAR(100) NOT NULL COMMENT '인챈트 이름 및 랭크', + name VARCHAR(50) NOT NULL COMMENT '인챈트 이름', + enchant_rank VARCHAR(1) NOT NULL COMMENT '랭크', + affix_position VARCHAR(10) NOT NULL COMMENT '접두 접미 구분', + effect VARCHAR(100) NULL COMMENT '효과', + acquired_info VARCHAR(200) NULL COMMENT '입수 정보', + full_option_rate INT NULL COMMENT '성공시 풀옵션 확률', + is_exclusive BOOLEAN NULL COMMENT '전용 인챈트 여부', + is_common_part BOOLEAN NULL COMMENT '전 부위 공용 인챈트 여부', + UNIQUE KEY uk_enchant_info (name, enchant_rank, affix_position) +) COMMENT '인챈트 정보 테이블'; diff --git a/src/main/resources/db/migration/V24__fix_enchant_rank_column_type.sql b/src/main/resources/db/migration/V24__fix_enchant_rank_column_type.sql new file mode 100644 index 0000000..7426175 --- /dev/null +++ b/src/main/resources/db/migration/V24__fix_enchant_rank_column_type.sql @@ -0,0 +1,3 @@ +-- V24: enchant_rank 컬럼 타입을 Hibernate 검증 스펙과 일치시키기 +ALTER TABLE enchant_info + MODIFY COLUMN enchant_rank VARCHAR(1) NOT NULL COMMENT '랭크'; diff --git a/src/main/resources/db/migration/V25__fix_enchant_full_option_rate_column_type.sql b/src/main/resources/db/migration/V25__fix_enchant_full_option_rate_column_type.sql new file mode 100644 index 0000000..cac370c --- /dev/null +++ b/src/main/resources/db/migration/V25__fix_enchant_full_option_rate_column_type.sql @@ -0,0 +1,3 @@ +-- V25: full_option_rate 컬럼 타입을 Hibernate 검증 스펙과 일치시키기 +ALTER TABLE enchant_info + MODIFY COLUMN full_option_rate INT NULL COMMENT '성공시 풀옵션 확률';