From ec05e85f3636b06f762bf608fb2dc3433dc55c59 Mon Sep 17 00:00:00 2001 From: Sanghyun Yi Date: Wed, 26 Nov 2025 23:36:32 +0900 Subject: [PATCH 01/12] =?UTF-8?q?Feat:=20item=20info=20summary=20api=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../CustomWebAuthenticationDetails.java | 27 +++++ .../common/filter/GatewayAuthFilter.java | 110 ++++++++++++++++++ .../common/request/PageRequestDto.java | 1 - .../eternity/common/util/IpAddressUtil.java | 41 +++++++ .../the/eternity/config/SecurityConfig.java | 38 +++++- .../application/service/ItemInfoService.java | 14 +++ .../repository/ItemInfoRepositoryPort.java | 6 + .../ItemInfoRepositoryPortImpl.java | 14 +++ .../rest/controller/ItemInfoController.java | 30 +++++ .../dto/request/ItemInfoPageRequestDto.java | 32 +++++ .../dto/response/ItemInfoSummaryResponse.java | 27 +++++ 11 files changed, 333 insertions(+), 7 deletions(-) create mode 100644 src/main/java/until/the/eternity/common/entity/CustomWebAuthenticationDetails.java create mode 100644 src/main/java/until/the/eternity/common/filter/GatewayAuthFilter.java create mode 100644 src/main/java/until/the/eternity/common/util/IpAddressUtil.java create mode 100644 src/main/java/until/the/eternity/iteminfo/interfaces/rest/dto/request/ItemInfoPageRequestDto.java create mode 100644 src/main/java/until/the/eternity/iteminfo/interfaces/rest/dto/response/ItemInfoSummaryResponse.java diff --git a/src/main/java/until/the/eternity/common/entity/CustomWebAuthenticationDetails.java b/src/main/java/until/the/eternity/common/entity/CustomWebAuthenticationDetails.java new file mode 100644 index 0000000..2c52202 --- /dev/null +++ b/src/main/java/until/the/eternity/common/entity/CustomWebAuthenticationDetails.java @@ -0,0 +1,27 @@ +package until.the.eternity.common.entity; + +import jakarta.servlet.http.HttpServletRequest; +import lombok.Getter; +import org.springframework.security.web.authentication.WebAuthenticationDetails; + +@Getter +public class CustomWebAuthenticationDetails extends WebAuthenticationDetails { + + private final String realRemoteAddress; + + public CustomWebAuthenticationDetails(HttpServletRequest request, String realIp) { + super(request); + this.realRemoteAddress = realIp; + } + + @Override + public String toString() { + return "CustomWebAuthenticationDetails [RemoteIpAddress(Custom)=" + + realRemoteAddress + + ", SessionId=" + + getSessionId() + + ", OriginalRemoteAddress(WebDetails)=" + + super.getRemoteAddress() + + "]"; + } +} diff --git a/src/main/java/until/the/eternity/common/filter/GatewayAuthFilter.java b/src/main/java/until/the/eternity/common/filter/GatewayAuthFilter.java new file mode 100644 index 0000000..b293dc4 --- /dev/null +++ b/src/main/java/until/the/eternity/common/filter/GatewayAuthFilter.java @@ -0,0 +1,110 @@ +package until.the.eternity.common.filter; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.util.ArrayList; +import java.util.List; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; +import until.the.eternity.common.entity.CustomWebAuthenticationDetails; +import until.the.eternity.common.util.IpAddressUtil; + +/** + * Gateway에서 전달한 인증 헤더(X-Auth-*)를 기반으로 Spring Security의 Authentication을 생성하는 필터 + * + *

Gateway에서 전달하는 헤더: - X-Auth-User-Id: 사용자 ID (Long) - X-Auth-Username: 사용자 이메일/username + * (String) - X-Auth-Roles: 사용자 역할 (예: ROLE_USER, ROLE_ADMIN) + */ +@Slf4j +@Component +@RequiredArgsConstructor +public class GatewayAuthFilter extends OncePerRequestFilter { + + private final IpAddressUtil ipAddressUtil; + + @Override + protected void doFilterInternal( + @NonNull HttpServletRequest request, + @NonNull HttpServletResponse response, + @NonNull FilterChain filterChain) + throws ServletException, IOException { + + // Gateway에서 전달한 인증 헤더 읽기 + String userIdHeader = request.getHeader("X-Auth-User-Id"); + String usernameHeader = request.getHeader("X-Auth-Username"); + String rolesHeader = request.getHeader("X-Auth-Roles"); + + UsernamePasswordAuthenticationToken authentication = + getAuthentication(userIdHeader, usernameHeader, rolesHeader); + + String clientIp = ipAddressUtil.getClientIp(request); + + CustomWebAuthenticationDetails webAuthenticationDetails = + new CustomWebAuthenticationDetails(request, clientIp); + + authentication.setDetails(webAuthenticationDetails); + + SecurityContextHolder.getContext().setAuthentication(authentication); + + log.debug("Authentication set for user: {} with roles: {}", usernameHeader, rolesHeader); + + filterChain.doFilter(request, response); + } + + /** + * Gateway에서 전달한 헤더 정보를 기반으로 Authentication 생성 + * + * @param userIdHeader 사용자 ID (Gateway에서 검증됨) + * @param usernameHeader 사용자 이메일/username (Gateway에서 검증됨) + * @param rolesHeader 사용자 역할 (Gateway에서 검증됨) + * @return UsernamePasswordAuthenticationToken + */ + private UsernamePasswordAuthenticationToken getAuthentication( + String userIdHeader, String usernameHeader, String rolesHeader) { + + // User ID 파싱 + Long userId = null; + try { + if (userIdHeader != null) { + userId = Long.parseLong(userIdHeader); + } + } catch (NumberFormatException e) { + log.warn("Invalid user ID header: {}", userIdHeader); + } + + // Roles가 없으면 익명 사용자로 처리 + if (rolesHeader == null || rolesHeader.isEmpty()) { + log.debug("No roles found, creating anonymous authentication"); + return new UsernamePasswordAuthenticationToken(userId, null); + } + + // Roles 파싱 (ROLE_USER, ROLE_ADMIN 등) + List authorities = new ArrayList<>(); + + // Gateway에서 넘어온 role이 이미 "ROLE_" prefix를 가지고 있으므로 그대로 사용 + // 단, "ROLE_" prefix가 없으면 추가 + String role = rolesHeader.trim(); + if (!role.startsWith("ROLE_")) { + role = "ROLE_" + role; + } + authorities.add(new SimpleGrantedAuthority(role)); + + log.debug( + "Created authentication for userId: {}, username: {}, role: {}", + userId, + usernameHeader, + role); + + return new UsernamePasswordAuthenticationToken(userId, null, authorities); + } +} diff --git a/src/main/java/until/the/eternity/common/request/PageRequestDto.java b/src/main/java/until/the/eternity/common/request/PageRequestDto.java index a2fbb81..06384ad 100644 --- a/src/main/java/until/the/eternity/common/request/PageRequestDto.java +++ b/src/main/java/until/the/eternity/common/request/PageRequestDto.java @@ -18,7 +18,6 @@ public record PageRequestDto( example = "dateAuctionBuy") SortField sortBy, @Schema(description = "정렬 방향 (ASC, DESC)", example = "DESC") SortDirection direction) { - private static final int DEFAULT_PAGE = 1; private static final int DEFAULT_SIZE = 20; private static final SortField DEFAULT_SORT_BY = SortField.DATE_AUCTION_BUY; diff --git a/src/main/java/until/the/eternity/common/util/IpAddressUtil.java b/src/main/java/until/the/eternity/common/util/IpAddressUtil.java new file mode 100644 index 0000000..0f65714 --- /dev/null +++ b/src/main/java/until/the/eternity/common/util/IpAddressUtil.java @@ -0,0 +1,41 @@ +package until.the.eternity.common.util; + +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +@Component +public class IpAddressUtil { + + public String getClientIp(HttpServletRequest request) { + + String ip = request.getHeader("X-Forwarded-For"); + + if (!isIpFound(ip)) { + ip = request.getHeader("Proxy-Client-IP"); + } + if (!isIpFound(ip)) { + ip = request.getHeader("WL-Proxy-Client-IP"); + } + if (!isIpFound(ip)) { + ip = request.getHeader("HTTP_CLIENT_IP"); + } + if (!isIpFound(ip)) { + ip = request.getHeader("HTTP_X_FORWARDED_FOR"); + } + + if (!isIpFound(ip)) { + ip = request.getRemoteAddr(); + } + + if (StringUtils.hasText(ip) && ip.contains(",")) { + return ip.split(",")[0].trim(); + } + + return ip; + } + + private boolean isIpFound(String ip) { + return StringUtils.hasText(ip) && !"unknown".equalsIgnoreCase(ip); + } +} diff --git a/src/main/java/until/the/eternity/config/SecurityConfig.java b/src/main/java/until/the/eternity/config/SecurityConfig.java index b30a192..e696a0b 100644 --- a/src/main/java/until/the/eternity/config/SecurityConfig.java +++ b/src/main/java/until/the/eternity/config/SecurityConfig.java @@ -1,21 +1,47 @@ package until.the.eternity.config; +import lombok.RequiredArgsConstructor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import until.the.eternity.common.filter.GatewayAuthFilter; @Configuration +@EnableWebSecurity +@RequiredArgsConstructor +@EnableMethodSecurity(securedEnabled = true, prePostEnabled = true) public class SecurityConfig { + private final GatewayAuthFilter gatewayAuthFilter; + @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { - http.authorizeHttpRequests( - authorize -> authorize.anyRequest().permitAll() // 모든 요청 허용 - ) - .csrf(csrf -> csrf.disable()) // CSRF 비활성화 - .formLogin(form -> form.disable()) // 기본 로그인 폼 비활성화 - .httpBasic(basic -> basic.disable()); // HTTP Basic 인증 비활성화 + http.csrf(AbstractHttpConfigurer::disable) + .httpBasic(AbstractHttpConfigurer::disable) + .formLogin(AbstractHttpConfigurer::disable) + .logout(AbstractHttpConfigurer::disable) + .sessionManagement( + session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests( + authorize -> + authorize + // Actuator 및 헬스체크 엔드포인트는 공개 + .requestMatchers("/actuator/**", "/health") + .permitAll() + // Swagger 문서는 공개 + .requestMatchers( + "/swagger-ui/**", "/v3/api-docs/**", "/docs/**") + .permitAll() + // 나머지 요청은 인증 필요 + .anyRequest() + .authenticated()) + .addFilterBefore(gatewayAuthFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } diff --git a/src/main/java/until/the/eternity/iteminfo/application/service/ItemInfoService.java b/src/main/java/until/the/eternity/iteminfo/application/service/ItemInfoService.java index 8e1ba3b..816bcb9 100644 --- a/src/main/java/until/the/eternity/iteminfo/application/service/ItemInfoService.java +++ b/src/main/java/until/the/eternity/iteminfo/application/service/ItemInfoService.java @@ -2,12 +2,15 @@ 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.iteminfo.domain.entity.ItemInfo; import until.the.eternity.iteminfo.domain.repository.ItemInfoRepositoryPort; import until.the.eternity.iteminfo.interfaces.rest.dto.response.ItemCategoryResponse; import until.the.eternity.iteminfo.interfaces.rest.dto.response.ItemInfoResponse; +import until.the.eternity.iteminfo.interfaces.rest.dto.response.ItemInfoSummaryResponse; @Service @Transactional(readOnly = true) @@ -34,4 +37,15 @@ public List findBySubCategory(String subCategory) { List itemInfos = itemInfoRepository.findBySubCategory(subCategory); return ItemInfoResponse.from(itemInfos); } + + public Page findAllDetail(Pageable pageable) { + Page itemInfoPage = itemInfoRepository.findAllWithPagination(pageable); + return itemInfoPage.map(ItemInfoResponse::from); + } + + public List findAllSummary( + org.springframework.data.domain.Sort.Direction direction) { + List itemInfos = itemInfoRepository.findAllSortedByName(direction); + return ItemInfoSummaryResponse.from(itemInfos); + } } diff --git a/src/main/java/until/the/eternity/iteminfo/domain/repository/ItemInfoRepositoryPort.java b/src/main/java/until/the/eternity/iteminfo/domain/repository/ItemInfoRepositoryPort.java index b9c1862..2081d75 100644 --- a/src/main/java/until/the/eternity/iteminfo/domain/repository/ItemInfoRepositoryPort.java +++ b/src/main/java/until/the/eternity/iteminfo/domain/repository/ItemInfoRepositoryPort.java @@ -1,6 +1,8 @@ package until.the.eternity.iteminfo.domain.repository; import java.util.List; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; import until.the.eternity.iteminfo.domain.entity.ItemInfo; public interface ItemInfoRepositoryPort { @@ -9,4 +11,8 @@ public interface ItemInfoRepositoryPort { List findByTopCategory(String topCategory); List findBySubCategory(String subCategory); + + Page findAllWithPagination(Pageable pageable); + + List findAllSortedByName(org.springframework.data.domain.Sort.Direction direction); } diff --git a/src/main/java/until/the/eternity/iteminfo/infrastructure/persistence/ItemInfoRepositoryPortImpl.java b/src/main/java/until/the/eternity/iteminfo/infrastructure/persistence/ItemInfoRepositoryPortImpl.java index 1e8dc1a..ec5e928 100644 --- a/src/main/java/until/the/eternity/iteminfo/infrastructure/persistence/ItemInfoRepositoryPortImpl.java +++ b/src/main/java/until/the/eternity/iteminfo/infrastructure/persistence/ItemInfoRepositoryPortImpl.java @@ -2,6 +2,9 @@ import java.util.List; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; import org.springframework.stereotype.Repository; import until.the.eternity.iteminfo.domain.entity.ItemInfo; import until.the.eternity.iteminfo.domain.repository.ItemInfoRepositoryPort; @@ -25,4 +28,15 @@ public List findByTopCategory(String topCategory) { public List findBySubCategory(String subCategory) { return jpaRepository.findByIdSubCategory(subCategory); } + + @Override + public Page findAllWithPagination(Pageable pageable) { + return jpaRepository.findAll(pageable); + } + + @Override + public List findAllSortedByName(Sort.Direction direction) { + Sort sort = Sort.by(direction, "id.name"); + return jpaRepository.findAll(sort); + } } diff --git a/src/main/java/until/the/eternity/iteminfo/interfaces/rest/controller/ItemInfoController.java b/src/main/java/until/the/eternity/iteminfo/interfaces/rest/controller/ItemInfoController.java index 0c1ccae..f7bef7c 100644 --- a/src/main/java/until/the/eternity/iteminfo/interfaces/rest/controller/ItemInfoController.java +++ b/src/main/java/until/the/eternity/iteminfo/interfaces/rest/controller/ItemInfoController.java @@ -3,17 +3,23 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; 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.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import until.the.eternity.common.enums.SortDirection; import until.the.eternity.common.response.ApiResponse; import until.the.eternity.iteminfo.application.service.ItemInfoService; +import until.the.eternity.iteminfo.interfaces.rest.dto.request.ItemInfoPageRequestDto; import until.the.eternity.iteminfo.interfaces.rest.dto.response.ItemCategoryResponse; import until.the.eternity.iteminfo.interfaces.rest.dto.response.ItemInfoResponse; +import until.the.eternity.iteminfo.interfaces.rest.dto.response.ItemInfoSummaryResponse; @RestController @RequestMapping("/api/item-infos") @@ -51,4 +57,28 @@ public List searchItemInfosBySubCategory( String subCategory) { return itemInfoService.findBySubCategory(subCategory); } + + @Operation( + summary = "아이템 상세 정보 페이지네이션 조회", + description = "모든 아이템 정보를 페이지네이션과 함께 조회합니다. name 컬럼 기준으로 정렬됩니다.") + @GetMapping("/detail") + public ResponseEntity>> getItemInfosDetail( + @Valid @ModelAttribute ItemInfoPageRequestDto pageRequest) { + Page itemInfoPage = + itemInfoService.findAllDetail(pageRequest.toPageable()); + return ResponseEntity.ok(ApiResponse.success(itemInfoPage)); + } + + @Operation( + summary = "아이템 요약 정보 조회", + description = "모든 아이템의 이름, 상위 카테고리, 하위 카테고리만 조회합니다. name 컬럼 기준으로 정렬됩니다.") + @GetMapping("/summary") + public ResponseEntity>> getItemInfosSummary( + @Parameter(description = "정렬 방향 (ASC, DESC)", example = "ASC") + @RequestParam(defaultValue = "ASC") + SortDirection direction) { + List summaryList = + itemInfoService.findAllSummary(direction.toSpringDirection()); + return ResponseEntity.ok(ApiResponse.success(summaryList)); + } } diff --git a/src/main/java/until/the/eternity/iteminfo/interfaces/rest/dto/request/ItemInfoPageRequestDto.java b/src/main/java/until/the/eternity/iteminfo/interfaces/rest/dto/request/ItemInfoPageRequestDto.java new file mode 100644 index 0000000..8ee8b2b --- /dev/null +++ b/src/main/java/until/the/eternity/iteminfo/interfaces/rest/dto/request/ItemInfoPageRequestDto.java @@ -0,0 +1,32 @@ +package until.the.eternity.iteminfo.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 ItemInfoPageRequestDto( + @Schema(description = "요청할 페이지 번호 (1부터 시작)", example = "1") @Min(1) Integer page, + @Schema(description = "페이지당 항목 수", example = "20") @Min(1) @Max(50) Integer size, + @Schema(description = "정렬 방향 (ASC, DESC)", example = "ASC") SortDirection direction) { + private static final int DEFAULT_PAGE = 1; + private static final int DEFAULT_SIZE = 20; + private static final SortDirection DEFAULT_DIRECTION = SortDirection.ASC; + private static final String SORT_FIELD = "id.name"; + + public Pageable toPageable() { + int resolvedPage = this.page != null ? this.page - 1 : DEFAULT_PAGE - 1; + int resolvedSize = this.size != null ? this.size : DEFAULT_SIZE; + 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/iteminfo/interfaces/rest/dto/response/ItemInfoSummaryResponse.java b/src/main/java/until/the/eternity/iteminfo/interfaces/rest/dto/response/ItemInfoSummaryResponse.java new file mode 100644 index 0000000..98e5cfd --- /dev/null +++ b/src/main/java/until/the/eternity/iteminfo/interfaces/rest/dto/response/ItemInfoSummaryResponse.java @@ -0,0 +1,27 @@ +package until.the.eternity.iteminfo.interfaces.rest.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; +import java.util.stream.Collectors; +import lombok.Builder; +import until.the.eternity.iteminfo.domain.entity.ItemInfo; + +@Builder +@Schema(description = "아이템 정보 요약 응답 DTO") +public record ItemInfoSummaryResponse( + @Schema(description = "아이템 이름", example = "나뭇가지") String name, + @Schema(description = "하위 카테고리", example = "한손검") String subCategory, + @Schema(description = "상위 카테고리", example = "무기") String topCategory) { + + public static ItemInfoSummaryResponse from(ItemInfo itemInfo) { + return ItemInfoSummaryResponse.builder() + .name(itemInfo.getName()) + .subCategory(itemInfo.getSubCategory()) + .topCategory(itemInfo.getTopCategory()) + .build(); + } + + public static List from(List itemInfos) { + return itemInfos.stream().map(ItemInfoSummaryResponse::from).collect(Collectors.toList()); + } +} From 73506854f72a25b579a196f99ff1c03ae356ce64 Mon Sep 17 00:00:00 2001 From: Sanghyun Yi Date: Wed, 26 Nov 2025 23:42:39 +0900 Subject: [PATCH 02/12] =?UTF-8?q?Fix:=20API=C3=AB=C2=8A=EB=AA=A8=EB=93=A0?= =?UTF-8?q?=20JWT=EB=A5=BC=20JWT=20=EC=9D=B8=EC=A6=9D=20=EC=97=86=EC=9D=B4?= =?UTF-8?q?=20=EC=82=AC=EC=9A=A9=EA=B0=80=EB=8A=A5=ED=95=98=EB=8F=84?= =?UTF-8?q?=EB=A1=9D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/until/the/eternity/config/SecurityConfig.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/java/until/the/eternity/config/SecurityConfig.java b/src/main/java/until/the/eternity/config/SecurityConfig.java index e696a0b..bc0a11c 100644 --- a/src/main/java/until/the/eternity/config/SecurityConfig.java +++ b/src/main/java/until/the/eternity/config/SecurityConfig.java @@ -38,6 +38,9 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .requestMatchers( "/swagger-ui/**", "/v3/api-docs/**", "/docs/**") .permitAll() + // API 엔드포인트는 공개 + .requestMatchers("/api/**") + .permitAll() // 나머지 요청은 인증 필요 .anyRequest() .authenticated()) From 2035d27c949f6f963e1f49a936fc88ed7ec66fbc Mon Sep 17 00:00:00 2001 From: Sanghyun Yi Date: Thu, 27 Nov 2025 00:10:18 +0900 Subject: [PATCH 03/12] =?UTF-8?q?Feat:=20ItemInfo=20=EB=8F=99=EA=B8=B0?= =?UTF-8?q?=ED=99=94=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AuctionHistoryRepositoryPort.java | 2 + .../AuctionHistoryJpaRepository.java | 7 +++ .../AuctionHistoryRepositoryPortImpl.java | 5 ++ .../the/eternity/config/SecurityConfig.java | 2 +- .../application/service/ItemInfoService.java | 60 +++++++++++++++++++ .../iteminfo/domain/entity/ItemInfo.java | 4 ++ .../repository/ItemInfoRepositoryPort.java | 5 ++ .../ItemInfoRepositoryPortImpl.java | 10 ++++ .../rest/controller/ItemInfoController.java | 13 ++++ .../dto/response/ItemInfoSyncResponse.java | 19 ++++++ 10 files changed, 126 insertions(+), 1 deletion(-) create mode 100644 src/main/java/until/the/eternity/iteminfo/interfaces/rest/dto/response/ItemInfoSyncResponse.java diff --git a/src/main/java/until/the/eternity/auctionhistory/domain/repository/AuctionHistoryRepositoryPort.java b/src/main/java/until/the/eternity/auctionhistory/domain/repository/AuctionHistoryRepositoryPort.java index 07d6e42..409d9e1 100644 --- a/src/main/java/until/the/eternity/auctionhistory/domain/repository/AuctionHistoryRepositoryPort.java +++ b/src/main/java/until/the/eternity/auctionhistory/domain/repository/AuctionHistoryRepositoryPort.java @@ -29,4 +29,6 @@ public interface AuctionHistoryRepositoryPort { void saveAll(List newEntities); Optional findLatestDateAuctionBuyBySubCategory(ItemCategory itemCategory); + + List findDistinctItemInfo(); } diff --git a/src/main/java/until/the/eternity/auctionhistory/infrastructure/persistence/AuctionHistoryJpaRepository.java b/src/main/java/until/the/eternity/auctionhistory/infrastructure/persistence/AuctionHistoryJpaRepository.java index 9ad1358..b30599b 100644 --- a/src/main/java/until/the/eternity/auctionhistory/infrastructure/persistence/AuctionHistoryJpaRepository.java +++ b/src/main/java/until/the/eternity/auctionhistory/infrastructure/persistence/AuctionHistoryJpaRepository.java @@ -36,4 +36,11 @@ select MAX(a.dateAuctionBuy) @EntityGraph(attributePaths = "itemOptions") Optional findWithItemOptionsByAuctionBuyId(String id); + + @Query( + """ + select distinct a.itemName, a.itemTopCategory, a.itemSubCategory + from AuctionHistory a + """) + List findDistinctItemInfo(); } diff --git a/src/main/java/until/the/eternity/auctionhistory/infrastructure/persistence/AuctionHistoryRepositoryPortImpl.java b/src/main/java/until/the/eternity/auctionhistory/infrastructure/persistence/AuctionHistoryRepositoryPortImpl.java index 417f13b..710ca18 100644 --- a/src/main/java/until/the/eternity/auctionhistory/infrastructure/persistence/AuctionHistoryRepositoryPortImpl.java +++ b/src/main/java/until/the/eternity/auctionhistory/infrastructure/persistence/AuctionHistoryRepositoryPortImpl.java @@ -83,4 +83,9 @@ public Optional findLatestDateAuctionBuyBySubCategory(ItemCategory item return jpaRepository.findLatestDateAuctionBuyBySubCategory( itemCategory.getTopCategory(), itemCategory.getSubCategory()); } + + @Override + public List findDistinctItemInfo() { + return jpaRepository.findDistinctItemInfo(); + } } diff --git a/src/main/java/until/the/eternity/config/SecurityConfig.java b/src/main/java/until/the/eternity/config/SecurityConfig.java index bc0a11c..a90da02 100644 --- a/src/main/java/until/the/eternity/config/SecurityConfig.java +++ b/src/main/java/until/the/eternity/config/SecurityConfig.java @@ -39,7 +39,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti "/swagger-ui/**", "/v3/api-docs/**", "/docs/**") .permitAll() // API 엔드포인트는 공개 - .requestMatchers("/api/**") + .requestMatchers("/api/**", "/auction-history/**") .permitAll() // 나머지 요청은 인증 필요 .anyRequest() diff --git a/src/main/java/until/the/eternity/iteminfo/application/service/ItemInfoService.java b/src/main/java/until/the/eternity/iteminfo/application/service/ItemInfoService.java index 816bcb9..8ee2ee3 100644 --- a/src/main/java/until/the/eternity/iteminfo/application/service/ItemInfoService.java +++ b/src/main/java/until/the/eternity/iteminfo/application/service/ItemInfoService.java @@ -1,23 +1,30 @@ package until.the.eternity.iteminfo.application.service; +import java.util.ArrayList; import java.util.List; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; 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.auctionhistory.domain.repository.AuctionHistoryRepositoryPort; import until.the.eternity.iteminfo.domain.entity.ItemInfo; +import until.the.eternity.iteminfo.domain.entity.ItemInfoId; import until.the.eternity.iteminfo.domain.repository.ItemInfoRepositoryPort; import until.the.eternity.iteminfo.interfaces.rest.dto.response.ItemCategoryResponse; import until.the.eternity.iteminfo.interfaces.rest.dto.response.ItemInfoResponse; import until.the.eternity.iteminfo.interfaces.rest.dto.response.ItemInfoSummaryResponse; +import until.the.eternity.iteminfo.interfaces.rest.dto.response.ItemInfoSyncResponse; +@Slf4j @Service @Transactional(readOnly = true) @RequiredArgsConstructor public class ItemInfoService { private final ItemInfoRepositoryPort itemInfoRepository; + private final AuctionHistoryRepositoryPort auctionHistoryRepository; public List findItemCategories() { return ItemCategoryResponse.from(); @@ -48,4 +55,57 @@ public List findAllSummary( List itemInfos = itemInfoRepository.findAllSortedByName(direction); return ItemInfoSummaryResponse.from(itemInfos); } + + @Transactional + public ItemInfoSyncResponse syncItemInfoFromAuctionHistory() { + log.info("Starting to sync ItemInfo from AuctionHistory"); + + // 1. AuctionHistory에서 distinct한 아이템 정보 조회 + List distinctItems = auctionHistoryRepository.findDistinctItemInfo(); + log.info("Found {} distinct items in AuctionHistory", distinctItems.size()); + + // 2. 중복되지 않은 아이템만 필터링하여 저장 + List newItemInfos = new ArrayList<>(); + List syncedItemNames = new ArrayList<>(); + + for (Object[] item : distinctItems) { + String itemName = (String) item[0]; + String topCategory = (String) item[1]; + String subCategory = (String) item[2]; + + ItemInfoId itemInfoId = new ItemInfoId(itemName, subCategory, topCategory); + + // 이미 존재하는 아이템인지 확인 + if (!itemInfoRepository.existsById(itemInfoId)) { + ItemInfo itemInfo = + ItemInfo.builder() + .id(itemInfoId) + .description(null) + .inventoryWidth(null) + .inventoryHeight(null) + .inventoryMaxBundleCount(null) + .history(null) + .acquisitionMethod(null) + .storeSalesPrice(null) + .weaponType(null) + .repair(null) + .maxAlterationCount(null) + .build(); + + newItemInfos.add(itemInfo); + syncedItemNames.add(itemName); + log.debug("Adding new item to sync: {}", itemName); + } + } + + // 3. 새로운 아이템 정보 저장 + if (!newItemInfos.isEmpty()) { + itemInfoRepository.saveAll(newItemInfos); + log.info("Successfully synced {} new items to ItemInfo", newItemInfos.size()); + } else { + log.info("No new items to sync"); + } + + return ItemInfoSyncResponse.of(syncedItemNames); + } } diff --git a/src/main/java/until/the/eternity/iteminfo/domain/entity/ItemInfo.java b/src/main/java/until/the/eternity/iteminfo/domain/entity/ItemInfo.java index 810d211..15ce2ad 100644 --- a/src/main/java/until/the/eternity/iteminfo/domain/entity/ItemInfo.java +++ b/src/main/java/until/the/eternity/iteminfo/domain/entity/ItemInfo.java @@ -5,6 +5,8 @@ import jakarta.persistence.Entity; import jakarta.persistence.Table; import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; @@ -12,6 +14,8 @@ @Table(name = "item_info") @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder public class ItemInfo { @EmbeddedId private ItemInfoId id; diff --git a/src/main/java/until/the/eternity/iteminfo/domain/repository/ItemInfoRepositoryPort.java b/src/main/java/until/the/eternity/iteminfo/domain/repository/ItemInfoRepositoryPort.java index 2081d75..d42edec 100644 --- a/src/main/java/until/the/eternity/iteminfo/domain/repository/ItemInfoRepositoryPort.java +++ b/src/main/java/until/the/eternity/iteminfo/domain/repository/ItemInfoRepositoryPort.java @@ -4,6 +4,7 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import until.the.eternity.iteminfo.domain.entity.ItemInfo; +import until.the.eternity.iteminfo.domain.entity.ItemInfoId; public interface ItemInfoRepositoryPort { List findAll(); @@ -15,4 +16,8 @@ public interface ItemInfoRepositoryPort { Page findAllWithPagination(Pageable pageable); List findAllSortedByName(org.springframework.data.domain.Sort.Direction direction); + + boolean existsById(ItemInfoId id); + + void saveAll(List itemInfos); } diff --git a/src/main/java/until/the/eternity/iteminfo/infrastructure/persistence/ItemInfoRepositoryPortImpl.java b/src/main/java/until/the/eternity/iteminfo/infrastructure/persistence/ItemInfoRepositoryPortImpl.java index ec5e928..d96f544 100644 --- a/src/main/java/until/the/eternity/iteminfo/infrastructure/persistence/ItemInfoRepositoryPortImpl.java +++ b/src/main/java/until/the/eternity/iteminfo/infrastructure/persistence/ItemInfoRepositoryPortImpl.java @@ -39,4 +39,14 @@ public List findAllSortedByName(Sort.Direction direction) { Sort sort = Sort.by(direction, "id.name"); return jpaRepository.findAll(sort); } + + @Override + public boolean existsById(until.the.eternity.iteminfo.domain.entity.ItemInfoId id) { + return jpaRepository.existsById(id); + } + + @Override + public void saveAll(List itemInfos) { + jpaRepository.saveAll(itemInfos); + } } diff --git a/src/main/java/until/the/eternity/iteminfo/interfaces/rest/controller/ItemInfoController.java b/src/main/java/until/the/eternity/iteminfo/interfaces/rest/controller/ItemInfoController.java index f7bef7c..79d6d1c 100644 --- a/src/main/java/until/the/eternity/iteminfo/interfaces/rest/controller/ItemInfoController.java +++ b/src/main/java/until/the/eternity/iteminfo/interfaces/rest/controller/ItemInfoController.java @@ -10,6 +10,7 @@ import org.springframework.http.ResponseEntity; 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.RequestParam; import org.springframework.web.bind.annotation.RestController; @@ -20,6 +21,7 @@ import until.the.eternity.iteminfo.interfaces.rest.dto.response.ItemCategoryResponse; import until.the.eternity.iteminfo.interfaces.rest.dto.response.ItemInfoResponse; import until.the.eternity.iteminfo.interfaces.rest.dto.response.ItemInfoSummaryResponse; +import until.the.eternity.iteminfo.interfaces.rest.dto.response.ItemInfoSyncResponse; @RestController @RequestMapping("/api/item-infos") @@ -81,4 +83,15 @@ public ResponseEntity>> getItemInfosSu itemInfoService.findAllSummary(direction.toSpringDirection()); return ResponseEntity.ok(ApiResponse.success(summaryList)); } + + @Operation( + summary = "경매 내역에서 아이템 정보 동기화", + description = + "AuctionHistory 테이블에서 아이템 정보를 조회하여 ItemInfo 테이블에 동기화합니다. " + + "이미 존재하는 아이템은 제외하고 새로운 아이템만 추가합니다.") + @PostMapping("/sync") + public ResponseEntity> syncItemInfoFromAuctionHistory() { + ItemInfoSyncResponse response = itemInfoService.syncItemInfoFromAuctionHistory(); + return ResponseEntity.ok(ApiResponse.success(response)); + } } diff --git a/src/main/java/until/the/eternity/iteminfo/interfaces/rest/dto/response/ItemInfoSyncResponse.java b/src/main/java/until/the/eternity/iteminfo/interfaces/rest/dto/response/ItemInfoSyncResponse.java new file mode 100644 index 0000000..e98fce3 --- /dev/null +++ b/src/main/java/until/the/eternity/iteminfo/interfaces/rest/dto/response/ItemInfoSyncResponse.java @@ -0,0 +1,19 @@ +package until.the.eternity.iteminfo.interfaces.rest.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.util.List; +import lombok.Builder; + +@Builder +@Schema(description = "아이템 정보 동기화 응답 DTO") +public record ItemInfoSyncResponse( + @Schema(description = "동기화된 아이템 이름 목록") List syncedItemNames, + @Schema(description = "동기화된 아이템 개수") int syncedCount) { + + public static ItemInfoSyncResponse of(List syncedItemNames) { + return ItemInfoSyncResponse.builder() + .syncedItemNames(syncedItemNames) + .syncedCount(syncedItemNames.size()) + .build(); + } +} From 789ba68c24d7693a788283b500581646f5e5b5ff Mon Sep 17 00:00:00 2001 From: Sanghyun Yi Date: Thu, 27 Nov 2025 00:56:58 +0900 Subject: [PATCH 04/12] =?UTF-8?q?fix:=20item=20info=20search=20api?= =?UTF-8?q?=EB=A5=BC=20summary,=20detail=EB=A1=9C=20=ED=86=B5=ED=95=A9=20?= =?UTF-8?q?=ED=9B=84=20name,=20category=EB=A5=BC=20=EC=BF=BC=EB=A6=AC=20?= =?UTF-8?q?=ED=8C=8C=EB=9D=BC=EB=AF=B8=ED=84=B0=EB=A1=9C=20=EB=B0=9B?= =?UTF-8?q?=EB=8F=84=EB=A1=9D=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/service/ItemInfoService.java | 26 +++++- .../exception/ItemInfoExceptionCode.java | 23 +++++ .../repository/ItemInfoRepositoryPort.java | 5 ++ .../ItemInfoQueryDslRepository.java | 89 +++++++++++++++++++ .../ItemInfoRepositoryPortImpl.java | 13 +++ .../rest/controller/ItemInfoController.java | 37 ++++---- .../dto/request/ItemInfoSearchRequest.java | 12 +++ .../V12__create_item_info_indexes.sql | 14 +++ 8 files changed, 194 insertions(+), 25 deletions(-) create mode 100644 src/main/java/until/the/eternity/iteminfo/domain/exception/ItemInfoExceptionCode.java create mode 100644 src/main/java/until/the/eternity/iteminfo/infrastructure/persistence/ItemInfoQueryDslRepository.java create mode 100644 src/main/java/until/the/eternity/iteminfo/interfaces/rest/dto/request/ItemInfoSearchRequest.java create mode 100644 src/main/resources/db/migration/V12__create_item_info_indexes.sql diff --git a/src/main/java/until/the/eternity/iteminfo/application/service/ItemInfoService.java b/src/main/java/until/the/eternity/iteminfo/application/service/ItemInfoService.java index 8ee2ee3..e017f24 100644 --- a/src/main/java/until/the/eternity/iteminfo/application/service/ItemInfoService.java +++ b/src/main/java/until/the/eternity/iteminfo/application/service/ItemInfoService.java @@ -9,9 +9,12 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import until.the.eternity.auctionhistory.domain.repository.AuctionHistoryRepositoryPort; +import until.the.eternity.common.exception.CustomException; import until.the.eternity.iteminfo.domain.entity.ItemInfo; import until.the.eternity.iteminfo.domain.entity.ItemInfoId; +import until.the.eternity.iteminfo.domain.exception.ItemInfoExceptionCode; import until.the.eternity.iteminfo.domain.repository.ItemInfoRepositoryPort; +import until.the.eternity.iteminfo.interfaces.rest.dto.request.ItemInfoSearchRequest; import until.the.eternity.iteminfo.interfaces.rest.dto.response.ItemCategoryResponse; import until.the.eternity.iteminfo.interfaces.rest.dto.response.ItemInfoResponse; import until.the.eternity.iteminfo.interfaces.rest.dto.response.ItemInfoSummaryResponse; @@ -45,17 +48,34 @@ public List findBySubCategory(String subCategory) { return ItemInfoResponse.from(itemInfos); } - public Page findAllDetail(Pageable pageable) { - Page itemInfoPage = itemInfoRepository.findAllWithPagination(pageable); + public Page findAllDetail( + ItemInfoSearchRequest searchRequest, Pageable pageable) { + validateTopCategory(searchRequest); + Page itemInfoPage = + itemInfoRepository.searchWithPagination(searchRequest, pageable); return itemInfoPage.map(ItemInfoResponse::from); } public List findAllSummary( + ItemInfoSearchRequest searchRequest, org.springframework.data.domain.Sort.Direction direction) { - List itemInfos = itemInfoRepository.findAllSortedByName(direction); + validateTopCategory(searchRequest); + // direction을 Pageable로 변환 + Pageable pageable = + org.springframework.data.domain.PageRequest.of( + 0, + Integer.MAX_VALUE, + org.springframework.data.domain.Sort.by(direction, "id.name")); + List itemInfos = itemInfoRepository.search(searchRequest, pageable); return ItemInfoSummaryResponse.from(itemInfos); } + private void validateTopCategory(ItemInfoSearchRequest searchRequest) { + if (searchRequest.topCategory() == null || searchRequest.topCategory().isBlank()) { + throw new CustomException(ItemInfoExceptionCode.TOP_CATEGORY_REQUIRED); + } + } + @Transactional public ItemInfoSyncResponse syncItemInfoFromAuctionHistory() { log.info("Starting to sync ItemInfo from AuctionHistory"); diff --git a/src/main/java/until/the/eternity/iteminfo/domain/exception/ItemInfoExceptionCode.java b/src/main/java/until/the/eternity/iteminfo/domain/exception/ItemInfoExceptionCode.java new file mode 100644 index 0000000..a7ffd4c --- /dev/null +++ b/src/main/java/until/the/eternity/iteminfo/domain/exception/ItemInfoExceptionCode.java @@ -0,0 +1,23 @@ +package until.the.eternity.iteminfo.domain.exception; + +import static org.springframework.http.HttpStatus.BAD_REQUEST; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import until.the.eternity.common.exception.ExceptionCode; + +@Getter +@RequiredArgsConstructor +public enum ItemInfoExceptionCode implements ExceptionCode { + TOP_CATEGORY_REQUIRED(BAD_REQUEST, "상위 카테고리(topCategory)는 필수 파라미터입니다."), + ; + + private final HttpStatus status; + private final String message; + + @Override + public String getCode() { + return this.name(); + } +} diff --git a/src/main/java/until/the/eternity/iteminfo/domain/repository/ItemInfoRepositoryPort.java b/src/main/java/until/the/eternity/iteminfo/domain/repository/ItemInfoRepositoryPort.java index d42edec..83d07f2 100644 --- a/src/main/java/until/the/eternity/iteminfo/domain/repository/ItemInfoRepositoryPort.java +++ b/src/main/java/until/the/eternity/iteminfo/domain/repository/ItemInfoRepositoryPort.java @@ -5,6 +5,7 @@ import org.springframework.data.domain.Pageable; import until.the.eternity.iteminfo.domain.entity.ItemInfo; import until.the.eternity.iteminfo.domain.entity.ItemInfoId; +import until.the.eternity.iteminfo.interfaces.rest.dto.request.ItemInfoSearchRequest; public interface ItemInfoRepositoryPort { List findAll(); @@ -20,4 +21,8 @@ public interface ItemInfoRepositoryPort { boolean existsById(ItemInfoId id); void saveAll(List itemInfos); + + Page searchWithPagination(ItemInfoSearchRequest searchRequest, Pageable pageable); + + List search(ItemInfoSearchRequest searchRequest, Pageable pageable); } diff --git a/src/main/java/until/the/eternity/iteminfo/infrastructure/persistence/ItemInfoQueryDslRepository.java b/src/main/java/until/the/eternity/iteminfo/infrastructure/persistence/ItemInfoQueryDslRepository.java new file mode 100644 index 0000000..266b230 --- /dev/null +++ b/src/main/java/until/the/eternity/iteminfo/infrastructure/persistence/ItemInfoQueryDslRepository.java @@ -0,0 +1,89 @@ +package until.the.eternity.iteminfo.infrastructure.persistence; + +import com.querydsl.core.BooleanBuilder; +import com.querydsl.jpa.impl.JPAQuery; +import com.querydsl.jpa.impl.JPAQueryFactory; +import java.util.List; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; +import until.the.eternity.iteminfo.domain.entity.ItemInfo; +import until.the.eternity.iteminfo.domain.entity.QItemInfo; +import until.the.eternity.iteminfo.interfaces.rest.dto.request.ItemInfoSearchRequest; + +@Repository +@RequiredArgsConstructor +public class ItemInfoQueryDslRepository { + + private final JPAQueryFactory queryFactory; + + public Page searchWithPagination( + ItemInfoSearchRequest searchRequest, Pageable pageable) { + QItemInfo itemInfo = QItemInfo.itemInfo; + + BooleanBuilder whereClause = buildWhereClause(searchRequest, itemInfo); + + JPAQuery query = + queryFactory + .selectFrom(itemInfo) + .where(whereClause) + .orderBy(itemInfo.id.name.asc()); + + List content = + query.offset(pageable.getOffset()).limit(pageable.getPageSize()).fetch(); + + Long total = + queryFactory.select(itemInfo.count()).from(itemInfo).where(whereClause).fetchOne(); + + return new PageImpl<>(content, pageable, total != null ? total : 0L); + } + + public List search(ItemInfoSearchRequest searchRequest, Pageable pageable) { + QItemInfo itemInfo = QItemInfo.itemInfo; + + BooleanBuilder whereClause = buildWhereClause(searchRequest, itemInfo); + + JPAQuery query = queryFactory.selectFrom(itemInfo).where(whereClause); + + // Pageable의 정렬 정보 적용 + if (pageable.getSort().isSorted()) { + pageable.getSort() + .forEach( + order -> { + if (order.getProperty().equals("name") + || order.getProperty().equals("id.name")) { + query.orderBy( + order.isAscending() + ? itemInfo.id.name.asc() + : itemInfo.id.name.desc()); + } + }); + } else { + // 기본 정렬: name 오름차순 + query.orderBy(itemInfo.id.name.asc()); + } + + return query.fetch(); + } + + private BooleanBuilder buildWhereClause( + ItemInfoSearchRequest searchRequest, QItemInfo itemInfo) { + BooleanBuilder builder = new BooleanBuilder(); + + if (searchRequest.name() != null && !searchRequest.name().isEmpty()) { + builder.and(itemInfo.id.name.eq(searchRequest.name())); + } + + if (searchRequest.subCategory() != null && !searchRequest.subCategory().isEmpty()) { + builder.and(itemInfo.id.subCategory.eq(searchRequest.subCategory())); + } + + if (searchRequest.topCategory() != null && !searchRequest.topCategory().isEmpty()) { + builder.and(itemInfo.id.topCategory.eq(searchRequest.topCategory())); + } + + return builder; + } +} diff --git a/src/main/java/until/the/eternity/iteminfo/infrastructure/persistence/ItemInfoRepositoryPortImpl.java b/src/main/java/until/the/eternity/iteminfo/infrastructure/persistence/ItemInfoRepositoryPortImpl.java index d96f544..97f0caf 100644 --- a/src/main/java/until/the/eternity/iteminfo/infrastructure/persistence/ItemInfoRepositoryPortImpl.java +++ b/src/main/java/until/the/eternity/iteminfo/infrastructure/persistence/ItemInfoRepositoryPortImpl.java @@ -8,11 +8,13 @@ import org.springframework.stereotype.Repository; import until.the.eternity.iteminfo.domain.entity.ItemInfo; import until.the.eternity.iteminfo.domain.repository.ItemInfoRepositoryPort; +import until.the.eternity.iteminfo.interfaces.rest.dto.request.ItemInfoSearchRequest; @Repository @RequiredArgsConstructor public class ItemInfoRepositoryPortImpl implements ItemInfoRepositoryPort { private final ItemInfoJpaRepository jpaRepository; + private final ItemInfoQueryDslRepository queryDslRepository; @Override public List findAll() { @@ -49,4 +51,15 @@ public boolean existsById(until.the.eternity.iteminfo.domain.entity.ItemInfoId i public void saveAll(List itemInfos) { jpaRepository.saveAll(itemInfos); } + + @Override + public Page searchWithPagination( + ItemInfoSearchRequest searchRequest, Pageable pageable) { + return queryDslRepository.searchWithPagination(searchRequest, pageable); + } + + @Override + public List search(ItemInfoSearchRequest searchRequest, Pageable pageable) { + return queryDslRepository.search(searchRequest, pageable); + } } diff --git a/src/main/java/until/the/eternity/iteminfo/interfaces/rest/controller/ItemInfoController.java b/src/main/java/until/the/eternity/iteminfo/interfaces/rest/controller/ItemInfoController.java index 79d6d1c..2465ae1 100644 --- a/src/main/java/until/the/eternity/iteminfo/interfaces/rest/controller/ItemInfoController.java +++ b/src/main/java/until/the/eternity/iteminfo/interfaces/rest/controller/ItemInfoController.java @@ -18,6 +18,7 @@ import until.the.eternity.common.response.ApiResponse; import until.the.eternity.iteminfo.application.service.ItemInfoService; import until.the.eternity.iteminfo.interfaces.rest.dto.request.ItemInfoPageRequestDto; +import until.the.eternity.iteminfo.interfaces.rest.dto.request.ItemInfoSearchRequest; import until.the.eternity.iteminfo.interfaces.rest.dto.response.ItemCategoryResponse; import until.the.eternity.iteminfo.interfaces.rest.dto.response.ItemInfoResponse; import until.the.eternity.iteminfo.interfaces.rest.dto.response.ItemInfoSummaryResponse; @@ -44,43 +45,35 @@ public List getAllItemInfos() { return itemInfoService.findAll(); } - @Operation(summary = "상위 카테고리로 검색", description = "상위 카테고리 이름으로 아이템 정보를 검색합니다.") - @GetMapping("/search/top-category") - public List searchItemInfosByTopCategory( - @Parameter(description = "검색할 상위 카테고리", required = true, example = "무기") @RequestParam - String topCategory) { - return itemInfoService.findByTopCategory(topCategory); - } - - @Operation(summary = "하위 카테고리로 검색", description = "하위 카테고리 이름으로 아이템 정보를 검색합니다.") - @GetMapping("/search/sub-category") - public List searchItemInfosBySubCategory( - @Parameter(description = "검색할 하위 카테고리", required = true, example = "한손검") @RequestParam - String subCategory) { - return itemInfoService.findBySubCategory(subCategory); - } - @Operation( summary = "아이템 상세 정보 페이지네이션 조회", - description = "모든 아이템 정보를 페이지네이션과 함께 조회합니다. name 컬럼 기준으로 정렬됩니다.") + description = + "아이템 정보를 페이지네이션과 함께 조회합니다. " + + "topCategory는 필수 파라미터이며, name, subCategory로 추가 필터링 가능합니다. " + + "name 컬럼 기준으로 정렬됩니다.") @GetMapping("/detail") public ResponseEntity>> getItemInfosDetail( - @Valid @ModelAttribute ItemInfoPageRequestDto pageRequest) { + @Valid @ModelAttribute ItemInfoPageRequestDto pageRequest, + @Valid @ModelAttribute ItemInfoSearchRequest searchRequest) { Page itemInfoPage = - itemInfoService.findAllDetail(pageRequest.toPageable()); + itemInfoService.findAllDetail(searchRequest, pageRequest.toPageable()); return ResponseEntity.ok(ApiResponse.success(itemInfoPage)); } @Operation( summary = "아이템 요약 정보 조회", - description = "모든 아이템의 이름, 상위 카테고리, 하위 카테고리만 조회합니다. name 컬럼 기준으로 정렬됩니다.") + description = + "아이템의 이름, 상위 카테고리, 하위 카테고리만 조회합니다. " + + "topCategory는 필수 파라미터이며, name, subCategory로 추가 필터링 가능합니다. " + + "name 컬럼 기준으로 정렬됩니다.") @GetMapping("/summary") public ResponseEntity>> getItemInfosSummary( @Parameter(description = "정렬 방향 (ASC, DESC)", example = "ASC") @RequestParam(defaultValue = "ASC") - SortDirection direction) { + SortDirection direction, + @Valid @ModelAttribute ItemInfoSearchRequest searchRequest) { List summaryList = - itemInfoService.findAllSummary(direction.toSpringDirection()); + itemInfoService.findAllSummary(searchRequest, direction.toSpringDirection()); return ResponseEntity.ok(ApiResponse.success(summaryList)); } diff --git a/src/main/java/until/the/eternity/iteminfo/interfaces/rest/dto/request/ItemInfoSearchRequest.java b/src/main/java/until/the/eternity/iteminfo/interfaces/rest/dto/request/ItemInfoSearchRequest.java new file mode 100644 index 0000000..66a46d8 --- /dev/null +++ b/src/main/java/until/the/eternity/iteminfo/interfaces/rest/dto/request/ItemInfoSearchRequest.java @@ -0,0 +1,12 @@ +package until.the.eternity.iteminfo.interfaces.rest.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +@Schema(description = "아이템 정보 검색 요청 파라미터") +public record ItemInfoSearchRequest( + @Schema(description = "아이템 이름", example = "나뭇가지") String name, + @Schema(description = "하위 카테고리", example = "한손검") String subCategory, + @Schema(description = "상위 카테고리 (필수)", example = "무기", required = true) + @NotBlank(message = "상위 카테고리(topCategory)는 필수 파라미터입니다.") + String topCategory) {} diff --git a/src/main/resources/db/migration/V12__create_item_info_indexes.sql b/src/main/resources/db/migration/V12__create_item_info_indexes.sql new file mode 100644 index 0000000..f0e0f65 --- /dev/null +++ b/src/main/resources/db/migration/V12__create_item_info_indexes.sql @@ -0,0 +1,14 @@ +-- Create indexes for item_info table to improve query performance +-- These indexes support the ItemInfo search functionality with dynamic queries + +-- Index for (top_category, sub_category, name) +CREATE INDEX idx_item_info_top_sub_name + ON item_info (top_category, sub_category, name); + +-- Index for (sub_category, name) +CREATE INDEX idx_item_info_sub_name + ON item_info (sub_category, name); + +-- Index for (name) +CREATE INDEX idx_item_info_name + ON item_info (name); From f008050d7a0a127dffb42c78b4938468b58288bb Mon Sep 17 00:00:00 2001 From: Sanghyun Yi Date: Thu, 27 Nov 2025 01:31:46 +0900 Subject: [PATCH 05/12] =?UTF-8?q?Feat:=20ItemInfo=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/ItemInfoServiceTest.java | 181 ++++++++++++++++++ 1 file changed, 181 insertions(+) diff --git a/src/test/java/until/the/eternity/iteminfo/application/service/ItemInfoServiceTest.java b/src/test/java/until/the/eternity/iteminfo/application/service/ItemInfoServiceTest.java index c75c884..b4f0f73 100644 --- a/src/test/java/until/the/eternity/iteminfo/application/service/ItemInfoServiceTest.java +++ b/src/test/java/until/the/eternity/iteminfo/application/service/ItemInfoServiceTest.java @@ -1,8 +1,10 @@ package until.the.eternity.iteminfo.application.service; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.Mockito.*; +import java.util.ArrayList; import java.util.List; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -10,16 +12,26 @@ import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.*; +import until.the.eternity.auctionhistory.domain.repository.AuctionHistoryRepositoryPort; +import until.the.eternity.common.exception.CustomException; import until.the.eternity.iteminfo.domain.entity.ItemInfo; +import until.the.eternity.iteminfo.domain.entity.ItemInfoId; +import until.the.eternity.iteminfo.domain.exception.ItemInfoExceptionCode; import until.the.eternity.iteminfo.domain.repository.ItemInfoRepositoryPort; +import until.the.eternity.iteminfo.interfaces.rest.dto.request.ItemInfoSearchRequest; import until.the.eternity.iteminfo.interfaces.rest.dto.response.ItemCategoryResponse; import until.the.eternity.iteminfo.interfaces.rest.dto.response.ItemInfoResponse; +import until.the.eternity.iteminfo.interfaces.rest.dto.response.ItemInfoSummaryResponse; +import until.the.eternity.iteminfo.interfaces.rest.dto.response.ItemInfoSyncResponse; @ExtendWith(MockitoExtension.class) class ItemInfoServiceTest { @Mock private ItemInfoRepositoryPort itemInfoRepository; + @Mock private AuctionHistoryRepositoryPort auctionHistoryRepository; + @InjectMocks private ItemInfoService itemInfoService; @Test @@ -104,6 +116,175 @@ void findAll_should_return_empty_list_when_no_data() { verify(itemInfoRepository).findAll(); } + @Test + @DisplayName("상세 정보 조회 시 topCategory가 없으면 예외가 발생한다") + void findAllDetail_should_throw_exception_when_topCategory_is_null() { + // given + ItemInfoSearchRequest searchRequest = new ItemInfoSearchRequest(null, null, null); + Pageable pageable = PageRequest.of(0, 20); + + // when & then + assertThatThrownBy(() -> itemInfoService.findAllDetail(searchRequest, pageable)) + .isInstanceOf(CustomException.class) + .hasMessage(ItemInfoExceptionCode.TOP_CATEGORY_REQUIRED.getMessage()); + } + + @Test + @DisplayName("상세 정보 조회 시 topCategory가 빈 문자열이면 예외가 발생한다") + void findAllDetail_should_throw_exception_when_topCategory_is_blank() { + // given + ItemInfoSearchRequest searchRequest = new ItemInfoSearchRequest(null, null, ""); + Pageable pageable = PageRequest.of(0, 20); + + // when & then + assertThatThrownBy(() -> itemInfoService.findAllDetail(searchRequest, pageable)) + .isInstanceOf(CustomException.class) + .hasMessage(ItemInfoExceptionCode.TOP_CATEGORY_REQUIRED.getMessage()); + } + + @Test + @DisplayName("상세 정보 조회 시 topCategory가 있으면 정상적으로 조회된다") + void findAllDetail_should_return_items_when_topCategory_is_present() { + // given + ItemInfoSearchRequest searchRequest = new ItemInfoSearchRequest(null, null, "무기"); + Pageable pageable = PageRequest.of(0, 20); + + ItemInfo item1 = createItemInfo("나뭇가지", "한손검", "무기"); + ItemInfo item2 = createItemInfo("숏소드", "한손검", "무기"); + Page itemInfoPage = new PageImpl<>(List.of(item1, item2), pageable, 2); + + when(itemInfoRepository.searchWithPagination(searchRequest, pageable)) + .thenReturn(itemInfoPage); + + // when + Page result = itemInfoService.findAllDetail(searchRequest, pageable); + + // then + assertThat(result.getContent()).hasSize(2); + assertThat(result.getContent()).extracting("name").containsExactly("나뭇가지", "숏소드"); + verify(itemInfoRepository).searchWithPagination(searchRequest, pageable); + } + + @Test + @DisplayName("상세 정보 조회 시 topCategory와 name, subCategory를 모두 사용하면 필터링된 결과를 반환한다") + void findAllDetail_should_return_filtered_items_with_all_conditions() { + // given + ItemInfoSearchRequest searchRequest = new ItemInfoSearchRequest("나뭇가지", "한손검", "무기"); + Pageable pageable = PageRequest.of(0, 20); + + ItemInfo item = createItemInfo("나뭇가지", "한손검", "무기"); + Page itemInfoPage = new PageImpl<>(List.of(item), pageable, 1); + + when(itemInfoRepository.searchWithPagination(searchRequest, pageable)) + .thenReturn(itemInfoPage); + + // when + Page result = itemInfoService.findAllDetail(searchRequest, pageable); + + // then + assertThat(result.getContent()).hasSize(1); + assertThat(result.getContent().get(0).name()).isEqualTo("나뭇가지"); + assertThat(result.getContent().get(0).subCategory()).isEqualTo("한손검"); + assertThat(result.getContent().get(0).topCategory()).isEqualTo("무기"); + verify(itemInfoRepository).searchWithPagination(searchRequest, pageable); + } + + @Test + @DisplayName("요약 정보 조회 시 topCategory가 없으면 예외가 발생한다") + void findAllSummary_should_throw_exception_when_topCategory_is_null() { + // given + ItemInfoSearchRequest searchRequest = new ItemInfoSearchRequest(null, null, null); + + // when & then + assertThatThrownBy(() -> itemInfoService.findAllSummary(searchRequest, Sort.Direction.ASC)) + .isInstanceOf(CustomException.class) + .hasMessage(ItemInfoExceptionCode.TOP_CATEGORY_REQUIRED.getMessage()); + } + + @Test + @DisplayName("요약 정보 조회 시 topCategory가 있으면 정상적으로 조회된다") + void findAllSummary_should_return_items_when_topCategory_is_present() { + // given + ItemInfoSearchRequest searchRequest = new ItemInfoSearchRequest(null, null, "무기"); + ItemInfo item1 = createItemInfo("나뭇가지", "한손검", "무기"); + ItemInfo item2 = createItemInfo("숏소드", "한손검", "무기"); + + when(itemInfoRepository.search(eq(searchRequest), any(Pageable.class))) + .thenReturn(List.of(item1, item2)); + + // when + List result = + itemInfoService.findAllSummary(searchRequest, Sort.Direction.ASC); + + // then + assertThat(result).hasSize(2); + assertThat(result).extracting("name").containsExactly("나뭇가지", "숏소드"); + assertThat(result).extracting("topCategory").containsOnly("무기"); + verify(itemInfoRepository).search(eq(searchRequest), any(Pageable.class)); + } + + @Test + @DisplayName("아이템 동기화 시 중복되지 않은 아이템만 저장된다") + void syncItemInfoFromAuctionHistory_should_save_only_new_items() { + // given + Object[] item1 = {"나뭇가지", "무기", "한손검"}; + Object[] item2 = {"숏소드", "무기", "한손검"}; + Object[] item3 = {"염색 앰플", "소모품", "염색 앰플"}; + + when(auctionHistoryRepository.findDistinctItemInfo()) + .thenReturn(List.of(item1, item2, item3)); + + // 나뭇가지만 이미 존재 + when(itemInfoRepository.existsById(new ItemInfoId("나뭇가지", "한손검", "무기"))).thenReturn(true); + when(itemInfoRepository.existsById(new ItemInfoId("숏소드", "한손검", "무기"))).thenReturn(false); + when(itemInfoRepository.existsById(new ItemInfoId("염색 앰플", "염색 앰플", "소모품"))) + .thenReturn(false); + + // when + ItemInfoSyncResponse result = itemInfoService.syncItemInfoFromAuctionHistory(); + + // then + assertThat(result.syncedItemNames()).hasSize(2); + assertThat(result.syncedItemNames()).containsExactly("숏소드", "염색 앰플"); + assertThat(result.syncedCount()).isEqualTo(2); + verify(itemInfoRepository).saveAll(argThat(list -> list.size() == 2)); + } + + @Test + @DisplayName("아이템 동기화 시 모든 아이템이 이미 존재하면 아무것도 저장하지 않는다") + void syncItemInfoFromAuctionHistory_should_not_save_when_all_items_exist() { + // given + Object[] item1 = new Object[] {"나뭇가지", "무기", "한손검"}; + List distinctItems = new ArrayList<>(); + distinctItems.add(item1); + + when(auctionHistoryRepository.findDistinctItemInfo()).thenReturn(distinctItems); + when(itemInfoRepository.existsById(any(ItemInfoId.class))).thenReturn(true); + + // when + ItemInfoSyncResponse result = itemInfoService.syncItemInfoFromAuctionHistory(); + + // then + assertThat(result.syncedItemNames()).isEmpty(); + assertThat(result.syncedCount()).isZero(); + verify(itemInfoRepository, never()).saveAll(anyList()); + } + + @Test + @DisplayName("아이템 동기화 시 AuctionHistory에 데이터가 없으면 빈 결과를 반환한다") + void syncItemInfoFromAuctionHistory_should_return_empty_when_no_data_in_auction_history() { + // given + when(auctionHistoryRepository.findDistinctItemInfo()).thenReturn(new ArrayList<>()); + + // when + ItemInfoSyncResponse result = itemInfoService.syncItemInfoFromAuctionHistory(); + + // then + assertThat(result.syncedItemNames()).isEmpty(); + assertThat(result.syncedCount()).isZero(); + verify(itemInfoRepository, never()).saveAll(anyList()); + } + private ItemInfo createItemInfo(String name, String subCategory, String topCategory) { ItemInfo itemInfo = mock(ItemInfo.class); From f4fa77e3d6cb089dbdcc404da69c2c65215d9af4 Mon Sep 17 00:00:00 2001 From: dev-ant Date: Fri, 5 Dec 2025 12:25:52 +0900 Subject: [PATCH 06/12] =?UTF-8?q?feat:=20ItemInfo=20=ED=81=B4=EB=9E=98?= =?UTF-8?q?=EC=8A=A4=EC=97=90=20lombok=20tostring=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../the/eternity/iteminfo/domain/entity/ItemInfo.java | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/main/java/until/the/eternity/iteminfo/domain/entity/ItemInfo.java b/src/main/java/until/the/eternity/iteminfo/domain/entity/ItemInfo.java index 15ce2ad..9dfc66a 100644 --- a/src/main/java/until/the/eternity/iteminfo/domain/entity/ItemInfo.java +++ b/src/main/java/until/the/eternity/iteminfo/domain/entity/ItemInfo.java @@ -4,11 +4,7 @@ import jakarta.persistence.EmbeddedId; import jakarta.persistence.Entity; import jakarta.persistence.Table; -import lombok.AccessLevel; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import lombok.NoArgsConstructor; +import lombok.*; @Entity @Table(name = "item_info") @@ -16,6 +12,7 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor @Builder +@ToString public class ItemInfo { @EmbeddedId private ItemInfoId id; From dff8bec2d785e83141e0a1eb5704e31179c928fc Mon Sep 17 00:00:00 2001 From: dev-ant Date: Fri, 5 Dec 2025 12:27:49 +0900 Subject: [PATCH 07/12] =?UTF-8?q?etc:=20security=20config=EC=97=90=20todo?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/java/until/the/eternity/config/SecurityConfig.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/main/java/until/the/eternity/config/SecurityConfig.java b/src/main/java/until/the/eternity/config/SecurityConfig.java index a90da02..db930c4 100644 --- a/src/main/java/until/the/eternity/config/SecurityConfig.java +++ b/src/main/java/until/the/eternity/config/SecurityConfig.java @@ -39,6 +39,8 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti "/swagger-ui/**", "/v3/api-docs/**", "/docs/**") .permitAll() // API 엔드포인트는 공개 + // TODO: API endpoint 정리 후 matcher 수정 + // TODO: 권한 관련 기능 개발 완료 후 hasRole 추가 .requestMatchers("/api/**", "/auction-history/**") .permitAll() // 나머지 요청은 인증 필요 From c6a5565c3f829f7c9142966b3ccbbc251c2f0a31 Mon Sep 17 00:00:00 2001 From: dev-ant Date: Fri, 5 Dec 2025 12:40:40 +0900 Subject: [PATCH 08/12] =?UTF-8?q?fix:=20item=20info=20=EB=8F=99=EA=B8=B0?= =?UTF-8?q?=ED=99=94=20=EC=8B=9C=20=EB=B0=B0=EC=B9=98=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=20=EB=B0=A9=EC=8B=9D=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/service/ItemInfoService.java | 14 ++++++++++---- .../domain/repository/ItemInfoRepositoryPort.java | 2 ++ .../persistence/ItemInfoJpaRepository.java | 4 ++++ .../persistence/ItemInfoRepositoryPortImpl.java | 5 +++++ 4 files changed, 21 insertions(+), 4 deletions(-) diff --git a/src/main/java/until/the/eternity/iteminfo/application/service/ItemInfoService.java b/src/main/java/until/the/eternity/iteminfo/application/service/ItemInfoService.java index e017f24..97459d4 100644 --- a/src/main/java/until/the/eternity/iteminfo/application/service/ItemInfoService.java +++ b/src/main/java/until/the/eternity/iteminfo/application/service/ItemInfoService.java @@ -1,7 +1,9 @@ package until.the.eternity.iteminfo.application.service; import java.util.ArrayList; +import java.util.HashSet; import java.util.List; +import java.util.Set; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; @@ -84,7 +86,11 @@ public ItemInfoSyncResponse syncItemInfoFromAuctionHistory() { List distinctItems = auctionHistoryRepository.findDistinctItemInfo(); log.info("Found {} distinct items in AuctionHistory", distinctItems.size()); - // 2. 중복되지 않은 아이템만 필터링하여 저장 + // 2. 기존 ItemInfoId를 한 번의 쿼리로 조회 (N+1 쿼리 문제 해결) + Set existingIds = new HashSet<>(itemInfoRepository.findAllIds()); + log.info("Found {} existing items in ItemInfo", existingIds.size()); + + // 3. 중복되지 않은 아이템만 필터링하여 저장 List newItemInfos = new ArrayList<>(); List syncedItemNames = new ArrayList<>(); @@ -95,8 +101,8 @@ public ItemInfoSyncResponse syncItemInfoFromAuctionHistory() { ItemInfoId itemInfoId = new ItemInfoId(itemName, subCategory, topCategory); - // 이미 존재하는 아이템인지 확인 - if (!itemInfoRepository.existsById(itemInfoId)) { + // 메모리에서 O(1) 시간 복잡도로 존재 여부 확인 + if (!existingIds.contains(itemInfoId)) { ItemInfo itemInfo = ItemInfo.builder() .id(itemInfoId) @@ -118,7 +124,7 @@ public ItemInfoSyncResponse syncItemInfoFromAuctionHistory() { } } - // 3. 새로운 아이템 정보 저장 + // 4. 새로운 아이템 정보 저장 if (!newItemInfos.isEmpty()) { itemInfoRepository.saveAll(newItemInfos); log.info("Successfully synced {} new items to ItemInfo", newItemInfos.size()); diff --git a/src/main/java/until/the/eternity/iteminfo/domain/repository/ItemInfoRepositoryPort.java b/src/main/java/until/the/eternity/iteminfo/domain/repository/ItemInfoRepositoryPort.java index 83d07f2..b540fc6 100644 --- a/src/main/java/until/the/eternity/iteminfo/domain/repository/ItemInfoRepositoryPort.java +++ b/src/main/java/until/the/eternity/iteminfo/domain/repository/ItemInfoRepositoryPort.java @@ -20,6 +20,8 @@ public interface ItemInfoRepositoryPort { boolean existsById(ItemInfoId id); + List findAllIds(); + void saveAll(List itemInfos); Page searchWithPagination(ItemInfoSearchRequest searchRequest, Pageable pageable); diff --git a/src/main/java/until/the/eternity/iteminfo/infrastructure/persistence/ItemInfoJpaRepository.java b/src/main/java/until/the/eternity/iteminfo/infrastructure/persistence/ItemInfoJpaRepository.java index 47ec58c..2be9873 100644 --- a/src/main/java/until/the/eternity/iteminfo/infrastructure/persistence/ItemInfoJpaRepository.java +++ b/src/main/java/until/the/eternity/iteminfo/infrastructure/persistence/ItemInfoJpaRepository.java @@ -3,6 +3,7 @@ import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaSpecificationExecutor; +import org.springframework.data.jpa.repository.Query; import until.the.eternity.iteminfo.domain.entity.ItemInfo; import until.the.eternity.iteminfo.domain.entity.ItemInfoId; @@ -12,4 +13,7 @@ public interface ItemInfoJpaRepository List findByIdTopCategory(String topCategory); List findByIdSubCategory(String subCategory); + + @Query("SELECT i.id FROM ItemInfo i") + List findAllIds(); } diff --git a/src/main/java/until/the/eternity/iteminfo/infrastructure/persistence/ItemInfoRepositoryPortImpl.java b/src/main/java/until/the/eternity/iteminfo/infrastructure/persistence/ItemInfoRepositoryPortImpl.java index 97f0caf..66481ca 100644 --- a/src/main/java/until/the/eternity/iteminfo/infrastructure/persistence/ItemInfoRepositoryPortImpl.java +++ b/src/main/java/until/the/eternity/iteminfo/infrastructure/persistence/ItemInfoRepositoryPortImpl.java @@ -47,6 +47,11 @@ public boolean existsById(until.the.eternity.iteminfo.domain.entity.ItemInfoId i return jpaRepository.existsById(id); } + @Override + public List findAllIds() { + return jpaRepository.findAllIds(); + } + @Override public void saveAll(List itemInfos) { jpaRepository.saveAll(itemInfos); From f45ffd943075db53c59acf1b289dd726fc4ed88e Mon Sep 17 00:00:00 2001 From: dev-ant Date: Fri, 5 Dec 2025 12:55:17 +0900 Subject: [PATCH 09/12] =?UTF-8?q?fix:=20AuctionHistoryRepository=20?= =?UTF-8?q?=EC=82=AC=EC=9A=A9=ED=95=98=EC=A7=80=20=EC=95=8A=EB=8A=94=20?= =?UTF-8?q?=EB=A9=94=EC=86=8C=EB=93=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AuctionHistoryRepositoryPort.java | 10 -------- .../AuctionHistoryRepositoryPortImpl.java | 25 ------------------- 2 files changed, 35 deletions(-) diff --git a/src/main/java/until/the/eternity/auctionhistory/domain/repository/AuctionHistoryRepositoryPort.java b/src/main/java/until/the/eternity/auctionhistory/domain/repository/AuctionHistoryRepositoryPort.java index 409d9e1..435c970 100644 --- a/src/main/java/until/the/eternity/auctionhistory/domain/repository/AuctionHistoryRepositoryPort.java +++ b/src/main/java/until/the/eternity/auctionhistory/domain/repository/AuctionHistoryRepositoryPort.java @@ -12,18 +12,8 @@ /** 경매장 거래 내역 POJO Repository - Mock 또는 Stub 으로 대체해 단위 테스트 용이성 확보 */ public interface AuctionHistoryRepositoryPort { - List findAllByAuctionBuyIds(List auctionBuyIds); - Page search(AuctionHistorySearchRequest condition, Pageable pageable); - Optional findByIdWithOptions(String id); - - boolean existsByAuctionBuyIds(List ids); - - List findExistingIds(List ids); - - boolean existsByAuctionBuyIdIn(List ids); - Optional findById(String id); void saveAll(List newEntities); diff --git a/src/main/java/until/the/eternity/auctionhistory/infrastructure/persistence/AuctionHistoryRepositoryPortImpl.java b/src/main/java/until/the/eternity/auctionhistory/infrastructure/persistence/AuctionHistoryRepositoryPortImpl.java index 710ca18..b020427 100644 --- a/src/main/java/until/the/eternity/auctionhistory/infrastructure/persistence/AuctionHistoryRepositoryPortImpl.java +++ b/src/main/java/until/the/eternity/auctionhistory/infrastructure/persistence/AuctionHistoryRepositoryPortImpl.java @@ -27,36 +27,11 @@ public class AuctionHistoryRepositoryPortImpl implements AuctionHistoryRepositor @Value("${spring.jpa.properties.hibernate.jdbc.batch_size:500}") private int batchSize; - @Override - public List findAllByAuctionBuyIds(List auctionBuyIds) { - return jpaRepository.findAllByAuctionBuyIdIn(auctionBuyIds); - } - @Override public Page search(AuctionHistorySearchRequest condition, Pageable pageable) { return queryDslRepository.search(condition, pageable); } - @Override - public Optional findByIdWithOptions(String id) { - return jpaRepository.findWithItemOptionsByAuctionBuyId(id); - } - - @Override - public boolean existsByAuctionBuyIds(List ids) { - return jpaRepository.existsByAuctionBuyIdIn(ids); - } - - @Override - public List findExistingIds(List ids) { - return jpaRepository.findExistingIds(ids); - } - - @Override - public boolean existsByAuctionBuyIdIn(List ids) { - return jpaRepository.existsByAuctionBuyIdIn(ids); - } - @Override public Optional findById(String id) { return jpaRepository.findById(id); From 4ed44fc5edad5f4103bcc74a0826d634e682c7d9 Mon Sep 17 00:00:00 2001 From: dev-ant Date: Fri, 5 Dec 2025 13:28:12 +0900 Subject: [PATCH 10/12] =?UTF-8?q?fix:=20ItemInfoService=20=EB=B3=80?= =?UTF-8?q?=EC=88=98=EB=AA=85=20=EB=AA=85=ED=99=95=ED=95=98=EA=B2=8C=20?= =?UTF-8?q?=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/service/ItemInfoService.java | 22 +++++-------------- 1 file changed, 5 insertions(+), 17 deletions(-) diff --git a/src/main/java/until/the/eternity/iteminfo/application/service/ItemInfoService.java b/src/main/java/until/the/eternity/iteminfo/application/service/ItemInfoService.java index 97459d4..1c80a41 100644 --- a/src/main/java/until/the/eternity/iteminfo/application/service/ItemInfoService.java +++ b/src/main/java/until/the/eternity/iteminfo/application/service/ItemInfoService.java @@ -95,28 +95,16 @@ public ItemInfoSyncResponse syncItemInfoFromAuctionHistory() { List syncedItemNames = new ArrayList<>(); for (Object[] item : distinctItems) { + // Query returns: itemName, itemTopCategory, itemSubCategory String itemName = (String) item[0]; - String topCategory = (String) item[1]; - String subCategory = (String) item[2]; + String itemTopCategory = (String) item[1]; + String itemSubCategory = (String) item[2]; - ItemInfoId itemInfoId = new ItemInfoId(itemName, subCategory, topCategory); + ItemInfoId itemInfoId = new ItemInfoId(itemName, itemSubCategory, itemTopCategory); // 메모리에서 O(1) 시간 복잡도로 존재 여부 확인 if (!existingIds.contains(itemInfoId)) { - ItemInfo itemInfo = - ItemInfo.builder() - .id(itemInfoId) - .description(null) - .inventoryWidth(null) - .inventoryHeight(null) - .inventoryMaxBundleCount(null) - .history(null) - .acquisitionMethod(null) - .storeSalesPrice(null) - .weaponType(null) - .repair(null) - .maxAlterationCount(null) - .build(); + ItemInfo itemInfo = ItemInfo.builder().id(itemInfoId).build(); newItemInfos.add(itemInfo); syncedItemNames.add(itemName); From 0bb8343ca19945f6974c42d40ccd85777435787e Mon Sep 17 00:00:00 2001 From: dev-ant Date: Fri, 5 Dec 2025 19:29:53 +0900 Subject: [PATCH 11/12] =?UTF-8?q?feat:=20item=20info=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gradlew | 0 .../application/service/ItemInfoServiceTest.java | 12 +++++++----- 2 files changed, 7 insertions(+), 5 deletions(-) mode change 100644 => 100755 gradlew diff --git a/gradlew b/gradlew old mode 100644 new mode 100755 diff --git a/src/test/java/until/the/eternity/iteminfo/application/service/ItemInfoServiceTest.java b/src/test/java/until/the/eternity/iteminfo/application/service/ItemInfoServiceTest.java index b4f0f73..4223655 100644 --- a/src/test/java/until/the/eternity/iteminfo/application/service/ItemInfoServiceTest.java +++ b/src/test/java/until/the/eternity/iteminfo/application/service/ItemInfoServiceTest.java @@ -235,10 +235,8 @@ void syncItemInfoFromAuctionHistory_should_save_only_new_items() { .thenReturn(List.of(item1, item2, item3)); // 나뭇가지만 이미 존재 - when(itemInfoRepository.existsById(new ItemInfoId("나뭇가지", "한손검", "무기"))).thenReturn(true); - when(itemInfoRepository.existsById(new ItemInfoId("숏소드", "한손검", "무기"))).thenReturn(false); - when(itemInfoRepository.existsById(new ItemInfoId("염색 앰플", "염색 앰플", "소모품"))) - .thenReturn(false); + ItemInfoId existingId = new ItemInfoId("나뭇가지", "한손검", "무기"); + when(itemInfoRepository.findAllIds()).thenReturn(List.of(existingId)); // when ItemInfoSyncResponse result = itemInfoService.syncItemInfoFromAuctionHistory(); @@ -259,7 +257,10 @@ void syncItemInfoFromAuctionHistory_should_not_save_when_all_items_exist() { distinctItems.add(item1); when(auctionHistoryRepository.findDistinctItemInfo()).thenReturn(distinctItems); - when(itemInfoRepository.existsById(any(ItemInfoId.class))).thenReturn(true); + + // 모든 아이템이 이미 존재 + ItemInfoId existingId = new ItemInfoId("나뭇가지", "한손검", "무기"); + when(itemInfoRepository.findAllIds()).thenReturn(List.of(existingId)); // when ItemInfoSyncResponse result = itemInfoService.syncItemInfoFromAuctionHistory(); @@ -275,6 +276,7 @@ void syncItemInfoFromAuctionHistory_should_not_save_when_all_items_exist() { void syncItemInfoFromAuctionHistory_should_return_empty_when_no_data_in_auction_history() { // given when(auctionHistoryRepository.findDistinctItemInfo()).thenReturn(new ArrayList<>()); + when(itemInfoRepository.findAllIds()).thenReturn(new ArrayList<>()); // when ItemInfoSyncResponse result = itemInfoService.syncItemInfoFromAuctionHistory(); From 27ae4d96232c7984bdc1674d6ca25d08faf51285 Mon Sep 17 00:00:00 2001 From: dev-ant Date: Fri, 5 Dec 2025 19:39:26 +0900 Subject: [PATCH 12/12] =?UTF-8?q?feat:=20item=20info=20service=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=BC=80=EC=9D=B4=EC=8A=A4=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/ItemInfoServiceTest.java | 100 ++++++++++++++++++ 1 file changed, 100 insertions(+) diff --git a/src/test/java/until/the/eternity/iteminfo/application/service/ItemInfoServiceTest.java b/src/test/java/until/the/eternity/iteminfo/application/service/ItemInfoServiceTest.java index 4223655..15a7a7d 100644 --- a/src/test/java/until/the/eternity/iteminfo/application/service/ItemInfoServiceTest.java +++ b/src/test/java/until/the/eternity/iteminfo/application/service/ItemInfoServiceTest.java @@ -287,6 +287,106 @@ void syncItemInfoFromAuctionHistory_should_return_empty_when_no_data_in_auction_ verify(itemInfoRepository, never()).saveAll(anyList()); } + @Test + @DisplayName("상위 카테고리로 조회 시 결과가 없으면 빈 목록을 반환한다") + void findByTopCategory_should_return_empty_list_when_no_results() { + // given + String topCategory = "존재하지않는카테고리"; + when(itemInfoRepository.findByTopCategory(topCategory)).thenReturn(List.of()); + + // when + List result = itemInfoService.findByTopCategory(topCategory); + + // then + assertThat(result).isEmpty(); + verify(itemInfoRepository).findByTopCategory(topCategory); + } + + @Test + @DisplayName("하위 카테고리로 조회 시 결과가 없으면 빈 목록을 반환한다") + void findBySubCategory_should_return_empty_list_when_no_results() { + // given + String subCategory = "존재하지않는카테고리"; + when(itemInfoRepository.findBySubCategory(subCategory)).thenReturn(List.of()); + + // when + List result = itemInfoService.findBySubCategory(subCategory); + + // then + assertThat(result).isEmpty(); + verify(itemInfoRepository).findBySubCategory(subCategory); + } + + @Test + @DisplayName("요약 정보 조회 시 topCategory가 빈 문자열이면 예외가 발생한다") + void findAllSummary_should_throw_exception_when_topCategory_is_blank() { + // given + ItemInfoSearchRequest searchRequest = new ItemInfoSearchRequest(null, null, ""); + + // when & then + assertThatThrownBy(() -> itemInfoService.findAllSummary(searchRequest, Sort.Direction.ASC)) + .isInstanceOf(CustomException.class) + .hasMessage(ItemInfoExceptionCode.TOP_CATEGORY_REQUIRED.getMessage()); + } + + @Test + @DisplayName("요약 정보 조회 시 결과가 없으면 빈 목록을 반환한다") + void findAllSummary_should_return_empty_list_when_no_results() { + // given + ItemInfoSearchRequest searchRequest = new ItemInfoSearchRequest(null, null, "무기"); + when(itemInfoRepository.search(eq(searchRequest), any(Pageable.class))) + .thenReturn(List.of()); + + // when + List result = + itemInfoService.findAllSummary(searchRequest, Sort.Direction.ASC); + + // then + assertThat(result).isEmpty(); + verify(itemInfoRepository).search(eq(searchRequest), any(Pageable.class)); + } + + @Test + @DisplayName("요약 정보 조회 시 내림차순 정렬이 적용된다") + void findAllSummary_should_apply_descending_sort() { + // given + ItemInfoSearchRequest searchRequest = new ItemInfoSearchRequest(null, null, "무기"); + ItemInfo item1 = createItemInfo("A아이템", "한손검", "무기"); + ItemInfo item2 = createItemInfo("Z아이템", "한손검", "무기"); + + when(itemInfoRepository.search(eq(searchRequest), any(Pageable.class))) + .thenReturn(List.of(item2, item1)); + + // when + List result = + itemInfoService.findAllSummary(searchRequest, Sort.Direction.DESC); + + // then + assertThat(result).hasSize(2); + assertThat(result).extracting("name").containsExactly("Z아이템", "A아이템"); + verify(itemInfoRepository).search(eq(searchRequest), any(Pageable.class)); + } + + @Test + @DisplayName("상세 정보 조회 시 결과가 없으면 빈 페이지를 반환한다") + void findAllDetail_should_return_empty_page_when_no_results() { + // given + ItemInfoSearchRequest searchRequest = new ItemInfoSearchRequest(null, null, "무기"); + Pageable pageable = PageRequest.of(0, 20); + Page emptyPage = new PageImpl<>(List.of(), pageable, 0); + + when(itemInfoRepository.searchWithPagination(searchRequest, pageable)) + .thenReturn(emptyPage); + + // when + Page result = itemInfoService.findAllDetail(searchRequest, pageable); + + // then + assertThat(result.getContent()).isEmpty(); + assertThat(result.getTotalElements()).isZero(); + verify(itemInfoRepository).searchWithPagination(searchRequest, pageable); + } + private ItemInfo createItemInfo(String name, String subCategory, String topCategory) { ItemInfo itemInfo = mock(ItemInfo.class);