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
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);
}
}
Comment on lines +1 to +33

Copilot AI Feb 23, 2026

Copy link

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.

Copilot uses AI. Check for mistakes.
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),

Copilot AI Feb 23, 2026

Copy link

Choose a reason for hiding this comment

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

The REGEXP_SUBSTR pattern '랭크 [A-Za-z0-9]' has a space between '랭크' and the character class, which means it expects a space in the input string. If the source data format is inconsistent (e.g., "랭크A" without a space), the extraction will fail and return NULL, causing the insert to fail due to the NOT NULL constraint on enchant_rank. Consider making the space optional with a pattern like '랭크 ?[A-Za-z0-9]' or verifying that the source data always has the space.

Suggested change
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 uses AI. Check for mistakes.
option_sub_type
FROM auction_history_item_option
WHERE option_type = '인챈트'

Copilot AI Feb 23, 2026

Copy link

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.

Suggested change
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

Copilot uses AI. Check for mistakes.
ON DUPLICATE KEY UPDATE fullname = VALUES(fullname)
""",
nativeQuery = true)
int upsertFromAuctionHistory();
}
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) {}
15 changes: 15 additions & 0 deletions src/main/resources/db/migration/V23__create_enchant_info.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
-- V21: 인챈트 정보 테이블 생성

Copilot AI Feb 23, 2026

Copy link

Choose a reason for hiding this comment

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

The comment incorrectly references "V21" but this is migration file V23. The comment should reference the correct version number.

Suggested change
-- V21: 인챈트 정보 테이블 생성
-- V23: 인챈트 정보 테이블 생성

Copilot uses AI. Check for mistakes.
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 '랭크',

Copilot AI Feb 23, 2026

Copy link

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.

Suggested change
enchant_rank VARCHAR(1) NOT NULL COMMENT '랭크',
enchant_rank VARCHAR(10) NOT NULL COMMENT '랭크',

Copilot uses AI. Check for mistakes.
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 '인챈트 정보 테이블';
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 '성공시 풀옵션 확률';