-
Notifications
You must be signed in to change notification settings - Fork 1
feat: 인챈트 정보 DB 테이블 생성 및 조회 API 구현 #104
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<EnchantInfoResponse> findAll(Pageable pageable) { | ||
| return enchantInfoRepository.findAll(pageable).map(EnchantInfoResponse::from); | ||
| } | ||
|
|
||
| public List<String> findAllFullnames() { | ||
| return enchantInfoRepository.findAllFullnames(); | ||
| } | ||
|
|
||
| @Transactional | ||
| public EnchantInfoSyncResponse syncFromAuctionHistory() { | ||
| int upserted = enchantInfoRepository.upsertFromAuctionHistory(); | ||
| return new EnchantInfoSyncResponse(upserted); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<EnchantInfoEntity> findAll(Pageable pageable); | ||
|
|
||
| List<String> findAllFullnames(); | ||
|
|
||
| int upsertFromAuctionHistory(); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
| } |
| Original file line number | Diff line number | Diff line change | ||||||
|---|---|---|---|---|---|---|---|---|
| @@ -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<EnchantInfoEntity, Long> { | ||||||||
|
|
||||||||
| @Query("SELECT e.fullname FROM EnchantInfoEntity e ORDER BY e.id ASC") | ||||||||
| List<String> 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), | ||||||||
|
||||||||
| REGEXP_SUBSTR(REGEXP_SUBSTR(option_value, '랭크 [A-Za-z0-9]', 1, 1), '[A-Za-z0-9]+', 1, 1), | |
| REGEXP_SUBSTR(REGEXP_SUBSTR(option_value, '랭크 ?[A-Za-z0-9]', 1, 1), '[A-Za-z0-9]+', 1, 1), |
Copilot
AI
Feb 23, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The REGEXP_SUBSTR function extracts the rank character but may return NULL if the pattern doesn't match. Since enchant_rank column is defined as NOT NULL in the table schema, this INSERT will fail for any option_value that doesn't contain a rank pattern. Consider adding a WHERE clause to filter out records without a valid rank pattern, or handle NULL values with COALESCE to provide a default value.
| WHERE option_type = '인챈트' | |
| WHERE option_type = '인챈트' | |
| AND REGEXP_SUBSTR(REGEXP_SUBSTR(option_value, '랭크 [A-Za-z0-9]', 1, 1), '[A-Za-z0-9]+', 1, 1) IS NOT NULL |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<EnchantInfoEntity> findAll(Pageable pageable) { | ||
| return jpaRepository.findAll(pageable); | ||
| } | ||
|
|
||
| @Override | ||
| public List<String> findAllFullnames() { | ||
| return jpaRepository.findAllFullnames(); | ||
| } | ||
|
|
||
| @Override | ||
| public int upsertFromAuctionHistory() { | ||
| return jpaRepository.upsertFromAuctionHistory(); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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<Page<EnchantInfoResponse>> getEnchantInfos( | ||
| @Valid @ModelAttribute EnchantInfoPageRequestDto pageRequest) { | ||
| return ResponseEntity.ok(enchantInfoService.findAll(pageRequest.toPageable())); | ||
| } | ||
|
|
||
| @Operation( | ||
| summary = "모든 인챈트 fullname 조회", | ||
| description = "페이지네이션 없이 저장된 모든 인챈트의 fullname(이름 및 랭크)을 한 번에 조회합니다.") | ||
| @GetMapping("/fullnames") | ||
| public List<String> 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<EnchantInfoSyncResponse> syncEnchantInfo() { | ||
| return ResponseEntity.ok(enchantInfoService.syncFromAuctionHistory()); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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)); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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()); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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) {} |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,15 @@ | ||||||
| -- V21: 인챈트 정보 테이블 생성 | ||||||
|
||||||
| -- V21: 인챈트 정보 테이블 생성 | |
| -- V23: 인챈트 정보 테이블 생성 |
Copilot
AI
Feb 23, 2026
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The enchant_rank column is defined as VARCHAR(1), which can only store single-character ranks. If the game has multi-character ranks (e.g., "10", "SS"), this will cause data truncation errors. The REGEXP_SUBSTR pattern '[A-Za-z0-9]+' suggests that multi-character ranks are possible. Consider changing the column type to VARCHAR(2) or VARCHAR(10) to accommodate multi-character ranks, and verify what rank values actually exist in the source data.
| enchant_rank VARCHAR(1) NOT NULL COMMENT '랭크', | |
| enchant_rank VARCHAR(10) NOT NULL COMMENT '랭크', |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| -- V24: enchant_rank 컬럼 타입을 Hibernate 검증 스펙과 일치시키기 | ||
| ALTER TABLE enchant_info | ||
| MODIFY COLUMN enchant_rank VARCHAR(1) NOT NULL COMMENT '랭크'; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| -- V25: full_option_rate 컬럼 타입을 Hibernate 검증 스펙과 일치시키기 | ||
| ALTER TABLE enchant_info | ||
| MODIFY COLUMN full_option_rate INT NULL COMMENT '성공시 풀옵션 확률'; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The EnchantInfoService class lacks unit tests, while similar service classes in the codebase (e.g., MetalwareInfoService) have comprehensive test coverage using Mockito. Consider adding unit tests to verify the behavior of findAll, findAllFullnames, and syncFromAuctionHistory methods, following the testing pattern established in the codebase.