From 3589f463011dfc768b3b230180c10f5d6838c09a Mon Sep 17 00:00:00 2001 From: choi-jin-wook Date: Wed, 12 Aug 2026 23:44:28 +0900 Subject: [PATCH 01/22] =?UTF-8?q?feat:=20user-service=20=EC=84=9C=EB=B2=84?= =?UTF-8?q?=EC=97=90=20AdminMemberController=20=EB=B0=8F=20AdminMemberServ?= =?UTF-8?q?ice=20=EB=BC=88=EB=8C=80=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AdminMemberController에 사용자 목록/상세 조회, 인벤토리 수정 3개 엔드포인트를 501(Not Implemented)로 스텁 처리 - AdminMemberService/Impl 인터페이스와 메서드 뼈대 생성 (getUsers, getUserDetail, updateUserInventory), 몸통은 UnsupportedOperationException으로 스텁 처리 Co-Authored-By: Claude Sonnet 5 --- .../member/service/AdminMemberService.java | 19 ++++++ .../service/AdminMemberServiceImpl.java | 38 +++++++++++ .../controller/AdminMemberController.java | 63 +++++++++++++++++++ 3 files changed, 120 insertions(+) create mode 100644 user-service/src/main/java/com/comatching/user/domain/member/service/AdminMemberService.java create mode 100644 user-service/src/main/java/com/comatching/user/domain/member/service/AdminMemberServiceImpl.java create mode 100644 user-service/src/main/java/com/comatching/user/infra/controller/AdminMemberController.java diff --git a/user-service/src/main/java/com/comatching/user/domain/member/service/AdminMemberService.java b/user-service/src/main/java/com/comatching/user/domain/member/service/AdminMemberService.java new file mode 100644 index 0000000..b31c308 --- /dev/null +++ b/user-service/src/main/java/com/comatching/user/domain/member/service/AdminMemberService.java @@ -0,0 +1,19 @@ +package com.comatching.user.domain.member.service; + +import org.springframework.data.domain.Pageable; + +import com.comatching.common.dto.member.AdminUserProfileDto; +import com.comatching.common.dto.response.PagingResponse; + +/** + * TODO(refactor/admin): 반환 타입을 item-service 원본 스펙(AdminUserSummaryResponse/AdminUserDetailResponse, + * 티켓 수량 포함)으로 교체. 해당 DTO가 common-module로 이관된 뒤 적용. + */ +public interface AdminMemberService { + + PagingResponse getUsers(String keyword, Pageable pageable); + + AdminUserProfileDto getUserDetail(Long memberId); + + void updateUserInventory(Long adminId, Long memberId, Object request); +} diff --git a/user-service/src/main/java/com/comatching/user/domain/member/service/AdminMemberServiceImpl.java b/user-service/src/main/java/com/comatching/user/domain/member/service/AdminMemberServiceImpl.java new file mode 100644 index 0000000..4e24e8d --- /dev/null +++ b/user-service/src/main/java/com/comatching/user/domain/member/service/AdminMemberServiceImpl.java @@ -0,0 +1,38 @@ +package com.comatching.user.domain.member.service; + +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.comatching.common.dto.member.AdminUserProfileDto; +import com.comatching.common.dto.response.PagingResponse; +import com.comatching.user.domain.member.repository.MemberRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AdminMemberServiceImpl implements AdminMemberService { + + private final MemberRepository memberRepository; + + @Override + public PagingResponse getUsers(String keyword, Pageable pageable) { + // TODO(refactor/admin): AdminMemberQueryServiceImpl#getUsers 로직 이식 + ItemAdminClient 티켓 수량 합성 + throw new UnsupportedOperationException("not implemented yet"); + } + + @Override + public AdminUserProfileDto getUserDetail(Long memberId) { + // TODO(refactor/admin): AdminMemberQueryServiceImpl#getUserDetail 로직 이식 + ItemAdminClient 티켓 수량 합성 + throw new UnsupportedOperationException("not implemented yet"); + } + + @Override + @Transactional + public void updateUserInventory(Long adminId, Long memberId, Object request) { + // TODO(refactor/admin): getUserDetail 선조회 -> ItemAdminClient.adjustInventory(memberId, adminId, request) 호출 + throw new UnsupportedOperationException("not implemented yet"); + } +} diff --git a/user-service/src/main/java/com/comatching/user/infra/controller/AdminMemberController.java b/user-service/src/main/java/com/comatching/user/infra/controller/AdminMemberController.java new file mode 100644 index 0000000..69f33a8 --- /dev/null +++ b/user-service/src/main/java/com/comatching/user/infra/controller/AdminMemberController.java @@ -0,0 +1,63 @@ +package com.comatching.user.infra.controller; + +import com.comatching.common.dto.response.PagingResponse; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Sort; +import org.springframework.data.web.PageableDefault; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import com.comatching.common.annotation.CurrentMember; +import com.comatching.common.annotation.RequireRole; +import com.comatching.common.domain.enums.MemberRole; +import com.comatching.common.dto.member.MemberInfo; +import com.comatching.common.dto.response.ApiResponse; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; + + +@Tag(name = "Admin User API", description = "관리자 전용 사용자 조회 및 인벤토리 관리 (현재 미구현)") +@RestController +@RequestMapping("/api/admin/users") +public class AdminMemberController { + + @RequireRole(MemberRole.ROLE_ADMIN) + @Operation(summary = "사용자 목록 조회/검색", description = "관리자가 이메일/닉네임/이름 키워드로 사용자 목록을 페이징 조회합니다.") + @GetMapping + public ResponseEntity>> getUsers( + @CurrentMember MemberInfo memberInfo, + @RequestParam(required = false) String keyword, + @PageableDefault(size = 20, sort = "id", direction = Sort.Direction.DESC) Pageable pageable + ) { + return ResponseEntity.status(HttpStatus.NOT_IMPLEMENTED).build(); + } + + @RequireRole(MemberRole.ROLE_ADMIN) + @Operation(summary = "사용자 상세 조회", description = "관리자가 사용자 상세 정보와 보유 아이템 인벤토리를 조회합니다.") + @GetMapping("/{memberId}") + public ResponseEntity> getUserDetail( + @CurrentMember MemberInfo memberInfo, + @PathVariable Long memberId + ) { + return ResponseEntity.status(HttpStatus.NOT_IMPLEMENTED).build(); + } + + @RequireRole(MemberRole.ROLE_ADMIN) + @Operation(summary = "사용자 아이템 인벤토리 수정", description = "관리자가 특정 사용자 인벤토리에 아이템을 추가하거나 차감합니다.") + @PatchMapping("/{memberId}/items") + public ResponseEntity> updateUserInventory( + @CurrentMember MemberInfo memberInfo, + @PathVariable Long memberId, + @RequestBody Object request + ) { + return ResponseEntity.status(HttpStatus.NOT_IMPLEMENTED).build(); + } +} From d5d9c3e2a95838ebab930ff4eb620bdbdae36766 Mon Sep 17 00:00:00 2001 From: choi-jin-wook Date: Fri, 14 Aug 2026 18:46:58 +0900 Subject: [PATCH 02/22] =?UTF-8?q?feat(user/item):=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=EC=9E=90=20=EC=82=AC=EC=9A=A9=EC=9E=90=20=EB=AA=A9=EB=A1=9D=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=EB=A5=BC=20user-service=EB=A1=9C=20=EC=9D=B4?= =?UTF-8?q?=EA=B4=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - item-service에 내부 인벤토리 수량 조회 API 추가 (InternalAdminItemController, AdminItemQueryService) - user-service에 인벤토리 관련 DTO 이관 (AdminInventoryAction/Counts/UpdateRequest, AdminUserDetailResponse, AdminUserSummaryResponse) - user-service에 item-service 내부 API 호출용 ItemAdminClient(Feign) 신규 추가 - AdminMemberService/Impl을 domain/member에서 domain/admin으로 이동하고 인벤토리 수량 조회 로직 구현 - AdminMemberController.getUsers를 AdminMemberService에 연결 (기존 501 스텁 제거) - UserServiceApplication에 @EnableFeignClients, application.yml에 item-service.url 추가 - AdminMemberControllerTest, AdminMemberServiceTest 신규 작성 Co-Authored-By: Claude Sonnet 5 --- .../admin/service/AdminItemQueryService.java | 44 +++++ .../InternalAdminItemController.java | 44 +++++ .../user/UserServiceApplication.java | 2 + .../admin/dto/AdminInventoryAction.java | 6 + .../admin/dto/AdminInventoryCounts.java | 10 ++ .../dto/AdminInventoryUpdateRequest.java | 29 ++++ .../admin/dto/AdminUserDetailResponse.java | 15 ++ .../admin/dto/AdminUserSummaryResponse.java | 29 ++++ .../service/AdminMemberService.java | 5 +- .../admin/service/AdminMemberServiceImpl.java | 93 +++++++++++ .../service/AdminMemberServiceImpl.java | 38 ----- .../user/infra/client/ItemAdminClient.java | 29 ++++ .../controller/AdminMemberController.java | 10 +- .../src/main/resources/application.yml | 3 + .../admin/service/AdminMemberServiceTest.java | 158 ++++++++++++++++++ .../controller/AdminMemberControllerTest.java | 112 +++++++++++++ 16 files changed, 585 insertions(+), 42 deletions(-) create mode 100644 item-service/src/main/java/com/comatching/item/domain/admin/service/AdminItemQueryService.java create mode 100644 item-service/src/main/java/com/comatching/item/infra/controller/InternalAdminItemController.java create mode 100644 user-service/src/main/java/com/comatching/user/domain/admin/dto/AdminInventoryAction.java create mode 100644 user-service/src/main/java/com/comatching/user/domain/admin/dto/AdminInventoryCounts.java create mode 100644 user-service/src/main/java/com/comatching/user/domain/admin/dto/AdminInventoryUpdateRequest.java create mode 100644 user-service/src/main/java/com/comatching/user/domain/admin/dto/AdminUserDetailResponse.java create mode 100644 user-service/src/main/java/com/comatching/user/domain/admin/dto/AdminUserSummaryResponse.java rename user-service/src/main/java/com/comatching/user/domain/{member => admin}/service/AdminMemberService.java (72%) create mode 100644 user-service/src/main/java/com/comatching/user/domain/admin/service/AdminMemberServiceImpl.java delete mode 100644 user-service/src/main/java/com/comatching/user/domain/member/service/AdminMemberServiceImpl.java create mode 100644 user-service/src/main/java/com/comatching/user/infra/client/ItemAdminClient.java create mode 100644 user-service/src/test/java/com/comatching/user/domain/admin/service/AdminMemberServiceTest.java create mode 100644 user-service/src/test/java/com/comatching/user/infra/controller/AdminMemberControllerTest.java diff --git a/item-service/src/main/java/com/comatching/item/domain/admin/service/AdminItemQueryService.java b/item-service/src/main/java/com/comatching/item/domain/admin/service/AdminItemQueryService.java new file mode 100644 index 0000000..6bf64f2 --- /dev/null +++ b/item-service/src/main/java/com/comatching/item/domain/admin/service/AdminItemQueryService.java @@ -0,0 +1,44 @@ +package com.comatching.item.domain.admin.service; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.comatching.common.domain.enums.ItemType; +import com.comatching.item.domain.admin.dto.AdminInventoryCounts; +import com.comatching.item.domain.item.repository.ItemRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AdminItemQueryService { + private final ItemRepository itemRepository; + + public Map getInventoryCounts(List memberIds) { + if (memberIds == null || memberIds.isEmpty()) { + return Map.of(); + } + + Map countsByMemberId = new HashMap<>(); + for (ItemRepository.MemberItemQuantity quantity : itemRepository.sumUsableQuantityByMemberIds(memberIds)) { + long[] counts = countsByMemberId.computeIfAbsent(quantity.getMemberId(), ignored -> new long[2]); + if (quantity.getItemType() == ItemType.MATCHING_TICKET) { + counts[0] = quantity.getQuantity(); + } + if (quantity.getItemType() == ItemType.OPTION_TICKET) { + counts[1] = quantity.getQuantity(); + } + } + + Map result = new HashMap<>(); + countsByMemberId.forEach((memberId, counts) -> + result.put(memberId, new AdminInventoryCounts(counts[0], counts[1])) + ); + return result; + } +} diff --git a/item-service/src/main/java/com/comatching/item/infra/controller/InternalAdminItemController.java b/item-service/src/main/java/com/comatching/item/infra/controller/InternalAdminItemController.java new file mode 100644 index 0000000..6d698ac --- /dev/null +++ b/item-service/src/main/java/com/comatching/item/infra/controller/InternalAdminItemController.java @@ -0,0 +1,44 @@ +package com.comatching.item.infra.controller; + +import java.util.List; +import java.util.Map; + +import com.comatching.item.domain.admin.service.AdminItemQueryService; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import com.comatching.item.domain.admin.dto.AdminInventoryCounts; +import com.comatching.item.domain.admin.dto.AdminInventoryUpdateRequest; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/api/internal/admin/items") +@RequiredArgsConstructor +public class InternalAdminItemController { + private final AdminItemQueryService adminItemQueryService; + + @GetMapping + public ResponseEntity> getInventoryCounts( + @RequestParam("memberIds") List memberIds + ) { + return ResponseEntity.ok(adminItemQueryService.getInventoryCounts(memberIds)); + } + + @PatchMapping("/{memberId}") + public ResponseEntity adjustInventory( + @PathVariable Long memberId, + @RequestHeader("X-Admin-Id") Long adminId, + @RequestBody AdminInventoryUpdateRequest request + ) { + return ResponseEntity.status(HttpStatus.NOT_IMPLEMENTED).build(); + } +} diff --git a/user-service/src/main/java/com/comatching/user/UserServiceApplication.java b/user-service/src/main/java/com/comatching/user/UserServiceApplication.java index 66327ba..3e1b411 100644 --- a/user-service/src/main/java/com/comatching/user/UserServiceApplication.java +++ b/user-service/src/main/java/com/comatching/user/UserServiceApplication.java @@ -4,6 +4,7 @@ import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.autoconfigure.security.servlet.UserDetailsServiceAutoConfiguration; import org.springframework.boot.context.properties.EnableConfigurationProperties; +import org.springframework.cloud.openfeign.EnableFeignClients; import org.springframework.context.annotation.ComponentScan; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; @@ -12,6 +13,7 @@ @SpringBootApplication(exclude = {UserDetailsServiceAutoConfiguration.class}) @ComponentScan(basePackages = {"com.comatching.user", "com.comatching.common"}) @EnableConfigurationProperties(ProfileImageProperties.class) +@EnableFeignClients(basePackages = "com.comatching.user") @EnableJpaAuditing public class UserServiceApplication { diff --git a/user-service/src/main/java/com/comatching/user/domain/admin/dto/AdminInventoryAction.java b/user-service/src/main/java/com/comatching/user/domain/admin/dto/AdminInventoryAction.java new file mode 100644 index 0000000..32b846b --- /dev/null +++ b/user-service/src/main/java/com/comatching/user/domain/admin/dto/AdminInventoryAction.java @@ -0,0 +1,6 @@ +package com.comatching.user.domain.admin.dto; + +public enum AdminInventoryAction { + ADD, + REMOVE +} diff --git a/user-service/src/main/java/com/comatching/user/domain/admin/dto/AdminInventoryCounts.java b/user-service/src/main/java/com/comatching/user/domain/admin/dto/AdminInventoryCounts.java new file mode 100644 index 0000000..935bc9a --- /dev/null +++ b/user-service/src/main/java/com/comatching/user/domain/admin/dto/AdminInventoryCounts.java @@ -0,0 +1,10 @@ +package com.comatching.user.domain.admin.dto; + +public record AdminInventoryCounts( + long matchingTicketCount, + long optionTicketCount +) { + public static AdminInventoryCounts empty() { + return new AdminInventoryCounts(0, 0); + } +} diff --git a/user-service/src/main/java/com/comatching/user/domain/admin/dto/AdminInventoryUpdateRequest.java b/user-service/src/main/java/com/comatching/user/domain/admin/dto/AdminInventoryUpdateRequest.java new file mode 100644 index 0000000..4f46c65 --- /dev/null +++ b/user-service/src/main/java/com/comatching/user/domain/admin/dto/AdminInventoryUpdateRequest.java @@ -0,0 +1,29 @@ +package com.comatching.user.domain.admin.dto; + +import com.comatching.common.domain.enums.ItemType; +import com.comatching.user.domain.admin.dto.AdminInventoryAction; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; +import jakarta.validation.constraints.Size; + +public record AdminInventoryUpdateRequest( + @NotNull(message = "아이템 타입은 필수입니다.") + ItemType itemType, + + @Positive(message = "수량은 1 이상이어야 합니다.") + int quantity, + + @NotNull(message = "수정 액션은 필수입니다.") + AdminInventoryAction action, + + @NotBlank(message = "수정 사유는 필수입니다.") + @Size(max = 255, message = "수정 사유는 255자 이하여야 합니다.") + String reason +) { + public AdminInventoryUpdateRequest { + if (reason != null) { + reason = reason.trim(); + } + } +} diff --git a/user-service/src/main/java/com/comatching/user/domain/admin/dto/AdminUserDetailResponse.java b/user-service/src/main/java/com/comatching/user/domain/admin/dto/AdminUserDetailResponse.java new file mode 100644 index 0000000..a9a30f2 --- /dev/null +++ b/user-service/src/main/java/com/comatching/user/domain/admin/dto/AdminUserDetailResponse.java @@ -0,0 +1,15 @@ +package com.comatching.user.domain.admin.dto; + +import com.comatching.common.domain.enums.Gender; + +public record AdminUserDetailResponse( + Long id, + String email, + String realName, + String nickname, + Gender gender, + String profileImageUrl, + long matchingTicketCount, + long optionTicketCount +) { +} diff --git a/user-service/src/main/java/com/comatching/user/domain/admin/dto/AdminUserSummaryResponse.java b/user-service/src/main/java/com/comatching/user/domain/admin/dto/AdminUserSummaryResponse.java new file mode 100644 index 0000000..243a694 --- /dev/null +++ b/user-service/src/main/java/com/comatching/user/domain/admin/dto/AdminUserSummaryResponse.java @@ -0,0 +1,29 @@ +package com.comatching.user.domain.admin.dto; + +import com.comatching.common.domain.enums.Gender; +import com.comatching.common.dto.member.AdminUserProfileDto; +import com.comatching.user.domain.admin.dto.AdminInventoryCounts; + +public record AdminUserSummaryResponse( + Long id, + String email, + String realName, + String nickname, + Gender gender, + String profileImageUrl, + long matchingTicketCount, + long optionTicketCount +) { + public static AdminUserSummaryResponse from(AdminUserProfileDto dto, AdminInventoryCounts inventoryCounts) { + return new AdminUserSummaryResponse( + dto.id(), + dto.email(), + dto.realName(), + dto.nickname(), + dto.gender(), + dto.profileImageUrl(), + inventoryCounts.matchingTicketCount(), + inventoryCounts.optionTicketCount() + ); + } +} diff --git a/user-service/src/main/java/com/comatching/user/domain/member/service/AdminMemberService.java b/user-service/src/main/java/com/comatching/user/domain/admin/service/AdminMemberService.java similarity index 72% rename from user-service/src/main/java/com/comatching/user/domain/member/service/AdminMemberService.java rename to user-service/src/main/java/com/comatching/user/domain/admin/service/AdminMemberService.java index b31c308..689daea 100644 --- a/user-service/src/main/java/com/comatching/user/domain/member/service/AdminMemberService.java +++ b/user-service/src/main/java/com/comatching/user/domain/admin/service/AdminMemberService.java @@ -1,5 +1,6 @@ -package com.comatching.user.domain.member.service; +package com.comatching.user.domain.admin.service; +import com.comatching.user.domain.admin.dto.AdminUserSummaryResponse; import org.springframework.data.domain.Pageable; import com.comatching.common.dto.member.AdminUserProfileDto; @@ -11,7 +12,7 @@ */ public interface AdminMemberService { - PagingResponse getUsers(String keyword, Pageable pageable); + PagingResponse getUsers(String keyword, Pageable pageable); AdminUserProfileDto getUserDetail(Long memberId); diff --git a/user-service/src/main/java/com/comatching/user/domain/admin/service/AdminMemberServiceImpl.java b/user-service/src/main/java/com/comatching/user/domain/admin/service/AdminMemberServiceImpl.java new file mode 100644 index 0000000..1007c27 --- /dev/null +++ b/user-service/src/main/java/com/comatching/user/domain/admin/service/AdminMemberServiceImpl.java @@ -0,0 +1,93 @@ +package com.comatching.user.domain.admin.service; + +import com.comatching.user.domain.admin.dto.AdminInventoryCounts; +import com.comatching.user.domain.admin.dto.AdminUserSummaryResponse; +import com.comatching.user.infra.client.ItemAdminClient; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import com.comatching.common.domain.enums.MemberRole; +import com.comatching.common.domain.enums.MemberStatus; +import com.comatching.common.dto.member.AdminUserProfileDto; +import com.comatching.common.dto.response.PagingResponse; +import com.comatching.user.domain.member.entity.Member; +import com.comatching.user.domain.member.repository.MemberRepository; + +import lombok.RequiredArgsConstructor; + +import java.util.List; +import java.util.Map; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class AdminMemberServiceImpl implements AdminMemberService { + + private final MemberRepository memberRepository; + private final ItemAdminClient itemAdminClient; + + @Override + public PagingResponse getUsers(String keyword, Pageable pageable) { + String normalizedKeyword = StringUtils.hasText(keyword) ? keyword.trim() : null; + + Page userPage = memberRepository.searchMembersForAdmin( + MemberStatus.ACTIVE, + MemberRole.ROLE_USER, + normalizedKeyword, + pageable + ) + .map(this::toAdminUserProfileDto); + + List users = userPage.getContent(); + Map inventoryCountsByMemberId = itemAdminClient.getInventoryCounts( + users.stream() + .map(AdminUserProfileDto::id) + .toList() + ); + + List summaries = users.stream() + .map(user -> AdminUserSummaryResponse.from( + user, + inventoryCountsByMemberId.getOrDefault(user.id(), AdminInventoryCounts.empty()) + )) + .toList(); + + + return new PagingResponse<>( + summaries, + userPage.getNumber(), + userPage.getSize(), + userPage.getTotalElements(), + userPage.getTotalPages(), + userPage.hasNext(), + userPage.hasPrevious() + ); + } + + @Override + public AdminUserProfileDto getUserDetail(Long memberId) { + // TODO(refactor/admin): AdminMemberQueryServiceImpl#getUserDetail 로직 이식 + ItemAdminClient 티켓 수량 합성 + throw new UnsupportedOperationException("not implemented yet"); + } + + @Override + @Transactional + public void updateUserInventory(Long adminId, Long memberId, Object request) { + // TODO(refactor/admin): getUserDetail 선조회 -> ItemAdminClient.adjustInventory(memberId, adminId, request) 호출 + throw new UnsupportedOperationException("not implemented yet"); + } + + private AdminUserProfileDto toAdminUserProfileDto(Member member) { + return new AdminUserProfileDto( + member.getId(), + member.getEmail(), + member.getRealName(), + member.getProfile().getNickname(), + member.getProfile().getGender(), + member.getProfile().getProfileImageUrl() + ); + } +} diff --git a/user-service/src/main/java/com/comatching/user/domain/member/service/AdminMemberServiceImpl.java b/user-service/src/main/java/com/comatching/user/domain/member/service/AdminMemberServiceImpl.java deleted file mode 100644 index 4e24e8d..0000000 --- a/user-service/src/main/java/com/comatching/user/domain/member/service/AdminMemberServiceImpl.java +++ /dev/null @@ -1,38 +0,0 @@ -package com.comatching.user.domain.member.service; - -import org.springframework.data.domain.Pageable; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -import com.comatching.common.dto.member.AdminUserProfileDto; -import com.comatching.common.dto.response.PagingResponse; -import com.comatching.user.domain.member.repository.MemberRepository; - -import lombok.RequiredArgsConstructor; - -@Service -@RequiredArgsConstructor -@Transactional(readOnly = true) -public class AdminMemberServiceImpl implements AdminMemberService { - - private final MemberRepository memberRepository; - - @Override - public PagingResponse getUsers(String keyword, Pageable pageable) { - // TODO(refactor/admin): AdminMemberQueryServiceImpl#getUsers 로직 이식 + ItemAdminClient 티켓 수량 합성 - throw new UnsupportedOperationException("not implemented yet"); - } - - @Override - public AdminUserProfileDto getUserDetail(Long memberId) { - // TODO(refactor/admin): AdminMemberQueryServiceImpl#getUserDetail 로직 이식 + ItemAdminClient 티켓 수량 합성 - throw new UnsupportedOperationException("not implemented yet"); - } - - @Override - @Transactional - public void updateUserInventory(Long adminId, Long memberId, Object request) { - // TODO(refactor/admin): getUserDetail 선조회 -> ItemAdminClient.adjustInventory(memberId, adminId, request) 호출 - throw new UnsupportedOperationException("not implemented yet"); - } -} diff --git a/user-service/src/main/java/com/comatching/user/infra/client/ItemAdminClient.java b/user-service/src/main/java/com/comatching/user/infra/client/ItemAdminClient.java new file mode 100644 index 0000000..6f3e13a --- /dev/null +++ b/user-service/src/main/java/com/comatching/user/infra/client/ItemAdminClient.java @@ -0,0 +1,29 @@ +package com.comatching.user.infra.client; + +import java.util.List; +import java.util.Map; + +import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestParam; + +import com.comatching.user.domain.admin.dto.AdminInventoryCounts; +import com.comatching.user.domain.admin.dto.AdminInventoryUpdateRequest; + +@FeignClient(name = "item-service-admin", url = "${item-service.url}", path = "/api/internal/admin/items") +public interface ItemAdminClient { + + @GetMapping + Map getInventoryCounts(@RequestParam("memberIds") List memberIds); + + @PatchMapping("/{memberId}") + void adjustInventory( + @PathVariable Long memberId, + @RequestHeader("X-Admin-Id") Long adminId, + @RequestBody AdminInventoryUpdateRequest request + ); +} diff --git a/user-service/src/main/java/com/comatching/user/infra/controller/AdminMemberController.java b/user-service/src/main/java/com/comatching/user/infra/controller/AdminMemberController.java index 69f33a8..1279af0 100644 --- a/user-service/src/main/java/com/comatching/user/infra/controller/AdminMemberController.java +++ b/user-service/src/main/java/com/comatching/user/infra/controller/AdminMemberController.java @@ -19,25 +19,31 @@ import com.comatching.common.domain.enums.MemberRole; import com.comatching.common.dto.member.MemberInfo; import com.comatching.common.dto.response.ApiResponse; +import com.comatching.user.domain.admin.dto.AdminUserSummaryResponse; +import com.comatching.user.domain.admin.service.AdminMemberService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; @Tag(name = "Admin User API", description = "관리자 전용 사용자 조회 및 인벤토리 관리 (현재 미구현)") @RestController @RequestMapping("/api/admin/users") +@RequiredArgsConstructor public class AdminMemberController { + private final AdminMemberService adminMemberService; + @RequireRole(MemberRole.ROLE_ADMIN) @Operation(summary = "사용자 목록 조회/검색", description = "관리자가 이메일/닉네임/이름 키워드로 사용자 목록을 페이징 조회합니다.") @GetMapping - public ResponseEntity>> getUsers( + public ResponseEntity>> getUsers( @CurrentMember MemberInfo memberInfo, @RequestParam(required = false) String keyword, @PageableDefault(size = 20, sort = "id", direction = Sort.Direction.DESC) Pageable pageable ) { - return ResponseEntity.status(HttpStatus.NOT_IMPLEMENTED).build(); + return ResponseEntity.ok(ApiResponse.ok(adminMemberService.getUsers(keyword, pageable))); } @RequireRole(MemberRole.ROLE_ADMIN) diff --git a/user-service/src/main/resources/application.yml b/user-service/src/main/resources/application.yml index 3809a0f..06efdb0 100644 --- a/user-service/src/main/resources/application.yml +++ b/user-service/src/main/resources/application.yml @@ -12,6 +12,9 @@ server: client: url: ${CLIENT_URL:http://localhost:3000} +item-service: + url: ${ITEM_SERVICE_URL:http://localhost:9006} + auth: cookie: secure: ${AUTH_COOKIE_SECURE:false} diff --git a/user-service/src/test/java/com/comatching/user/domain/admin/service/AdminMemberServiceTest.java b/user-service/src/test/java/com/comatching/user/domain/admin/service/AdminMemberServiceTest.java new file mode 100644 index 0000000..407993a --- /dev/null +++ b/user-service/src/test/java/com/comatching/user/domain/admin/service/AdminMemberServiceTest.java @@ -0,0 +1,158 @@ +package com.comatching.user.domain.admin.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.anyList; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; + +import java.util.List; +import java.util.Map; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Sort; +import org.springframework.test.util.ReflectionTestUtils; + +import com.comatching.common.domain.enums.Gender; +import com.comatching.common.domain.enums.MemberRole; +import com.comatching.common.domain.enums.MemberStatus; +import com.comatching.common.dto.response.PagingResponse; +import com.comatching.user.domain.admin.dto.AdminInventoryCounts; +import com.comatching.user.domain.admin.dto.AdminUserSummaryResponse; +import com.comatching.user.domain.member.entity.Member; +import com.comatching.user.domain.member.entity.Profile; +import com.comatching.user.domain.member.repository.MemberRepository; +import com.comatching.user.infra.client.ItemAdminClient; + +@ExtendWith(MockitoExtension.class) +@DisplayName("AdminMemberServiceImpl 테스트") +class AdminMemberServiceTest { + + @InjectMocks + private AdminMemberServiceImpl adminMemberService; + + @Mock + private MemberRepository memberRepository; + + @Mock + private ItemAdminClient itemAdminClient; + + @Test + @DisplayName("사용자 목록과 인벤토리 수량을 함께 조회한다") + void shouldReturnUsersWithInventoryCounts() { + // given + PageRequest pageable = PageRequest.of(0, 20, Sort.by(Sort.Direction.DESC, "id")); + Member member1 = createMemberWithProfile(1L, "user1@test.com", "홍길동", "닉네임1", Gender.FEMALE, "https://img1"); + Member member2 = createMemberWithProfile(2L, "user2@test.com", "김철수", "닉네임2", Gender.MALE, "https://img2"); + + given(memberRepository.searchMembersForAdmin(MemberStatus.ACTIVE, MemberRole.ROLE_USER, null, pageable)) + .willReturn(new PageImpl<>(List.of(member1, member2), pageable, 2)); + given(itemAdminClient.getInventoryCounts(anyList())) + .willReturn(Map.of(1L, new AdminInventoryCounts(3L, 1L))); + + // when + PagingResponse result = adminMemberService.getUsers(null, pageable); + + // then + assertThat(result.content()).hasSize(2); + assertThat(result.totalElements()).isEqualTo(2); + + AdminUserSummaryResponse first = result.content().get(0); + assertThat(first.id()).isEqualTo(1L); + assertThat(first.email()).isEqualTo("user1@test.com"); + assertThat(first.matchingTicketCount()).isEqualTo(3L); + assertThat(first.optionTicketCount()).isEqualTo(1L); + + AdminUserSummaryResponse second = result.content().get(1); + assertThat(second.id()).isEqualTo(2L); + assertThat(second.matchingTicketCount()).isZero(); + assertThat(second.optionTicketCount()).isZero(); + } + + @Test + @DisplayName("인벤토리 조회 결과에 없는 사용자는 수량 0으로 채운다") + void shouldFallbackToEmptyInventoryWhenMissing() { + // given + PageRequest pageable = PageRequest.of(0, 20, Sort.by(Sort.Direction.DESC, "id")); + Member member = createMemberWithProfile(5L, "user5@test.com", "이영희", "닉네임5", Gender.FEMALE, "https://img5"); + + given(memberRepository.searchMembersForAdmin(MemberStatus.ACTIVE, MemberRole.ROLE_USER, null, pageable)) + .willReturn(new PageImpl<>(List.of(member), pageable, 1)); + given(itemAdminClient.getInventoryCounts(anyList())).willReturn(Map.of()); + + // when + PagingResponse result = adminMemberService.getUsers(null, pageable); + + // then + assertThat(result.content()).hasSize(1); + assertThat(result.content().get(0).matchingTicketCount()).isZero(); + assertThat(result.content().get(0).optionTicketCount()).isZero(); + } + + @Test + @DisplayName("keyword 앞뒤 공백을 제거해서 저장소로 전달한다") + void shouldTrimKeywordBeforeQuery() { + // given + PageRequest pageable = PageRequest.of(0, 20, Sort.by(Sort.Direction.DESC, "id")); + + given(memberRepository.searchMembersForAdmin(MemberStatus.ACTIVE, MemberRole.ROLE_USER, "nickname", pageable)) + .willReturn(new PageImpl<>(List.of(), pageable, 0)); + + // when + adminMemberService.getUsers(" nickname ", pageable); + + // then + then(memberRepository).should() + .searchMembersForAdmin(MemberStatus.ACTIVE, MemberRole.ROLE_USER, "nickname", pageable); + } + + @Test + @DisplayName("빈 문자열 keyword는 null로 정규화해서 전달한다") + void shouldNormalizeBlankKeywordToNull() { + // given + PageRequest pageable = PageRequest.of(0, 20, Sort.by(Sort.Direction.DESC, "id")); + + given(memberRepository.searchMembersForAdmin(MemberStatus.ACTIVE, MemberRole.ROLE_USER, null, pageable)) + .willReturn(new PageImpl<>(List.of(), pageable, 0)); + + // when + adminMemberService.getUsers(" ", pageable); + + // then + then(memberRepository).should() + .searchMembersForAdmin(MemberStatus.ACTIVE, MemberRole.ROLE_USER, null, pageable); + } + + private static Member createMemberWithProfile( + Long id, + String email, + String realName, + String nickname, + Gender gender, + String imageUrl + ) { + Member member = Member.builder() + .email(email) + .role(MemberRole.ROLE_USER) + .status(MemberStatus.ACTIVE) + .build(); + member.updateRealName(realName); + + Profile profile = Profile.builder() + .member(member) + .nickname(nickname) + .gender(gender) + .profileImageUrl(imageUrl) + .build(); + + member.setProfile(profile); + ReflectionTestUtils.setField(member, "id", id); + return member; + } +} diff --git a/user-service/src/test/java/com/comatching/user/infra/controller/AdminMemberControllerTest.java b/user-service/src/test/java/com/comatching/user/infra/controller/AdminMemberControllerTest.java new file mode 100644 index 0000000..a022471 --- /dev/null +++ b/user-service/src/test/java/com/comatching/user/infra/controller/AdminMemberControllerTest.java @@ -0,0 +1,112 @@ +package com.comatching.user.infra.controller; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableHandlerMethodArgumentResolver; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import com.comatching.common.domain.enums.Gender; +import com.comatching.common.dto.response.PagingResponse; +import com.comatching.common.exception.handler.GlobalExceptionHandler; +import com.comatching.common.resolver.MemberInfoArgumentResolver; +import com.comatching.user.domain.admin.dto.AdminUserSummaryResponse; +import com.comatching.user.domain.admin.service.AdminMemberService; +import com.fasterxml.jackson.databind.ObjectMapper; + +@ExtendWith(MockitoExtension.class) +class AdminMemberControllerTest { + + private MockMvc mockMvc; + + @Mock + private AdminMemberService adminMemberService; + + @InjectMocks + private AdminMemberController adminMemberController; + + private static final Long ADMIN_ID = 999L; + private static final String SUCCESS_CODE = "GEN-000"; + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders.standaloneSetup(adminMemberController) + .setCustomArgumentResolvers(new MemberInfoArgumentResolver(), new PageableHandlerMethodArgumentResolver()) + .setControllerAdvice(new GlobalExceptionHandler(new ObjectMapper())) + .build(); + } + + @Test + @DisplayName("GET /api/admin/users - 사용자 목록을 조회한다") + void getUsers_success() throws Exception { + // given + AdminUserSummaryResponse summary = new AdminUserSummaryResponse( + 1L, "user@test.com", "홍길동", "닉네임", Gender.FEMALE, "https://img", 3L, 1L + ); + PagingResponse response = + new PagingResponse<>(List.of(summary), 0, 20, 1, 1, false, false); + + given(adminMemberService.getUsers(eq(null), any(Pageable.class))).willReturn(response); + + // when & then + mockMvc.perform(get("/api/admin/users") + .header("X-Member-Id", ADMIN_ID) + .header("X-Member-Role", "ROLE_ADMIN")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(SUCCESS_CODE)) + .andExpect(jsonPath("$.data.content.length()").value(1)) + .andExpect(jsonPath("$.data.content[0].id").value(1)) + .andExpect(jsonPath("$.data.content[0].email").value("user@test.com")) + .andExpect(jsonPath("$.data.content[0].matchingTicketCount").value(3)) + .andExpect(jsonPath("$.data.content[0].optionTicketCount").value(1)) + .andExpect(jsonPath("$.data.totalElements").value(1)); + + then(adminMemberService).should().getUsers(eq(null), any(Pageable.class)); + } + + @Test + @DisplayName("GET /api/admin/users?keyword= - 키워드를 서비스로 그대로 전달한다") + void getUsers_withKeyword() throws Exception { + // given + PagingResponse response = + new PagingResponse<>(List.of(), 0, 20, 0, 0, false, false); + + given(adminMemberService.getUsers(eq("nickname"), any(Pageable.class))).willReturn(response); + + // when & then + mockMvc.perform(get("/api/admin/users") + .param("keyword", "nickname") + .header("X-Member-Id", ADMIN_ID) + .header("X-Member-Role", "ROLE_ADMIN")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(SUCCESS_CODE)) + .andExpect(jsonPath("$.data.content.length()").value(0)); + + then(adminMemberService).should().getUsers(eq("nickname"), any(Pageable.class)); + } + + @Test + @DisplayName("GET /api/admin/users - 인증 헤더가 없으면 예외가 발생한다") + void getUsers_missingMemberIdHeader() throws Exception { + mockMvc.perform(get("/api/admin/users")) + .andExpect(status().isInternalServerError()); + + then(adminMemberService).shouldHaveNoInteractions(); + } +} From b97da7c06791691aca89a83fc0d6086f6556aec9 Mon Sep 17 00:00:00 2001 From: jinwook219 Date: Sun, 16 Aug 2026 20:35:28 +0900 Subject: [PATCH 03/22] =?UTF-8?q?feat(user):=20=EA=B4=80=EB=A6=AC=EC=9E=90?= =?UTF-8?q?=20=EC=82=AC=EC=9A=A9=EC=9E=90=20=EC=83=81=EC=84=B8=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=EB=A5=BC=20user-service=EB=A1=9C=20=EC=9D=B4=EA=B4=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AdminMemberServiceImpl#getUserDetail 구현 (로컬 회원 조회 + ItemAdminClient로 인벤토리 수량 합성) - AdminUserDetailResponse#from 정적 팩토리 추가 - AdminMemberService/AdminMemberController 반환 타입을 AdminUserDetailResponse로 변경 - item-service AdminUserController Swagger 설명에 리팩토링 완료 표기 - gradlew 줄바꿈, chat-service/build.gradle 공백 정리 - .gitignore에 *.txt 추가 Co-Authored-By: Claude Sonnet 5 --- .gitignore | 1 + chat-service/build.gradle | 2 +- .../infra/controller/AdminUserController.java | 2 +- .../admin/dto/AdminUserDetailResponse.java | 13 ++++++++++ .../admin/service/AdminMemberService.java | 10 +++----- .../admin/service/AdminMemberServiceImpl.java | 25 ++++++++++++++++--- .../controller/AdminMemberController.java | 5 ++-- 7 files changed, 44 insertions(+), 14 deletions(-) diff --git a/.gitignore b/.gitignore index 5bfb258..fcf65ac 100644 --- a/.gitignore +++ b/.gitignore @@ -74,3 +74,4 @@ tools/perf/jmeter/results/ .pids/ logs/ cookies.txt +*.txt diff --git a/chat-service/build.gradle b/chat-service/build.gradle index 9f7c4ee..461cc86 100644 --- a/chat-service/build.gradle +++ b/chat-service/build.gradle @@ -1,4 +1,4 @@ -plugins { + plugins { id 'java' id 'org.springframework.boot' id 'io.spring.dependency-management' diff --git a/item-service/src/main/java/com/comatching/item/infra/controller/AdminUserController.java b/item-service/src/main/java/com/comatching/item/infra/controller/AdminUserController.java index 5ef736d..30325e5 100644 --- a/item-service/src/main/java/com/comatching/item/infra/controller/AdminUserController.java +++ b/item-service/src/main/java/com/comatching/item/infra/controller/AdminUserController.java @@ -37,7 +37,7 @@ public class AdminUserController { private final AdminUserItemService adminUserItemService; @RequireRole(MemberRole.ROLE_ADMIN) - @Operation(summary = "사용자 목록 조회/검색", description = "관리자가 이메일/닉네임/이름 키워드로 사용자 목록을 페이징 조회합니다.") + @Operation(summary = "사용자 목록 조회/검색", description = "관리자가 이메일/닉네임/이름 키워드로 사용자 목록을 페이징 조회합니다. (리팩토링 완료)") @GetMapping public ResponseEntity>> getUsers( @CurrentMember MemberInfo memberInfo, diff --git a/user-service/src/main/java/com/comatching/user/domain/admin/dto/AdminUserDetailResponse.java b/user-service/src/main/java/com/comatching/user/domain/admin/dto/AdminUserDetailResponse.java index a9a30f2..00897b3 100644 --- a/user-service/src/main/java/com/comatching/user/domain/admin/dto/AdminUserDetailResponse.java +++ b/user-service/src/main/java/com/comatching/user/domain/admin/dto/AdminUserDetailResponse.java @@ -1,6 +1,7 @@ package com.comatching.user.domain.admin.dto; import com.comatching.common.domain.enums.Gender; +import com.comatching.common.dto.member.AdminUserProfileDto; public record AdminUserDetailResponse( Long id, @@ -12,4 +13,16 @@ public record AdminUserDetailResponse( long matchingTicketCount, long optionTicketCount ) { + public static AdminUserDetailResponse from(AdminUserProfileDto dto, AdminInventoryCounts inventoryCounts) { + return new AdminUserDetailResponse( + dto.id(), + dto.email(), + dto.realName(), + dto.nickname(), + dto.gender(), + dto.profileImageUrl(), + inventoryCounts.matchingTicketCount(), + inventoryCounts.optionTicketCount() + ); + } } diff --git a/user-service/src/main/java/com/comatching/user/domain/admin/service/AdminMemberService.java b/user-service/src/main/java/com/comatching/user/domain/admin/service/AdminMemberService.java index 689daea..19dfa80 100644 --- a/user-service/src/main/java/com/comatching/user/domain/admin/service/AdminMemberService.java +++ b/user-service/src/main/java/com/comatching/user/domain/admin/service/AdminMemberService.java @@ -1,20 +1,16 @@ package com.comatching.user.domain.admin.service; -import com.comatching.user.domain.admin.dto.AdminUserSummaryResponse; import org.springframework.data.domain.Pageable; -import com.comatching.common.dto.member.AdminUserProfileDto; import com.comatching.common.dto.response.PagingResponse; +import com.comatching.user.domain.admin.dto.AdminUserDetailResponse; +import com.comatching.user.domain.admin.dto.AdminUserSummaryResponse; -/** - * TODO(refactor/admin): 반환 타입을 item-service 원본 스펙(AdminUserSummaryResponse/AdminUserDetailResponse, - * 티켓 수량 포함)으로 교체. 해당 DTO가 common-module로 이관된 뒤 적용. - */ public interface AdminMemberService { PagingResponse getUsers(String keyword, Pageable pageable); - AdminUserProfileDto getUserDetail(Long memberId); + AdminUserDetailResponse getUserDetail(Long memberId); void updateUserInventory(Long adminId, Long memberId, Object request); } diff --git a/user-service/src/main/java/com/comatching/user/domain/admin/service/AdminMemberServiceImpl.java b/user-service/src/main/java/com/comatching/user/domain/admin/service/AdminMemberServiceImpl.java index 1007c27..d80d327 100644 --- a/user-service/src/main/java/com/comatching/user/domain/admin/service/AdminMemberServiceImpl.java +++ b/user-service/src/main/java/com/comatching/user/domain/admin/service/AdminMemberServiceImpl.java @@ -1,7 +1,11 @@ package com.comatching.user.domain.admin.service; +import com.comatching.common.exception.BusinessException; +import com.comatching.common.exception.code.GeneralErrorCode; import com.comatching.user.domain.admin.dto.AdminInventoryCounts; +import com.comatching.user.domain.admin.dto.AdminUserDetailResponse; import com.comatching.user.domain.admin.dto.AdminUserSummaryResponse; +import com.comatching.user.global.exception.UserErrorCode; import com.comatching.user.infra.client.ItemAdminClient; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -17,10 +21,12 @@ import com.comatching.user.domain.member.repository.MemberRepository; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import java.util.List; import java.util.Map; +@Slf4j @Service @RequiredArgsConstructor @Transactional(readOnly = true) @@ -68,9 +74,19 @@ public PagingResponse getUsers(String keyword, Pageabl } @Override - public AdminUserProfileDto getUserDetail(Long memberId) { - // TODO(refactor/admin): AdminMemberQueryServiceImpl#getUserDetail 로직 이식 + ItemAdminClient 티켓 수량 합성 - throw new UnsupportedOperationException("not implemented yet"); + public AdminUserDetailResponse getUserDetail(Long memberId) { + if (memberId == null || memberId <= 0) { + throw new BusinessException(GeneralErrorCode.INVALID_INPUT_VALUE, "memberId는 1 이상의 값이어야 합니다."); + } + + Member member = memberRepository.findAdminMemberById(memberId, MemberStatus.ACTIVE, MemberRole.ROLE_USER) + .orElseThrow(() -> new BusinessException(UserErrorCode.USER_NOT_EXIST)); + AdminUserProfileDto user = toAdminUserProfileDto(member); + + Map inventoryCountsByMemberId = itemAdminClient.getInventoryCounts(List.of(memberId)); + AdminInventoryCounts inventoryCounts = inventoryCountsByMemberId.getOrDefault(memberId, AdminInventoryCounts.empty()); + + return AdminUserDetailResponse.from(user, inventoryCounts); } @Override @@ -90,4 +106,7 @@ private AdminUserProfileDto toAdminUserProfileDto(Member member) { member.getProfile().getProfileImageUrl() ); } + + + } diff --git a/user-service/src/main/java/com/comatching/user/infra/controller/AdminMemberController.java b/user-service/src/main/java/com/comatching/user/infra/controller/AdminMemberController.java index 1279af0..04d25b4 100644 --- a/user-service/src/main/java/com/comatching/user/infra/controller/AdminMemberController.java +++ b/user-service/src/main/java/com/comatching/user/infra/controller/AdminMemberController.java @@ -1,6 +1,7 @@ package com.comatching.user.infra.controller; import com.comatching.common.dto.response.PagingResponse; +import com.comatching.user.domain.admin.dto.AdminUserDetailResponse; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; import org.springframework.data.web.PageableDefault; @@ -49,11 +50,11 @@ public ResponseEntity>> get @RequireRole(MemberRole.ROLE_ADMIN) @Operation(summary = "사용자 상세 조회", description = "관리자가 사용자 상세 정보와 보유 아이템 인벤토리를 조회합니다.") @GetMapping("/{memberId}") - public ResponseEntity> getUserDetail( + public ResponseEntity> getUserDetail( @CurrentMember MemberInfo memberInfo, @PathVariable Long memberId ) { - return ResponseEntity.status(HttpStatus.NOT_IMPLEMENTED).build(); + return ResponseEntity.ok(ApiResponse.ok(adminMemberService.getUserDetail(memberId))); } @RequireRole(MemberRole.ROLE_ADMIN) From 8de989a30f58860aca2893a3fc3fb579c54644c1 Mon Sep 17 00:00:00 2001 From: jinwook219 Date: Mon, 17 Aug 2026 13:30:52 +0900 Subject: [PATCH 04/22] =?UTF-8?q?feat(user/item):=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=EC=9E=90=20=EC=9D=B8=EB=B2=A4=ED=86=A0=EB=A6=AC=20=EC=A1=B0?= =?UTF-8?q?=EC=A0=95=EC=9D=84=20user-service=EB=A1=9C=20=EC=9D=B4=EA=B4=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - item-service: PATCH /api/internal/admin/items/{memberId} 내부 API 구현 (AdminItemCommandService) - item-service: dedupe/adjustment에 중복 구현돼 있던 검증 로직을 AdminInventoryRequestValidator로 통합해 요청당 한 번만 실행되도록 정리 - user-service: AdminMemberServiceImpl.updateUserInventory에서 ItemAdminClient.adjustInventory 호출 및 Feign 에러(409/400/기타) 매핑 추가 - user-service: AdminMemberController PATCH가 항상 501을 반환하던 버그 수정 - user-service: Feign 기본 클라이언트가 PATCH를 지원하지 않아 발생하던 500 오류 해결 (feign-hc5 추가) - user-service: 사용자 상세 조회 실패 코드를 MEM-001에서 구경로와 동일한 ITEM-004로 통일 - user-service: UserErrorCode에 item-service와 동일한 ITEM-001/004/005/006 추가 - 관련 서비스/컨트롤러 단위 테스트 추가 및 갱신 Co-Authored-By: Claude Sonnet 5 --- .../AdminInventoryAdjustmentService.java | 13 +- .../service/AdminInventoryDedupeService.java | 14 +- .../AdminInventoryRequestValidator.java | 21 +++ .../service/AdminItemCommandService.java | 20 +++ .../InternalAdminItemController.java | 8 +- .../AdminInventoryDedupeServiceTest.java | 3 + .../AdminInventoryRequestValidatorTest.java | 58 ++++++ user-service/build.gradle | 3 + .../admin/service/AdminMemberService.java | 3 +- .../admin/service/AdminMemberServiceImpl.java | 53 ++++-- .../user/global/exception/UserErrorCode.java | 6 + .../controller/AdminMemberController.java | 15 +- .../admin/service/AdminMemberServiceTest.java | 165 ++++++++++++++++++ .../controller/AdminMemberControllerTest.java | 71 ++++++++ 14 files changed, 405 insertions(+), 48 deletions(-) create mode 100644 item-service/src/main/java/com/comatching/item/domain/admin/service/AdminInventoryRequestValidator.java create mode 100644 item-service/src/main/java/com/comatching/item/domain/admin/service/AdminItemCommandService.java create mode 100644 item-service/src/test/java/com/comatching/item/domain/admin/service/AdminInventoryRequestValidatorTest.java diff --git a/item-service/src/main/java/com/comatching/item/domain/admin/service/AdminInventoryAdjustmentService.java b/item-service/src/main/java/com/comatching/item/domain/admin/service/AdminInventoryAdjustmentService.java index 75b6565..6a04ad7 100644 --- a/item-service/src/main/java/com/comatching/item/domain/admin/service/AdminInventoryAdjustmentService.java +++ b/item-service/src/main/java/com/comatching/item/domain/admin/service/AdminInventoryAdjustmentService.java @@ -5,11 +5,9 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import org.springframework.util.StringUtils; import com.comatching.common.annotation.DistributedLock; import com.comatching.common.exception.BusinessException; -import com.comatching.common.exception.code.GeneralErrorCode; import com.comatching.item.domain.admin.dto.AdminInventoryAction; import com.comatching.item.domain.admin.dto.AdminInventoryUpdateRequest; import com.comatching.item.domain.item.entity.Item; @@ -26,15 +24,13 @@ public class AdminInventoryAdjustmentService { private static final LocalDateTime NO_EXPIRATION = LocalDateTime.of(2099, 12, 31, 23, 59, 59); - private static final int MAX_REASON_LENGTH = 255; private final ItemRepository itemRepository; private final ItemHistoryService historyService; + // request 검증은 이 파이프라인의 첫 단계인 AdminInventoryDedupeService.reserveOrThrow에서 이미 끝난 상태로 호출된다 @DistributedLock(key = "item:inventory", identifier = "#memberId + ':' + #request.itemType()", leaseTime = 10L) public void adjust(Long adminId, Long memberId, AdminInventoryUpdateRequest request) { - validateRequest(request); - if (request.action() == AdminInventoryAction.ADD) { addInventory(memberId, request); saveAdminHistory(adminId, memberId, request, request.quantity()); @@ -85,11 +81,4 @@ private void saveAdminHistory(Long adminId, Long memberId, AdminInventoryUpdateR "관리자 조정(adminId=" + adminId + "): " + request.reason() ); } - - private void validateRequest(AdminInventoryUpdateRequest request) { - if (request == null || request.itemType() == null || request.action() == null || request.quantity() <= 0 - || !StringUtils.hasText(request.reason()) || request.reason().length() > MAX_REASON_LENGTH) { - throw new BusinessException(GeneralErrorCode.INVALID_INPUT_VALUE); - } - } } diff --git a/item-service/src/main/java/com/comatching/item/domain/admin/service/AdminInventoryDedupeService.java b/item-service/src/main/java/com/comatching/item/domain/admin/service/AdminInventoryDedupeService.java index c59f4d7..5316630 100644 --- a/item-service/src/main/java/com/comatching/item/domain/admin/service/AdminInventoryDedupeService.java +++ b/item-service/src/main/java/com/comatching/item/domain/admin/service/AdminInventoryDedupeService.java @@ -5,10 +5,8 @@ import org.redisson.api.RBucket; import org.redisson.api.RedissonClient; import org.springframework.stereotype.Service; -import org.springframework.util.StringUtils; import com.comatching.common.exception.BusinessException; -import com.comatching.common.exception.code.GeneralErrorCode; import com.comatching.item.domain.admin.dto.AdminInventoryUpdateRequest; import com.comatching.item.global.exception.ItemErrorCode; @@ -19,13 +17,14 @@ public class AdminInventoryDedupeService { private static final long DEDUPE_TTL_SECONDS = 3L; - private static final int MAX_REASON_LENGTH = 255; private static final String DEDUPE_KEY_PREFIX = "admin:inventory:dedupe"; private final RedissonClient redissonClient; + private final AdminInventoryRequestValidator requestValidator; public void reserveOrThrow(Long memberId, AdminInventoryUpdateRequest request) { - validateRequest(request); + // 이 파이프라인의 첫 단계라 여기서만 검증한다 - adjust()는 항상 이 메서드 통과 후에만 호출된다 + requestValidator.validate(request); String key = String.join(":", DEDUPE_KEY_PREFIX, @@ -42,11 +41,4 @@ public void reserveOrThrow(Long memberId, AdminInventoryUpdateRequest request) { throw new BusinessException(ItemErrorCode.DUPLICATE_ADMIN_INVENTORY_ADJUSTMENT); } } - - private void validateRequest(AdminInventoryUpdateRequest request) { - if (request == null || request.itemType() == null || request.action() == null || request.quantity() <= 0 - || !StringUtils.hasText(request.reason()) || request.reason().length() > MAX_REASON_LENGTH) { - throw new BusinessException(GeneralErrorCode.INVALID_INPUT_VALUE); - } - } } diff --git a/item-service/src/main/java/com/comatching/item/domain/admin/service/AdminInventoryRequestValidator.java b/item-service/src/main/java/com/comatching/item/domain/admin/service/AdminInventoryRequestValidator.java new file mode 100644 index 0000000..7945472 --- /dev/null +++ b/item-service/src/main/java/com/comatching/item/domain/admin/service/AdminInventoryRequestValidator.java @@ -0,0 +1,21 @@ +package com.comatching.item.domain.admin.service; + +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import com.comatching.common.exception.BusinessException; +import com.comatching.common.exception.code.GeneralErrorCode; +import com.comatching.item.domain.admin.dto.AdminInventoryUpdateRequest; + +@Component +public class AdminInventoryRequestValidator { + + private static final int MAX_REASON_LENGTH = 255; + + public void validate(AdminInventoryUpdateRequest request) { + if (request == null || request.itemType() == null || request.action() == null || request.quantity() <= 0 + || !StringUtils.hasText(request.reason()) || request.reason().length() > MAX_REASON_LENGTH) { + throw new BusinessException(GeneralErrorCode.INVALID_INPUT_VALUE); + } + } +} diff --git a/item-service/src/main/java/com/comatching/item/domain/admin/service/AdminItemCommandService.java b/item-service/src/main/java/com/comatching/item/domain/admin/service/AdminItemCommandService.java new file mode 100644 index 0000000..59b23a7 --- /dev/null +++ b/item-service/src/main/java/com/comatching/item/domain/admin/service/AdminItemCommandService.java @@ -0,0 +1,20 @@ +package com.comatching.item.domain.admin.service; + +import org.springframework.stereotype.Service; + +import com.comatching.item.domain.admin.dto.AdminInventoryUpdateRequest; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class AdminItemCommandService { + + private final AdminInventoryDedupeService adminInventoryDedupeService; + private final AdminInventoryAdjustmentService adminInventoryAdjustmentService; + + public void adjustInventory(Long adminId, Long memberId, AdminInventoryUpdateRequest request) { + adminInventoryDedupeService.reserveOrThrow(memberId, request); + adminInventoryAdjustmentService.adjust(adminId, memberId, request); + } +} diff --git a/item-service/src/main/java/com/comatching/item/infra/controller/InternalAdminItemController.java b/item-service/src/main/java/com/comatching/item/infra/controller/InternalAdminItemController.java index 6d698ac..0dd7303 100644 --- a/item-service/src/main/java/com/comatching/item/infra/controller/InternalAdminItemController.java +++ b/item-service/src/main/java/com/comatching/item/infra/controller/InternalAdminItemController.java @@ -3,8 +3,6 @@ import java.util.List; import java.util.Map; -import com.comatching.item.domain.admin.service.AdminItemQueryService; -import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; @@ -17,6 +15,8 @@ import com.comatching.item.domain.admin.dto.AdminInventoryCounts; import com.comatching.item.domain.admin.dto.AdminInventoryUpdateRequest; +import com.comatching.item.domain.admin.service.AdminItemCommandService; +import com.comatching.item.domain.admin.service.AdminItemQueryService; import lombok.RequiredArgsConstructor; @@ -25,6 +25,7 @@ @RequiredArgsConstructor public class InternalAdminItemController { private final AdminItemQueryService adminItemQueryService; + private final AdminItemCommandService adminItemCommandService; @GetMapping public ResponseEntity> getInventoryCounts( @@ -39,6 +40,7 @@ public ResponseEntity adjustInventory( @RequestHeader("X-Admin-Id") Long adminId, @RequestBody AdminInventoryUpdateRequest request ) { - return ResponseEntity.status(HttpStatus.NOT_IMPLEMENTED).build(); + adminItemCommandService.adjustInventory(adminId, memberId, request); + return ResponseEntity.ok().build(); } } diff --git a/item-service/src/test/java/com/comatching/item/domain/admin/service/AdminInventoryDedupeServiceTest.java b/item-service/src/test/java/com/comatching/item/domain/admin/service/AdminInventoryDedupeServiceTest.java index 793248e..ae170f1 100644 --- a/item-service/src/test/java/com/comatching/item/domain/admin/service/AdminInventoryDedupeServiceTest.java +++ b/item-service/src/test/java/com/comatching/item/domain/admin/service/AdminInventoryDedupeServiceTest.java @@ -37,6 +37,9 @@ class AdminInventoryDedupeServiceTest { @Mock private RBucket bucket; + @Mock + private AdminInventoryRequestValidator requestValidator; + @Test @DisplayName("동일 조정 요청을 3초 TTL 키로 예약한다") void shouldReserveDedupeKeyWithTtl() { diff --git a/item-service/src/test/java/com/comatching/item/domain/admin/service/AdminInventoryRequestValidatorTest.java b/item-service/src/test/java/com/comatching/item/domain/admin/service/AdminInventoryRequestValidatorTest.java new file mode 100644 index 0000000..434ad81 --- /dev/null +++ b/item-service/src/test/java/com/comatching/item/domain/admin/service/AdminInventoryRequestValidatorTest.java @@ -0,0 +1,58 @@ +package com.comatching.item.domain.admin.service; + +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import com.comatching.common.domain.enums.ItemType; +import com.comatching.common.exception.BusinessException; +import com.comatching.item.domain.admin.dto.AdminInventoryAction; +import com.comatching.item.domain.admin.dto.AdminInventoryUpdateRequest; + +@DisplayName("AdminInventoryRequestValidator 테스트") +class AdminInventoryRequestValidatorTest { + + private final AdminInventoryRequestValidator validator = new AdminInventoryRequestValidator(); + + @Test + @DisplayName("유효한 요청은 예외를 던지지 않는다") + void shouldPassWhenRequestIsValid() { + AdminInventoryUpdateRequest request = new AdminInventoryUpdateRequest( + ItemType.MATCHING_TICKET, 1, AdminInventoryAction.ADD, "보상 지급" + ); + + assertThatCode(() -> validator.validate(request)).doesNotThrowAnyException(); + } + + @Test + @DisplayName("수량이 0 이하이면 예외가 발생한다") + void shouldThrowWhenQuantityIsNotPositive() { + AdminInventoryUpdateRequest request = new AdminInventoryUpdateRequest( + ItemType.MATCHING_TICKET, 0, AdminInventoryAction.ADD, "보상 지급" + ); + + assertThatThrownBy(() -> validator.validate(request)).isInstanceOf(BusinessException.class); + } + + @Test + @DisplayName("사유가 공백이면 예외가 발생한다") + void shouldThrowWhenReasonIsBlank() { + AdminInventoryUpdateRequest request = new AdminInventoryUpdateRequest( + ItemType.MATCHING_TICKET, 1, AdminInventoryAction.ADD, " " + ); + + assertThatThrownBy(() -> validator.validate(request)).isInstanceOf(BusinessException.class); + } + + @Test + @DisplayName("사유가 255자를 초과하면 예외가 발생한다") + void shouldThrowWhenReasonExceedsMaxLength() { + AdminInventoryUpdateRequest request = new AdminInventoryUpdateRequest( + ItemType.MATCHING_TICKET, 1, AdminInventoryAction.ADD, "가".repeat(256) + ); + + assertThatThrownBy(() -> validator.validate(request)).isInstanceOf(BusinessException.class); + } +} diff --git a/user-service/build.gradle b/user-service/build.gradle index 0729480..84fd28a 100644 --- a/user-service/build.gradle +++ b/user-service/build.gradle @@ -26,6 +26,9 @@ dependencies { // Kafka implementation 'org.springframework.kafka:spring-kafka' + // Feign - 기본 HttpURLConnection 클라이언트는 PATCH를 지원하지 않아 hc5로 교체 + implementation 'io.github.openfeign:feign-hc5' + // Mail implementation 'org.springframework.boot:spring-boot-starter-mail' implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' diff --git a/user-service/src/main/java/com/comatching/user/domain/admin/service/AdminMemberService.java b/user-service/src/main/java/com/comatching/user/domain/admin/service/AdminMemberService.java index 19dfa80..6a87a52 100644 --- a/user-service/src/main/java/com/comatching/user/domain/admin/service/AdminMemberService.java +++ b/user-service/src/main/java/com/comatching/user/domain/admin/service/AdminMemberService.java @@ -3,6 +3,7 @@ import org.springframework.data.domain.Pageable; import com.comatching.common.dto.response.PagingResponse; +import com.comatching.user.domain.admin.dto.AdminInventoryUpdateRequest; import com.comatching.user.domain.admin.dto.AdminUserDetailResponse; import com.comatching.user.domain.admin.dto.AdminUserSummaryResponse; @@ -12,5 +13,5 @@ public interface AdminMemberService { AdminUserDetailResponse getUserDetail(Long memberId); - void updateUserInventory(Long adminId, Long memberId, Object request); + void updateUserInventory(Long adminId, Long memberId, AdminInventoryUpdateRequest request); } diff --git a/user-service/src/main/java/com/comatching/user/domain/admin/service/AdminMemberServiceImpl.java b/user-service/src/main/java/com/comatching/user/domain/admin/service/AdminMemberServiceImpl.java index d80d327..1a27792 100644 --- a/user-service/src/main/java/com/comatching/user/domain/admin/service/AdminMemberServiceImpl.java +++ b/user-service/src/main/java/com/comatching/user/domain/admin/service/AdminMemberServiceImpl.java @@ -1,12 +1,8 @@ package com.comatching.user.domain.admin.service; -import com.comatching.common.exception.BusinessException; -import com.comatching.common.exception.code.GeneralErrorCode; -import com.comatching.user.domain.admin.dto.AdminInventoryCounts; -import com.comatching.user.domain.admin.dto.AdminUserDetailResponse; -import com.comatching.user.domain.admin.dto.AdminUserSummaryResponse; -import com.comatching.user.global.exception.UserErrorCode; -import com.comatching.user.infra.client.ItemAdminClient; +import java.util.List; +import java.util.Map; + import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; @@ -17,15 +13,22 @@ import com.comatching.common.domain.enums.MemberStatus; import com.comatching.common.dto.member.AdminUserProfileDto; import com.comatching.common.dto.response.PagingResponse; +import com.comatching.common.exception.BusinessException; +import com.comatching.common.exception.code.GeneralErrorCode; +import com.comatching.user.domain.admin.dto.AdminInventoryCounts; +import com.comatching.user.domain.admin.dto.AdminInventoryUpdateRequest; +import com.comatching.user.domain.admin.dto.AdminUserDetailResponse; +import com.comatching.user.domain.admin.dto.AdminUserSummaryResponse; import com.comatching.user.domain.member.entity.Member; import com.comatching.user.domain.member.repository.MemberRepository; +import com.comatching.user.global.exception.UserErrorCode; +import com.comatching.user.infra.client.ItemAdminClient; +import feign.FeignException; +import feign.codec.DecodeException; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import java.util.List; -import java.util.Map; - @Slf4j @Service @RequiredArgsConstructor @@ -80,7 +83,7 @@ public AdminUserDetailResponse getUserDetail(Long memberId) { } Member member = memberRepository.findAdminMemberById(memberId, MemberStatus.ACTIVE, MemberRole.ROLE_USER) - .orElseThrow(() -> new BusinessException(UserErrorCode.USER_NOT_EXIST)); + .orElseThrow(() -> new BusinessException(UserErrorCode.TARGET_USER_NOT_FOUND)); AdminUserProfileDto user = toAdminUserProfileDto(member); Map inventoryCountsByMemberId = itemAdminClient.getInventoryCounts(List.of(memberId)); @@ -91,9 +94,31 @@ public AdminUserDetailResponse getUserDetail(Long memberId) { @Override @Transactional - public void updateUserInventory(Long adminId, Long memberId, Object request) { - // TODO(refactor/admin): getUserDetail 선조회 -> ItemAdminClient.adjustInventory(memberId, adminId, request) 호출 - throw new UnsupportedOperationException("not implemented yet"); + public void updateUserInventory(Long adminId, Long memberId, AdminInventoryUpdateRequest request) { + // 존재하지 않는 대상 사용자의 인벤토리 수정 요청을 막기 위해 선조회 + memberRepository.findAdminMemberById(memberId, MemberStatus.ACTIVE, MemberRole.ROLE_USER) + .orElseThrow(() -> new BusinessException(UserErrorCode.TARGET_USER_NOT_FOUND)); + + try { + itemAdminClient.adjustInventory(memberId, adminId, request); + } catch (DecodeException e) { + // item-service 응답 본문을 역직렬화하지 못한 경우(스펙 불일치 등) - 원인 파악이 불가능하므로 조회 실패로 처리 + log.warn("Admin inventory adjustment decode failed. memberId={}", memberId, e); + throw new BusinessException(UserErrorCode.USER_QUERY_FAILED); + } catch (FeignException e) { + if (e.status() == 409) { + // AdminInventoryDedupeService가 3초 내 동일 요청(memberId+itemType+action+quantity+reason)을 감지 - 재시도/중복클릭으로 재요청하면 안 됨 + throw new BusinessException(UserErrorCode.DUPLICATE_ADMIN_INVENTORY_ADJUSTMENT); + } + if (e.status() == 400) { + // AdminInventoryAdjustmentService가 REMOVE 처리 중 보유 수량 부족을 감지 - 수량을 줄이거나 회원 보유량을 먼저 확인해야 함 + throw new BusinessException(UserErrorCode.NOT_ENOUGH_ITEM); + } + // 그 외(5xx, 타임아웃 등)는 item-service 쪽 원인을 특정할 수 없는 조정 실패 - 로그로 남기고 재시도 여부는 호출측 판단에 맡김 + log.warn("Admin inventory adjustment failed. memberId={}, status={}, body={}", + memberId, e.status(), e.contentUTF8(), e); + throw new BusinessException(UserErrorCode.USER_QUERY_FAILED); + } } private AdminUserProfileDto toAdminUserProfileDto(Member member) { diff --git a/user-service/src/main/java/com/comatching/user/global/exception/UserErrorCode.java b/user-service/src/main/java/com/comatching/user/global/exception/UserErrorCode.java index 57189a6..4357a16 100644 --- a/user-service/src/main/java/com/comatching/user/global/exception/UserErrorCode.java +++ b/user-service/src/main/java/com/comatching/user/global/exception/UserErrorCode.java @@ -32,6 +32,12 @@ public enum UserErrorCode implements ErrorCode { INVALID_NICKNAME("MEM-009", HttpStatus.BAD_REQUEST, "닉네임은 공백일 수 없습니다."), INVALID_PROFILE_TAG("MEM-010", HttpStatus.BAD_REQUEST, "유효하지 않은 장점 태그입니다."), INVALID_REAL_NAME("MEM-011", HttpStatus.BAD_REQUEST, "실명은 공백일 수 없습니다."), + + // 관리자 인벤토리 조정 에러 - item-service AdminUserController와 응답 스펙(코드/메시지/상태)을 동일하게 유지해야 하므로 ITEM-0xx 코드를 그대로 사용 + NOT_ENOUGH_ITEM("ITEM-001", HttpStatus.BAD_REQUEST, "아이템이 부족합니다."), + TARGET_USER_NOT_FOUND("ITEM-004", HttpStatus.BAD_REQUEST, "대상 사용자를 찾을 수 없습니다."), + USER_QUERY_FAILED("ITEM-005", HttpStatus.INTERNAL_SERVER_ERROR, "사용자 조회 중 오류가 발생했습니다."), + DUPLICATE_ADMIN_INVENTORY_ADJUSTMENT("ITEM-006", HttpStatus.CONFLICT, "동일한 관리자 아이템 조정 요청이 처리 중입니다."), ; private final String code; diff --git a/user-service/src/main/java/com/comatching/user/infra/controller/AdminMemberController.java b/user-service/src/main/java/com/comatching/user/infra/controller/AdminMemberController.java index 04d25b4..b332890 100644 --- a/user-service/src/main/java/com/comatching/user/infra/controller/AdminMemberController.java +++ b/user-service/src/main/java/com/comatching/user/infra/controller/AdminMemberController.java @@ -1,11 +1,8 @@ package com.comatching.user.infra.controller; -import com.comatching.common.dto.response.PagingResponse; -import com.comatching.user.domain.admin.dto.AdminUserDetailResponse; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; import org.springframework.data.web.PageableDefault; -import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PatchMapping; @@ -20,15 +17,18 @@ import com.comatching.common.domain.enums.MemberRole; import com.comatching.common.dto.member.MemberInfo; import com.comatching.common.dto.response.ApiResponse; +import com.comatching.common.dto.response.PagingResponse; +import com.comatching.user.domain.admin.dto.AdminInventoryUpdateRequest; +import com.comatching.user.domain.admin.dto.AdminUserDetailResponse; import com.comatching.user.domain.admin.dto.AdminUserSummaryResponse; import com.comatching.user.domain.admin.service.AdminMemberService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; - -@Tag(name = "Admin User API", description = "관리자 전용 사용자 조회 및 인벤토리 관리 (현재 미구현)") +@Tag(name = "Admin User API", description = "관리자 전용 사용자 조회 및 인벤토리 관리") @RestController @RequestMapping("/api/admin/users") @RequiredArgsConstructor @@ -63,8 +63,9 @@ public ResponseEntity> getUserDetail( public ResponseEntity> updateUserInventory( @CurrentMember MemberInfo memberInfo, @PathVariable Long memberId, - @RequestBody Object request + @Valid @RequestBody AdminInventoryUpdateRequest request ) { - return ResponseEntity.status(HttpStatus.NOT_IMPLEMENTED).build(); + adminMemberService.updateUserInventory(memberInfo.memberId(), memberId, request); + return ResponseEntity.ok(ApiResponse.ok()); } } diff --git a/user-service/src/test/java/com/comatching/user/domain/admin/service/AdminMemberServiceTest.java b/user-service/src/test/java/com/comatching/user/domain/admin/service/AdminMemberServiceTest.java index 407993a..beeb398 100644 --- a/user-service/src/test/java/com/comatching/user/domain/admin/service/AdminMemberServiceTest.java +++ b/user-service/src/test/java/com/comatching/user/domain/admin/service/AdminMemberServiceTest.java @@ -1,12 +1,16 @@ package com.comatching.user.domain.admin.service; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.anyList; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; +import static org.mockito.BDDMockito.willThrow; import java.util.List; import java.util.Map; +import java.util.Optional; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -20,16 +24,26 @@ import org.springframework.test.util.ReflectionTestUtils; import com.comatching.common.domain.enums.Gender; +import com.comatching.common.domain.enums.ItemType; import com.comatching.common.domain.enums.MemberRole; import com.comatching.common.domain.enums.MemberStatus; import com.comatching.common.dto.response.PagingResponse; +import com.comatching.common.exception.BusinessException; +import com.comatching.user.domain.admin.dto.AdminInventoryAction; import com.comatching.user.domain.admin.dto.AdminInventoryCounts; +import com.comatching.user.domain.admin.dto.AdminInventoryUpdateRequest; import com.comatching.user.domain.admin.dto.AdminUserSummaryResponse; import com.comatching.user.domain.member.entity.Member; import com.comatching.user.domain.member.entity.Profile; import com.comatching.user.domain.member.repository.MemberRepository; +import com.comatching.user.global.exception.UserErrorCode; import com.comatching.user.infra.client.ItemAdminClient; +import feign.FeignException; +import feign.Request; +import feign.Response; +import feign.codec.DecodeException; + @ExtendWith(MockitoExtension.class) @DisplayName("AdminMemberServiceImpl 테스트") class AdminMemberServiceTest { @@ -129,6 +143,157 @@ void shouldNormalizeBlankKeywordToNull() { .searchMembersForAdmin(MemberStatus.ACTIVE, MemberRole.ROLE_USER, null, pageable); } + @Test + @DisplayName("대상 사용자가 존재하면 인벤토리 조정을 item-service에 위임한다") + void shouldAdjustInventoryWhenMemberExists() { + // given + Long adminId = 900L; + Long memberId = 15L; + Member member = createMemberWithProfile(memberId, "u@u.com", "유저", "u", Gender.MALE, "https://img"); + AdminInventoryUpdateRequest request = new AdminInventoryUpdateRequest( + ItemType.MATCHING_TICKET, 3, AdminInventoryAction.ADD, "보상 지급" + ); + + given(memberRepository.findAdminMemberById(memberId, MemberStatus.ACTIVE, MemberRole.ROLE_USER)) + .willReturn(Optional.of(member)); + + // when + assertThatCode(() -> adminMemberService.updateUserInventory(adminId, memberId, request)) + .doesNotThrowAnyException(); + + // then + then(itemAdminClient).should().adjustInventory(memberId, adminId, request); + } + + @Test + @DisplayName("대상 사용자가 없으면 item-service를 호출하지 않고 예외를 던진다") + void shouldThrowWhenTargetMemberNotFound() { + // given + Long adminId = 900L; + Long memberId = 99L; + AdminInventoryUpdateRequest request = new AdminInventoryUpdateRequest( + ItemType.MATCHING_TICKET, 3, AdminInventoryAction.ADD, "보상 지급" + ); + + given(memberRepository.findAdminMemberById(memberId, MemberStatus.ACTIVE, MemberRole.ROLE_USER)) + .willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> adminMemberService.updateUserInventory(adminId, memberId, request)) + .isInstanceOf(BusinessException.class) + .extracting("errorCode") + .isEqualTo(UserErrorCode.TARGET_USER_NOT_FOUND); + then(itemAdminClient).shouldHaveNoInteractions(); + } + + @Test + @DisplayName("item-service가 409를 반환하면 중복 조정 예외로 변환한다") + void shouldThrowDuplicateWhenItemServiceReturns409() { + // given + Long adminId = 900L; + Long memberId = 15L; + Member member = createMemberWithProfile(memberId, "u@u.com", "유저", "u", Gender.MALE, "https://img"); + AdminInventoryUpdateRequest request = new AdminInventoryUpdateRequest( + ItemType.MATCHING_TICKET, 3, AdminInventoryAction.ADD, "보상 지급" + ); + + given(memberRepository.findAdminMemberById(memberId, MemberStatus.ACTIVE, MemberRole.ROLE_USER)) + .willReturn(Optional.of(member)); + willThrow(feignException(409)).given(itemAdminClient).adjustInventory(memberId, adminId, request); + + // when & then + assertThatThrownBy(() -> adminMemberService.updateUserInventory(adminId, memberId, request)) + .isInstanceOf(BusinessException.class) + .extracting("errorCode") + .isEqualTo(UserErrorCode.DUPLICATE_ADMIN_INVENTORY_ADJUSTMENT); + } + + @Test + @DisplayName("item-service가 400을 반환하면 아이템 부족 예외로 변환한다") + void shouldThrowNotEnoughItemWhenItemServiceReturns400() { + // given + Long adminId = 900L; + Long memberId = 15L; + Member member = createMemberWithProfile(memberId, "u@u.com", "유저", "u", Gender.MALE, "https://img"); + AdminInventoryUpdateRequest request = new AdminInventoryUpdateRequest( + ItemType.MATCHING_TICKET, 3, AdminInventoryAction.REMOVE, "오지급 회수" + ); + + given(memberRepository.findAdminMemberById(memberId, MemberStatus.ACTIVE, MemberRole.ROLE_USER)) + .willReturn(Optional.of(member)); + willThrow(feignException(400)).given(itemAdminClient).adjustInventory(memberId, adminId, request); + + // when & then + assertThatThrownBy(() -> adminMemberService.updateUserInventory(adminId, memberId, request)) + .isInstanceOf(BusinessException.class) + .extracting("errorCode") + .isEqualTo(UserErrorCode.NOT_ENOUGH_ITEM); + } + + @Test + @DisplayName("item-service가 그 외 상태코드를 반환하면 조회 실패 예외로 변환한다") + void shouldThrowUserQueryFailedForOtherStatus() { + // given + Long adminId = 900L; + Long memberId = 15L; + Member member = createMemberWithProfile(memberId, "u@u.com", "유저", "u", Gender.MALE, "https://img"); + AdminInventoryUpdateRequest request = new AdminInventoryUpdateRequest( + ItemType.MATCHING_TICKET, 3, AdminInventoryAction.ADD, "보상 지급" + ); + + given(memberRepository.findAdminMemberById(memberId, MemberStatus.ACTIVE, MemberRole.ROLE_USER)) + .willReturn(Optional.of(member)); + willThrow(feignException(500)).given(itemAdminClient).adjustInventory(memberId, adminId, request); + + // when & then + assertThatThrownBy(() -> adminMemberService.updateUserInventory(adminId, memberId, request)) + .isInstanceOf(BusinessException.class) + .extracting("errorCode") + .isEqualTo(UserErrorCode.USER_QUERY_FAILED); + } + + @Test + @DisplayName("item-service 응답 디코딩에 실패하면 조회 실패 예외로 변환한다") + void shouldThrowUserQueryFailedWhenDecodeFails() { + // given + Long adminId = 900L; + Long memberId = 15L; + Member member = createMemberWithProfile(memberId, "u@u.com", "유저", "u", Gender.MALE, "https://img"); + AdminInventoryUpdateRequest request = new AdminInventoryUpdateRequest( + ItemType.MATCHING_TICKET, 3, AdminInventoryAction.ADD, "보상 지급" + ); + + given(memberRepository.findAdminMemberById(memberId, MemberStatus.ACTIVE, MemberRole.ROLE_USER)) + .willReturn(Optional.of(member)); + willThrow(decodeException()).given(itemAdminClient).adjustInventory(memberId, adminId, request); + + // when & then + assertThatThrownBy(() -> adminMemberService.updateUserInventory(adminId, memberId, request)) + .isInstanceOf(BusinessException.class) + .extracting("errorCode") + .isEqualTo(UserErrorCode.USER_QUERY_FAILED); + } + + private static FeignException feignException(int status) { + Response response = Response.builder() + .status(status) + .reason("error") + .request(testRequest()) + .headers(Map.of()) + .build(); + return FeignException.errorStatus("ItemAdminClient#adjustInventory", response); + } + + private static DecodeException decodeException() { + return new DecodeException(200, "decode failed", testRequest()); + } + + private static Request testRequest() { + return Request.create( + Request.HttpMethod.PATCH, "/api/internal/admin/items/1", Map.of(), Request.Body.empty(), null + ); + } + private static Member createMemberWithProfile( Long id, String email, diff --git a/user-service/src/test/java/com/comatching/user/infra/controller/AdminMemberControllerTest.java b/user-service/src/test/java/com/comatching/user/infra/controller/AdminMemberControllerTest.java index a022471..a476a75 100644 --- a/user-service/src/test/java/com/comatching/user/infra/controller/AdminMemberControllerTest.java +++ b/user-service/src/test/java/com/comatching/user/infra/controller/AdminMemberControllerTest.java @@ -4,7 +4,9 @@ import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; +import static org.mockito.BDDMockito.willThrow; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -19,15 +21,22 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.data.domain.Pageable; import org.springframework.data.web.PageableHandlerMethodArgumentResolver; +import org.springframework.http.MediaType; import org.springframework.test.web.servlet.MockMvc; import org.springframework.test.web.servlet.setup.MockMvcBuilders; import com.comatching.common.domain.enums.Gender; +import com.comatching.common.domain.enums.ItemType; import com.comatching.common.dto.response.PagingResponse; +import com.comatching.common.exception.BusinessException; import com.comatching.common.exception.handler.GlobalExceptionHandler; import com.comatching.common.resolver.MemberInfoArgumentResolver; +import com.comatching.user.domain.admin.dto.AdminInventoryAction; +import com.comatching.user.domain.admin.dto.AdminInventoryUpdateRequest; +import com.comatching.user.domain.admin.dto.AdminUserDetailResponse; import com.comatching.user.domain.admin.dto.AdminUserSummaryResponse; import com.comatching.user.domain.admin.service.AdminMemberService; +import com.comatching.user.global.exception.UserErrorCode; import com.fasterxml.jackson.databind.ObjectMapper; @ExtendWith(MockitoExtension.class) @@ -44,6 +53,8 @@ class AdminMemberControllerTest { private static final Long ADMIN_ID = 999L; private static final String SUCCESS_CODE = "GEN-000"; + private final ObjectMapper objectMapper = new ObjectMapper(); + @BeforeEach void setUp() { mockMvc = MockMvcBuilders.standaloneSetup(adminMemberController) @@ -109,4 +120,64 @@ void getUsers_missingMemberIdHeader() throws Exception { then(adminMemberService).shouldHaveNoInteractions(); } + + @Test + @DisplayName("GET /api/admin/users/{memberId} - 사용자 상세와 인벤토리를 조회한다") + void getUserDetail_success() throws Exception { + // given + AdminUserDetailResponse detail = new AdminUserDetailResponse( + 1L, "user@test.com", "홍길동", "닉네임", Gender.FEMALE, "https://img", 3L, 1L + ); + given(adminMemberService.getUserDetail(1L)).willReturn(detail); + + // when & then + mockMvc.perform(get("/api/admin/users/{memberId}", 1L) + .header("X-Member-Id", ADMIN_ID) + .header("X-Member-Role", "ROLE_ADMIN")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(SUCCESS_CODE)) + .andExpect(jsonPath("$.data.id").value(1)) + .andExpect(jsonPath("$.data.matchingTicketCount").value(3)) + .andExpect(jsonPath("$.data.optionTicketCount").value(1)); + } + + @Test + @DisplayName("PATCH /api/admin/users/{memberId}/items - 인벤토리 조정에 성공하면 200을 반환한다") + void updateUserInventory_success() throws Exception { + // given + AdminInventoryUpdateRequest request = new AdminInventoryUpdateRequest( + ItemType.MATCHING_TICKET, 3, AdminInventoryAction.ADD, "보상 지급" + ); + + // when & then + mockMvc.perform(patch("/api/admin/users/{memberId}/items", 1L) + .header("X-Member-Id", ADMIN_ID) + .header("X-Member-Role", "ROLE_ADMIN") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value(SUCCESS_CODE)); + + then(adminMemberService).should().updateUserInventory(ADMIN_ID, 1L, request); + } + + @Test + @DisplayName("PATCH /api/admin/users/{memberId}/items - 대상 사용자가 없으면 ITEM-004를 반환한다") + void updateUserInventory_targetUserNotFound() throws Exception { + // given + AdminInventoryUpdateRequest request = new AdminInventoryUpdateRequest( + ItemType.MATCHING_TICKET, 3, AdminInventoryAction.ADD, "보상 지급" + ); + willThrow(new BusinessException(UserErrorCode.TARGET_USER_NOT_FOUND)) + .given(adminMemberService).updateUserInventory(ADMIN_ID, 1L, request); + + // when & then + mockMvc.perform(patch("/api/admin/users/{memberId}/items", 1L) + .header("X-Member-Id", ADMIN_ID) + .header("X-Member-Role", "ROLE_ADMIN") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("ITEM-004")); + } } From af9227b3333e8a3c208e0883f0a99385bac72a28 Mon Sep 17 00:00:00 2001 From: jinwook219 Date: Wed, 19 Aug 2026 15:08:39 +0900 Subject: [PATCH 05/22] =?UTF-8?q?feat(user):=20=EA=B3=B5=EC=A7=80=EC=82=AC?= =?UTF-8?q?=ED=95=AD=20=EA=B4=80=EB=A6=AC=20API=EB=A5=BC=20item-service?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=9D=B4=EA=B4=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - item-service의 notice 도메인(entity/repository/service/dto/controller)을 user-service domain/admin/notice로 옮기고 AdminNoticeService(Impl)로 리네임 - /api/admin/notices(목록/등록/수정/삭제), /api/notices/active 엔드포인트를 AdminNoticeController로 추가 - 기존 admin 패키지를 domain/admin/user로 재정리해 notice와 계층 구조를 통일 - item-service NoticeServiceImplTest를 AdminNoticeServiceImplTest로 이관(9개 케이스) Co-Authored-By: Claude Sonnet 5 --- .../notice/dto/ActiveNoticeResponse.java | 18 ++ .../admin/notice/dto/AdminNoticeResponse.java | 30 +++ .../admin/notice/dto/NoticeCreateRequest.java | 23 ++ .../admin/notice/dto/NoticeUpdateRequest.java | 23 ++ .../domain/admin/notice/entity/Notice.java | 48 ++++ .../notice/repository/NoticeRepository.java | 18 ++ .../notice/service/AdminNoticeService.java | 23 ++ .../service/AdminNoticeServiceImpl.java | 87 +++++++ .../{ => user}/dto/AdminInventoryAction.java | 2 +- .../{ => user}/dto/AdminInventoryCounts.java | 2 +- .../dto/AdminInventoryUpdateRequest.java | 3 +- .../dto/AdminUserDetailResponse.java | 2 +- .../dto/AdminUserSummaryResponse.java | 3 +- .../service/AdminMemberService.java | 8 +- .../service/AdminMemberServiceImpl.java | 10 +- .../user/infra/client/ItemAdminClient.java | 4 +- .../controller/AdminMemberController.java | 8 +- .../controller/AdminNoticeController.java | 80 ++++++ .../service/AdminNoticeServiceImplTest.java | 241 ++++++++++++++++++ .../admin/service/AdminMemberServiceTest.java | 9 +- .../controller/AdminMemberControllerTest.java | 10 +- 21 files changed, 621 insertions(+), 31 deletions(-) create mode 100644 user-service/src/main/java/com/comatching/user/domain/admin/notice/dto/ActiveNoticeResponse.java create mode 100644 user-service/src/main/java/com/comatching/user/domain/admin/notice/dto/AdminNoticeResponse.java create mode 100644 user-service/src/main/java/com/comatching/user/domain/admin/notice/dto/NoticeCreateRequest.java create mode 100644 user-service/src/main/java/com/comatching/user/domain/admin/notice/dto/NoticeUpdateRequest.java create mode 100644 user-service/src/main/java/com/comatching/user/domain/admin/notice/entity/Notice.java create mode 100644 user-service/src/main/java/com/comatching/user/domain/admin/notice/repository/NoticeRepository.java create mode 100644 user-service/src/main/java/com/comatching/user/domain/admin/notice/service/AdminNoticeService.java create mode 100644 user-service/src/main/java/com/comatching/user/domain/admin/notice/service/AdminNoticeServiceImpl.java rename user-service/src/main/java/com/comatching/user/domain/admin/{ => user}/dto/AdminInventoryAction.java (50%) rename user-service/src/main/java/com/comatching/user/domain/admin/{ => user}/dto/AdminInventoryCounts.java (78%) rename user-service/src/main/java/com/comatching/user/domain/admin/{ => user}/dto/AdminInventoryUpdateRequest.java (87%) rename user-service/src/main/java/com/comatching/user/domain/admin/{ => user}/dto/AdminUserDetailResponse.java (92%) rename user-service/src/main/java/com/comatching/user/domain/admin/{ => user}/dto/AdminUserSummaryResponse.java (85%) rename user-service/src/main/java/com/comatching/user/domain/admin/{ => user}/service/AdminMemberService.java (57%) rename user-service/src/main/java/com/comatching/user/domain/admin/{ => user}/service/AdminMemberServiceImpl.java (93%) create mode 100644 user-service/src/main/java/com/comatching/user/infra/controller/AdminNoticeController.java create mode 100644 user-service/src/test/java/com/comatching/user/domain/admin/notice/service/AdminNoticeServiceImplTest.java diff --git a/user-service/src/main/java/com/comatching/user/domain/admin/notice/dto/ActiveNoticeResponse.java b/user-service/src/main/java/com/comatching/user/domain/admin/notice/dto/ActiveNoticeResponse.java new file mode 100644 index 0000000..ed872e2 --- /dev/null +++ b/user-service/src/main/java/com/comatching/user/domain/admin/notice/dto/ActiveNoticeResponse.java @@ -0,0 +1,18 @@ +package com.comatching.user.domain.admin.notice.dto; + + +import com.comatching.user.domain.admin.notice.entity.Notice; + +public record ActiveNoticeResponse( + Long noticeId, + String title, + String content +) { + public static ActiveNoticeResponse from(Notice notice) { + return new ActiveNoticeResponse( + notice.getId(), + notice.getTitle(), + notice.getContent() + ); + } +} diff --git a/user-service/src/main/java/com/comatching/user/domain/admin/notice/dto/AdminNoticeResponse.java b/user-service/src/main/java/com/comatching/user/domain/admin/notice/dto/AdminNoticeResponse.java new file mode 100644 index 0000000..6fbce5f --- /dev/null +++ b/user-service/src/main/java/com/comatching/user/domain/admin/notice/dto/AdminNoticeResponse.java @@ -0,0 +1,30 @@ +package com.comatching.user.domain.admin.notice.dto; + + +import com.comatching.user.domain.admin.notice.entity.Notice; + +import java.time.LocalDateTime; + +public record AdminNoticeResponse( + Long noticeId, + String title, + String content, + LocalDateTime startTime, + LocalDateTime endTime, + boolean active +) { + public static AdminNoticeResponse from(Notice notice, LocalDateTime currentTime) { + return new AdminNoticeResponse( + notice.getId(), + notice.getTitle(), + notice.getContent(), + notice.getStartTime(), + notice.getEndTime(), + isActive(notice, currentTime) + ); + } + + private static boolean isActive(Notice notice, LocalDateTime currentTime) { + return !notice.getStartTime().isAfter(currentTime) && !notice.getEndTime().isBefore(currentTime); + } +} diff --git a/user-service/src/main/java/com/comatching/user/domain/admin/notice/dto/NoticeCreateRequest.java b/user-service/src/main/java/com/comatching/user/domain/admin/notice/dto/NoticeCreateRequest.java new file mode 100644 index 0000000..2382af0 --- /dev/null +++ b/user-service/src/main/java/com/comatching/user/domain/admin/notice/dto/NoticeCreateRequest.java @@ -0,0 +1,23 @@ +package com.comatching.user.domain.admin.notice.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import java.time.LocalDateTime; + +public record NoticeCreateRequest( + @NotBlank(message = "제목은 필수입니다.") + @Size(max = 200, message = "제목은 200자 이하로 입력해주세요.") + String title, + + @NotBlank(message = "내용은 필수입니다.") + String content, + + @NotNull(message = "시작시간은 필수입니다.") + LocalDateTime startTime, + + @NotNull(message = "종료시간은 필수입니다.") + LocalDateTime endTime +) { +} diff --git a/user-service/src/main/java/com/comatching/user/domain/admin/notice/dto/NoticeUpdateRequest.java b/user-service/src/main/java/com/comatching/user/domain/admin/notice/dto/NoticeUpdateRequest.java new file mode 100644 index 0000000..f8f123d --- /dev/null +++ b/user-service/src/main/java/com/comatching/user/domain/admin/notice/dto/NoticeUpdateRequest.java @@ -0,0 +1,23 @@ +package com.comatching.user.domain.admin.notice.dto; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +import java.time.LocalDateTime; + +public record NoticeUpdateRequest( + @NotBlank(message = "제목은 필수입니다.") + @Size(max = 200, message = "제목은 200자 이하로 입력해주세요.") + String title, + + @NotBlank(message = "내용은 필수입니다.") + String content, + + @NotNull(message = "시작시간은 필수입니다.") + LocalDateTime startTime, + + @NotNull(message = "종료시간은 필수입니다.") + LocalDateTime endTime +) { +} diff --git a/user-service/src/main/java/com/comatching/user/domain/admin/notice/entity/Notice.java b/user-service/src/main/java/com/comatching/user/domain/admin/notice/entity/Notice.java new file mode 100644 index 0000000..f62d31e --- /dev/null +++ b/user-service/src/main/java/com/comatching/user/domain/admin/notice/entity/Notice.java @@ -0,0 +1,48 @@ +package com.comatching.user.domain.admin.notice.entity; + +import jakarta.persistence.*; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "notice") +public class Notice { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false, length = 200) + private String title; + + @Lob + @Column(nullable = false, columnDefinition = "TEXT") + private String content; + + @Column(nullable = false) + private LocalDateTime startTime; + + @Column(nullable = false) + private LocalDateTime endTime; + + @Builder + public Notice(String title, String content, LocalDateTime startTime, LocalDateTime endTime) { + this.title = title; + this.content = content; + this.startTime = startTime; + this.endTime = endTime; + } + + public void update(String title, String content, LocalDateTime startTime, LocalDateTime endTime) { + this.title = title; + this.content = content; + this.startTime = startTime; + this.endTime = endTime; + } +} diff --git a/user-service/src/main/java/com/comatching/user/domain/admin/notice/repository/NoticeRepository.java b/user-service/src/main/java/com/comatching/user/domain/admin/notice/repository/NoticeRepository.java new file mode 100644 index 0000000..1472e7d --- /dev/null +++ b/user-service/src/main/java/com/comatching/user/domain/admin/notice/repository/NoticeRepository.java @@ -0,0 +1,18 @@ +package com.comatching.user.domain.admin.notice.repository; + + +import com.comatching.user.domain.admin.notice.entity.Notice; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.time.LocalDateTime; +import java.util.List; + +public interface NoticeRepository extends JpaRepository { + + List findAllByOrderByStartTimeDescIdDesc(); + + List findAllByStartTimeLessThanEqualAndEndTimeGreaterThanEqualOrderByStartTimeDescIdDesc( + LocalDateTime currentTime, + LocalDateTime currentTime2 + ); +} diff --git a/user-service/src/main/java/com/comatching/user/domain/admin/notice/service/AdminNoticeService.java b/user-service/src/main/java/com/comatching/user/domain/admin/notice/service/AdminNoticeService.java new file mode 100644 index 0000000..3a57b89 --- /dev/null +++ b/user-service/src/main/java/com/comatching/user/domain/admin/notice/service/AdminNoticeService.java @@ -0,0 +1,23 @@ +package com.comatching.user.domain.admin.notice.service; + + + +import com.comatching.user.domain.admin.notice.dto.ActiveNoticeResponse; +import com.comatching.user.domain.admin.notice.dto.AdminNoticeResponse; +import com.comatching.user.domain.admin.notice.dto.NoticeCreateRequest; +import com.comatching.user.domain.admin.notice.dto.NoticeUpdateRequest; + +import java.util.List; + +public interface AdminNoticeService { + + void createNotice(NoticeCreateRequest request); + + void updateNotice(Long noticeId, NoticeUpdateRequest request); + + void deleteNotice(Long noticeId); + + List getActiveNotices(); + + List getAdminNotices(); +} diff --git a/user-service/src/main/java/com/comatching/user/domain/admin/notice/service/AdminNoticeServiceImpl.java b/user-service/src/main/java/com/comatching/user/domain/admin/notice/service/AdminNoticeServiceImpl.java new file mode 100644 index 0000000..7258b0b --- /dev/null +++ b/user-service/src/main/java/com/comatching/user/domain/admin/notice/service/AdminNoticeServiceImpl.java @@ -0,0 +1,87 @@ +package com.comatching.user.domain.admin.notice.service; + +import com.comatching.common.exception.BusinessException; +import com.comatching.common.exception.code.GeneralErrorCode; +import com.comatching.user.domain.admin.notice.dto.ActiveNoticeResponse; +import com.comatching.user.domain.admin.notice.dto.AdminNoticeResponse; +import com.comatching.user.domain.admin.notice.dto.NoticeCreateRequest; +import com.comatching.user.domain.admin.notice.dto.NoticeUpdateRequest; +import com.comatching.user.domain.admin.notice.entity.Notice; +import com.comatching.user.domain.admin.notice.repository.NoticeRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; + +@Service +@RequiredArgsConstructor +@Transactional +public class AdminNoticeServiceImpl implements AdminNoticeService { + + private final NoticeRepository noticeRepository; + + @Override + public void createNotice(NoticeCreateRequest request) { + validatePeriod(request.startTime(), request.endTime()); + + Notice notice = Notice.builder() + .title(request.title()) + .content(request.content()) + .startTime(request.startTime()) + .endTime(request.endTime()) + .build(); + + noticeRepository.save(notice); + } + + @Override + public void updateNotice(Long noticeId, NoticeUpdateRequest request) { + validatePeriod(request.startTime(), request.endTime()); + + Notice notice = findNoticeOrThrow(noticeId); + notice.update(request.title(), request.content(), request.startTime(), request.endTime()); + } + + @Override + public void deleteNotice(Long noticeId) { + Notice notice = findNoticeOrThrow(noticeId); + noticeRepository.delete(notice); + } + + @Override + @Transactional(readOnly = true) + public List getActiveNotices() { + LocalDateTime currentTime = LocalDateTime.now(); + return noticeRepository + .findAllByStartTimeLessThanEqualAndEndTimeGreaterThanEqualOrderByStartTimeDescIdDesc(currentTime, currentTime) + .stream() + .map(ActiveNoticeResponse::from) + .toList(); + } + + @Override + @Transactional(readOnly = true) + public List getAdminNotices() { + LocalDateTime currentTime = LocalDateTime.now(); + return noticeRepository.findAllByOrderByStartTimeDescIdDesc() + .stream() + .map(notice -> AdminNoticeResponse.from(notice, currentTime)) + .toList(); + } + + private Notice findNoticeOrThrow(Long noticeId) { + return noticeRepository.findById(noticeId) + .orElseThrow(() -> new BusinessException(GeneralErrorCode.NOT_FOUND, "공지사항을 찾을 수 없습니다.")); + } + + private void validatePeriod(LocalDateTime startTime, LocalDateTime endTime) { + if (startTime == null || endTime == null) { + throw new BusinessException(GeneralErrorCode.INVALID_INPUT_VALUE, "시작시간과 종료시간은 필수입니다."); + } + if (!startTime.isBefore(endTime)) { + throw new BusinessException(GeneralErrorCode.INVALID_INPUT_VALUE, "시작시간은 종료시간보다 이전이어야 합니다."); + } + } +} diff --git a/user-service/src/main/java/com/comatching/user/domain/admin/dto/AdminInventoryAction.java b/user-service/src/main/java/com/comatching/user/domain/admin/user/dto/AdminInventoryAction.java similarity index 50% rename from user-service/src/main/java/com/comatching/user/domain/admin/dto/AdminInventoryAction.java rename to user-service/src/main/java/com/comatching/user/domain/admin/user/dto/AdminInventoryAction.java index 32b846b..f0fbc2f 100644 --- a/user-service/src/main/java/com/comatching/user/domain/admin/dto/AdminInventoryAction.java +++ b/user-service/src/main/java/com/comatching/user/domain/admin/user/dto/AdminInventoryAction.java @@ -1,4 +1,4 @@ -package com.comatching.user.domain.admin.dto; +package com.comatching.user.domain.admin.user.dto; public enum AdminInventoryAction { ADD, diff --git a/user-service/src/main/java/com/comatching/user/domain/admin/dto/AdminInventoryCounts.java b/user-service/src/main/java/com/comatching/user/domain/admin/user/dto/AdminInventoryCounts.java similarity index 78% rename from user-service/src/main/java/com/comatching/user/domain/admin/dto/AdminInventoryCounts.java rename to user-service/src/main/java/com/comatching/user/domain/admin/user/dto/AdminInventoryCounts.java index 935bc9a..7f74d28 100644 --- a/user-service/src/main/java/com/comatching/user/domain/admin/dto/AdminInventoryCounts.java +++ b/user-service/src/main/java/com/comatching/user/domain/admin/user/dto/AdminInventoryCounts.java @@ -1,4 +1,4 @@ -package com.comatching.user.domain.admin.dto; +package com.comatching.user.domain.admin.user.dto; public record AdminInventoryCounts( long matchingTicketCount, diff --git a/user-service/src/main/java/com/comatching/user/domain/admin/dto/AdminInventoryUpdateRequest.java b/user-service/src/main/java/com/comatching/user/domain/admin/user/dto/AdminInventoryUpdateRequest.java similarity index 87% rename from user-service/src/main/java/com/comatching/user/domain/admin/dto/AdminInventoryUpdateRequest.java rename to user-service/src/main/java/com/comatching/user/domain/admin/user/dto/AdminInventoryUpdateRequest.java index 4f46c65..d12d6ef 100644 --- a/user-service/src/main/java/com/comatching/user/domain/admin/dto/AdminInventoryUpdateRequest.java +++ b/user-service/src/main/java/com/comatching/user/domain/admin/user/dto/AdminInventoryUpdateRequest.java @@ -1,7 +1,6 @@ -package com.comatching.user.domain.admin.dto; +package com.comatching.user.domain.admin.user.dto; import com.comatching.common.domain.enums.ItemType; -import com.comatching.user.domain.admin.dto.AdminInventoryAction; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Positive; diff --git a/user-service/src/main/java/com/comatching/user/domain/admin/dto/AdminUserDetailResponse.java b/user-service/src/main/java/com/comatching/user/domain/admin/user/dto/AdminUserDetailResponse.java similarity index 92% rename from user-service/src/main/java/com/comatching/user/domain/admin/dto/AdminUserDetailResponse.java rename to user-service/src/main/java/com/comatching/user/domain/admin/user/dto/AdminUserDetailResponse.java index 00897b3..160d266 100644 --- a/user-service/src/main/java/com/comatching/user/domain/admin/dto/AdminUserDetailResponse.java +++ b/user-service/src/main/java/com/comatching/user/domain/admin/user/dto/AdminUserDetailResponse.java @@ -1,4 +1,4 @@ -package com.comatching.user.domain.admin.dto; +package com.comatching.user.domain.admin.user.dto; import com.comatching.common.domain.enums.Gender; import com.comatching.common.dto.member.AdminUserProfileDto; diff --git a/user-service/src/main/java/com/comatching/user/domain/admin/dto/AdminUserSummaryResponse.java b/user-service/src/main/java/com/comatching/user/domain/admin/user/dto/AdminUserSummaryResponse.java similarity index 85% rename from user-service/src/main/java/com/comatching/user/domain/admin/dto/AdminUserSummaryResponse.java rename to user-service/src/main/java/com/comatching/user/domain/admin/user/dto/AdminUserSummaryResponse.java index 243a694..d638414 100644 --- a/user-service/src/main/java/com/comatching/user/domain/admin/dto/AdminUserSummaryResponse.java +++ b/user-service/src/main/java/com/comatching/user/domain/admin/user/dto/AdminUserSummaryResponse.java @@ -1,8 +1,7 @@ -package com.comatching.user.domain.admin.dto; +package com.comatching.user.domain.admin.user.dto; import com.comatching.common.domain.enums.Gender; import com.comatching.common.dto.member.AdminUserProfileDto; -import com.comatching.user.domain.admin.dto.AdminInventoryCounts; public record AdminUserSummaryResponse( Long id, diff --git a/user-service/src/main/java/com/comatching/user/domain/admin/service/AdminMemberService.java b/user-service/src/main/java/com/comatching/user/domain/admin/user/service/AdminMemberService.java similarity index 57% rename from user-service/src/main/java/com/comatching/user/domain/admin/service/AdminMemberService.java rename to user-service/src/main/java/com/comatching/user/domain/admin/user/service/AdminMemberService.java index 6a87a52..ff3b585 100644 --- a/user-service/src/main/java/com/comatching/user/domain/admin/service/AdminMemberService.java +++ b/user-service/src/main/java/com/comatching/user/domain/admin/user/service/AdminMemberService.java @@ -1,11 +1,11 @@ -package com.comatching.user.domain.admin.service; +package com.comatching.user.domain.admin.user.service; import org.springframework.data.domain.Pageable; import com.comatching.common.dto.response.PagingResponse; -import com.comatching.user.domain.admin.dto.AdminInventoryUpdateRequest; -import com.comatching.user.domain.admin.dto.AdminUserDetailResponse; -import com.comatching.user.domain.admin.dto.AdminUserSummaryResponse; +import com.comatching.user.domain.admin.user.dto.AdminInventoryUpdateRequest; +import com.comatching.user.domain.admin.user.dto.AdminUserDetailResponse; +import com.comatching.user.domain.admin.user.dto.AdminUserSummaryResponse; public interface AdminMemberService { diff --git a/user-service/src/main/java/com/comatching/user/domain/admin/service/AdminMemberServiceImpl.java b/user-service/src/main/java/com/comatching/user/domain/admin/user/service/AdminMemberServiceImpl.java similarity index 93% rename from user-service/src/main/java/com/comatching/user/domain/admin/service/AdminMemberServiceImpl.java rename to user-service/src/main/java/com/comatching/user/domain/admin/user/service/AdminMemberServiceImpl.java index 1a27792..d55f9bb 100644 --- a/user-service/src/main/java/com/comatching/user/domain/admin/service/AdminMemberServiceImpl.java +++ b/user-service/src/main/java/com/comatching/user/domain/admin/user/service/AdminMemberServiceImpl.java @@ -1,4 +1,4 @@ -package com.comatching.user.domain.admin.service; +package com.comatching.user.domain.admin.user.service; import java.util.List; import java.util.Map; @@ -15,10 +15,10 @@ import com.comatching.common.dto.response.PagingResponse; import com.comatching.common.exception.BusinessException; import com.comatching.common.exception.code.GeneralErrorCode; -import com.comatching.user.domain.admin.dto.AdminInventoryCounts; -import com.comatching.user.domain.admin.dto.AdminInventoryUpdateRequest; -import com.comatching.user.domain.admin.dto.AdminUserDetailResponse; -import com.comatching.user.domain.admin.dto.AdminUserSummaryResponse; +import com.comatching.user.domain.admin.user.dto.AdminInventoryCounts; +import com.comatching.user.domain.admin.user.dto.AdminInventoryUpdateRequest; +import com.comatching.user.domain.admin.user.dto.AdminUserDetailResponse; +import com.comatching.user.domain.admin.user.dto.AdminUserSummaryResponse; import com.comatching.user.domain.member.entity.Member; import com.comatching.user.domain.member.repository.MemberRepository; import com.comatching.user.global.exception.UserErrorCode; diff --git a/user-service/src/main/java/com/comatching/user/infra/client/ItemAdminClient.java b/user-service/src/main/java/com/comatching/user/infra/client/ItemAdminClient.java index 6f3e13a..c0d203a 100644 --- a/user-service/src/main/java/com/comatching/user/infra/client/ItemAdminClient.java +++ b/user-service/src/main/java/com/comatching/user/infra/client/ItemAdminClient.java @@ -11,8 +11,8 @@ import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestParam; -import com.comatching.user.domain.admin.dto.AdminInventoryCounts; -import com.comatching.user.domain.admin.dto.AdminInventoryUpdateRequest; +import com.comatching.user.domain.admin.user.dto.AdminInventoryCounts; +import com.comatching.user.domain.admin.user.dto.AdminInventoryUpdateRequest; @FeignClient(name = "item-service-admin", url = "${item-service.url}", path = "/api/internal/admin/items") public interface ItemAdminClient { diff --git a/user-service/src/main/java/com/comatching/user/infra/controller/AdminMemberController.java b/user-service/src/main/java/com/comatching/user/infra/controller/AdminMemberController.java index b332890..455a002 100644 --- a/user-service/src/main/java/com/comatching/user/infra/controller/AdminMemberController.java +++ b/user-service/src/main/java/com/comatching/user/infra/controller/AdminMemberController.java @@ -18,10 +18,10 @@ import com.comatching.common.dto.member.MemberInfo; import com.comatching.common.dto.response.ApiResponse; import com.comatching.common.dto.response.PagingResponse; -import com.comatching.user.domain.admin.dto.AdminInventoryUpdateRequest; -import com.comatching.user.domain.admin.dto.AdminUserDetailResponse; -import com.comatching.user.domain.admin.dto.AdminUserSummaryResponse; -import com.comatching.user.domain.admin.service.AdminMemberService; +import com.comatching.user.domain.admin.user.dto.AdminInventoryUpdateRequest; +import com.comatching.user.domain.admin.user.dto.AdminUserDetailResponse; +import com.comatching.user.domain.admin.user.dto.AdminUserSummaryResponse; +import com.comatching.user.domain.admin.user.service.AdminMemberService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; diff --git a/user-service/src/main/java/com/comatching/user/infra/controller/AdminNoticeController.java b/user-service/src/main/java/com/comatching/user/infra/controller/AdminNoticeController.java new file mode 100644 index 0000000..1ffed93 --- /dev/null +++ b/user-service/src/main/java/com/comatching/user/infra/controller/AdminNoticeController.java @@ -0,0 +1,80 @@ +package com.comatching.user.infra.controller; + +import com.comatching.common.annotation.CurrentMember; +import com.comatching.common.annotation.RequireRole; +import com.comatching.common.domain.enums.MemberRole; +import com.comatching.common.dto.member.MemberInfo; +import com.comatching.common.dto.response.ApiResponse; +import com.comatching.user.domain.admin.notice.dto.ActiveNoticeResponse; +import com.comatching.user.domain.admin.notice.dto.AdminNoticeResponse; +import com.comatching.user.domain.admin.notice.dto.NoticeCreateRequest; +import com.comatching.user.domain.admin.notice.dto.NoticeUpdateRequest; +import com.comatching.user.domain.admin.notice.service.AdminNoticeService; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@Tag(name = "Notice API", description = "공지사항 등록/수정/삭제 및 조회") +@RestController +@RequestMapping("/api") +@RequiredArgsConstructor +public class AdminNoticeController { + private final AdminNoticeService adminNoticeService; + + @RequireRole(MemberRole.ROLE_ADMIN) + @Operation(summary = "관리자 공지사항 목록 조회", description = "관리자가 등록된 전체 공지사항을 노출 시작시간 내림차순으로 조회합니다.") + @GetMapping("/admin/notices") + public ResponseEntity>> getAdminNotices( + @CurrentMember MemberInfo memberInfo + ) { + return ResponseEntity.ok(ApiResponse.ok(adminNoticeService.getAdminNotices())); + } + + @RequireRole(MemberRole.ROLE_ADMIN) + @Operation(summary = "공지사항 등록", description = "관리자가 제목, 내용, 시작시간, 종료시간으로 공지사항을 등록합니다.") + @PostMapping("/admin/notices") + public ResponseEntity> createNotice( + @CurrentMember MemberInfo memberInfo, + @RequestBody @Valid NoticeCreateRequest request + ) { + adminNoticeService.createNotice(request); + return ResponseEntity.ok(ApiResponse.ok()); + } + + @RequireRole(MemberRole.ROLE_ADMIN) + @Operation(summary = "공지사항 수정", description = "관리자가 기존 공지사항의 제목, 내용, 노출 기간을 수정합니다.") + @PatchMapping("/admin/notices/{noticeId}") + public ResponseEntity> updateNotice( + @CurrentMember MemberInfo memberInfo, + @PathVariable Long noticeId, + @RequestBody @Valid NoticeUpdateRequest request + ) { + adminNoticeService.updateNotice(noticeId, request); + return ResponseEntity.ok(ApiResponse.ok()); + } + + @RequireRole(MemberRole.ROLE_ADMIN) + @Operation(summary = "공지사항 삭제", description = "관리자가 공지사항을 삭제합니다.") + @DeleteMapping("/admin/notices/{noticeId}") + public ResponseEntity> deleteNotice( + @CurrentMember MemberInfo memberInfo, + @PathVariable Long noticeId + ) { + adminNoticeService.deleteNotice(noticeId); + return ResponseEntity.ok(ApiResponse.ok()); + } + + @RequireRole({MemberRole.ROLE_USER, MemberRole.ROLE_ADMIN}) + @Operation(summary = "활성 공지사항 조회", description = "현재 시각 기준으로 노출 기간에 포함된 공지사항 목록을 조회합니다.") + @GetMapping("/notices/active") + public ResponseEntity>> getActiveNotices( + @CurrentMember MemberInfo memberInfo + ) { + return ResponseEntity.ok(ApiResponse.ok(adminNoticeService.getActiveNotices())); + } +} diff --git a/user-service/src/test/java/com/comatching/user/domain/admin/notice/service/AdminNoticeServiceImplTest.java b/user-service/src/test/java/com/comatching/user/domain/admin/notice/service/AdminNoticeServiceImplTest.java new file mode 100644 index 0000000..41f6959 --- /dev/null +++ b/user-service/src/test/java/com/comatching/user/domain/admin/notice/service/AdminNoticeServiceImplTest.java @@ -0,0 +1,241 @@ +package com.comatching.user.domain.admin.notice.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import com.comatching.common.exception.BusinessException; +import com.comatching.common.exception.code.GeneralErrorCode; +import com.comatching.user.domain.admin.notice.dto.ActiveNoticeResponse; +import com.comatching.user.domain.admin.notice.dto.AdminNoticeResponse; +import com.comatching.user.domain.admin.notice.dto.NoticeCreateRequest; +import com.comatching.user.domain.admin.notice.dto.NoticeUpdateRequest; +import com.comatching.user.domain.admin.notice.entity.Notice; +import com.comatching.user.domain.admin.notice.repository.NoticeRepository; + +@ExtendWith(MockitoExtension.class) +@DisplayName("AdminNoticeServiceImpl 테스트") +class AdminNoticeServiceImplTest { + + @InjectMocks + private AdminNoticeServiceImpl adminNoticeService; + + @Mock + private NoticeRepository noticeRepository; + + @Test + @DisplayName("관리자 공지사항 등록 시 내용을 그대로 저장한다") + void shouldSaveNoticeWithOriginalContent() { + // given + String content = "첫째 줄\n둘째 줄\n셋째 줄"; + NoticeCreateRequest request = new NoticeCreateRequest( + "점검 안내", + content, + LocalDateTime.of(2026, 3, 12, 14, 0), + LocalDateTime.of(2026, 3, 13, 2, 0) + ); + given(noticeRepository.save(any(Notice.class))).willAnswer(invocation -> invocation.getArgument(0)); + + // when + adminNoticeService.createNotice(request); + + // then + ArgumentCaptor noticeCaptor = ArgumentCaptor.forClass(Notice.class); + then(noticeRepository).should().save(noticeCaptor.capture()); + Notice savedNotice = noticeCaptor.getValue(); + assertThat(savedNotice.getTitle()).isEqualTo("점검 안내"); + assertThat(savedNotice.getContent()).isEqualTo(content); + assertThat(savedNotice.getStartTime()).isEqualTo(request.startTime()); + assertThat(savedNotice.getEndTime()).isEqualTo(request.endTime()); + } + + @Test + @DisplayName("시작시간이 종료시간보다 같거나 늦으면 등록에 실패한다") + void shouldThrowWhenStartTimeIsNotBeforeEndTime() { + // given + LocalDateTime startTime = LocalDateTime.of(2026, 3, 12, 10, 0); + NoticeCreateRequest request = new NoticeCreateRequest( + "잘못된 공지", + "내용", + startTime, + startTime + ); + + // when & then + assertThatThrownBy(() -> adminNoticeService.createNotice(request)) + .isInstanceOf(BusinessException.class); + } + + @Test + @DisplayName("시작시간 또는 종료시간이 null이면 등록에 실패한다") + void shouldThrowWhenPeriodIsNull() { + // given + NoticeCreateRequest request = new NoticeCreateRequest( + "공지", + "내용", + null, + LocalDateTime.of(2026, 3, 12, 11, 0) + ); + + // when & then + assertThatThrownBy(() -> adminNoticeService.createNotice(request)) + .isInstanceOf(BusinessException.class); + } + + @Test + @DisplayName("현재 시각에 활성화된 공지사항 id, 제목, 내용을 그대로 반환한다") + void shouldReturnActiveNotices() { + // given + Notice notice = Notice.builder() + .title("공지 제목") + .content("한 줄\n두 줄") + .startTime(LocalDateTime.of(2026, 3, 12, 0, 0)) + .endTime(LocalDateTime.of(2026, 3, 20, 23, 59)) + .build(); + ReflectionTestUtils.setField(notice, "id", 10L); + + given(noticeRepository.findAllByStartTimeLessThanEqualAndEndTimeGreaterThanEqualOrderByStartTimeDescIdDesc( + any(LocalDateTime.class), any(LocalDateTime.class))) + .willReturn(List.of(notice)); + + // when + List responses = adminNoticeService.getActiveNotices(); + + // then + assertThat(responses).hasSize(1); + assertThat(responses.get(0).noticeId()).isEqualTo(10L); + assertThat(responses.get(0).title()).isEqualTo("공지 제목"); + assertThat(responses.get(0).content()).isEqualTo("한 줄\n두 줄"); + } + + @Test + @DisplayName("관리자 공지사항 목록 조회 시 전체 공지사항과 활성 여부를 반환한다") + void shouldReturnAdminNotices() { + // given + LocalDateTime now = LocalDateTime.now(); + Notice activeNotice = Notice.builder() + .title("활성 공지") + .content("활성 내용") + .startTime(now.minusDays(1)) + .endTime(now.plusDays(1)) + .build(); + ReflectionTestUtils.setField(activeNotice, "id", 11L); + + Notice expiredNotice = Notice.builder() + .title("종료 공지") + .content("종료 내용") + .startTime(now.minusDays(3)) + .endTime(now.minusDays(2)) + .build(); + ReflectionTestUtils.setField(expiredNotice, "id", 12L); + + given(noticeRepository.findAllByOrderByStartTimeDescIdDesc()) + .willReturn(List.of(activeNotice, expiredNotice)); + + // when + List responses = adminNoticeService.getAdminNotices(); + + // then + assertThat(responses).hasSize(2); + assertThat(responses).extracting(AdminNoticeResponse::noticeId).containsExactly(11L, 12L); + assertThat(responses).extracting(AdminNoticeResponse::title).containsExactly("활성 공지", "종료 공지"); + assertThat(responses).extracting(AdminNoticeResponse::active).containsExactly(true, false); + } + + @Test + @DisplayName("관리자 공지사항 수정 시 제목, 내용, 노출 기간을 변경한다") + void shouldUpdateNotice() { + // given + Notice notice = Notice.builder() + .title("기존 제목") + .content("기존 내용") + .startTime(LocalDateTime.of(2026, 3, 10, 0, 0)) + .endTime(LocalDateTime.of(2026, 3, 11, 0, 0)) + .build(); + ReflectionTestUtils.setField(notice, "id", 7L); + given(noticeRepository.findById(7L)).willReturn(Optional.of(notice)); + + NoticeUpdateRequest request = new NoticeUpdateRequest( + "수정 제목", + "수정 내용", + LocalDateTime.of(2026, 3, 12, 9, 0), + LocalDateTime.of(2026, 3, 13, 9, 0) + ); + + // when + adminNoticeService.updateNotice(7L, request); + + // then + assertThat(notice.getTitle()).isEqualTo("수정 제목"); + assertThat(notice.getContent()).isEqualTo("수정 내용"); + assertThat(notice.getStartTime()).isEqualTo(request.startTime()); + assertThat(notice.getEndTime()).isEqualTo(request.endTime()); + } + + @Test + @DisplayName("존재하지 않는 공지사항 수정 시 예외가 발생한다") + void shouldThrowWhenUpdatingMissingNotice() { + // given + NoticeUpdateRequest request = new NoticeUpdateRequest( + "수정 제목", + "수정 내용", + LocalDateTime.of(2026, 3, 12, 9, 0), + LocalDateTime.of(2026, 3, 13, 9, 0) + ); + given(noticeRepository.findById(999L)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> adminNoticeService.updateNotice(999L, request)) + .isInstanceOf(BusinessException.class) + .extracting(exception -> ((BusinessException)exception).getErrorCode()) + .isEqualTo(GeneralErrorCode.NOT_FOUND); + } + + @Test + @DisplayName("관리자 공지사항 삭제 시 공지사항을 삭제한다") + void shouldDeleteNotice() { + // given + Notice notice = Notice.builder() + .title("삭제 대상") + .content("삭제 내용") + .startTime(LocalDateTime.of(2026, 3, 10, 0, 0)) + .endTime(LocalDateTime.of(2026, 3, 11, 0, 0)) + .build(); + ReflectionTestUtils.setField(notice, "id", 5L); + given(noticeRepository.findById(5L)).willReturn(Optional.of(notice)); + + // when + adminNoticeService.deleteNotice(5L); + + // then + then(noticeRepository).should().delete(notice); + } + + @Test + @DisplayName("존재하지 않는 공지사항 삭제 시 예외가 발생한다") + void shouldThrowWhenDeletingMissingNotice() { + // given + given(noticeRepository.findById(888L)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> adminNoticeService.deleteNotice(888L)) + .isInstanceOf(BusinessException.class) + .extracting(exception -> ((BusinessException)exception).getErrorCode()) + .isEqualTo(GeneralErrorCode.NOT_FOUND); + } +} diff --git a/user-service/src/test/java/com/comatching/user/domain/admin/service/AdminMemberServiceTest.java b/user-service/src/test/java/com/comatching/user/domain/admin/service/AdminMemberServiceTest.java index beeb398..5410fc8 100644 --- a/user-service/src/test/java/com/comatching/user/domain/admin/service/AdminMemberServiceTest.java +++ b/user-service/src/test/java/com/comatching/user/domain/admin/service/AdminMemberServiceTest.java @@ -12,6 +12,7 @@ import java.util.Map; import java.util.Optional; +import com.comatching.user.domain.admin.user.service.AdminMemberServiceImpl; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -29,10 +30,10 @@ import com.comatching.common.domain.enums.MemberStatus; import com.comatching.common.dto.response.PagingResponse; import com.comatching.common.exception.BusinessException; -import com.comatching.user.domain.admin.dto.AdminInventoryAction; -import com.comatching.user.domain.admin.dto.AdminInventoryCounts; -import com.comatching.user.domain.admin.dto.AdminInventoryUpdateRequest; -import com.comatching.user.domain.admin.dto.AdminUserSummaryResponse; +import com.comatching.user.domain.admin.user.dto.AdminInventoryAction; +import com.comatching.user.domain.admin.user.dto.AdminInventoryCounts; +import com.comatching.user.domain.admin.user.dto.AdminInventoryUpdateRequest; +import com.comatching.user.domain.admin.user.dto.AdminUserSummaryResponse; import com.comatching.user.domain.member.entity.Member; import com.comatching.user.domain.member.entity.Profile; import com.comatching.user.domain.member.repository.MemberRepository; diff --git a/user-service/src/test/java/com/comatching/user/infra/controller/AdminMemberControllerTest.java b/user-service/src/test/java/com/comatching/user/infra/controller/AdminMemberControllerTest.java index a476a75..58f117a 100644 --- a/user-service/src/test/java/com/comatching/user/infra/controller/AdminMemberControllerTest.java +++ b/user-service/src/test/java/com/comatching/user/infra/controller/AdminMemberControllerTest.java @@ -31,11 +31,11 @@ import com.comatching.common.exception.BusinessException; import com.comatching.common.exception.handler.GlobalExceptionHandler; import com.comatching.common.resolver.MemberInfoArgumentResolver; -import com.comatching.user.domain.admin.dto.AdminInventoryAction; -import com.comatching.user.domain.admin.dto.AdminInventoryUpdateRequest; -import com.comatching.user.domain.admin.dto.AdminUserDetailResponse; -import com.comatching.user.domain.admin.dto.AdminUserSummaryResponse; -import com.comatching.user.domain.admin.service.AdminMemberService; +import com.comatching.user.domain.admin.user.dto.AdminInventoryAction; +import com.comatching.user.domain.admin.user.dto.AdminInventoryUpdateRequest; +import com.comatching.user.domain.admin.user.dto.AdminUserDetailResponse; +import com.comatching.user.domain.admin.user.dto.AdminUserSummaryResponse; +import com.comatching.user.domain.admin.user.service.AdminMemberService; import com.comatching.user.global.exception.UserErrorCode; import com.fasterxml.jackson.databind.ObjectMapper; From 22d0eba6cac937fb6e71070dcd637c0e05cce203 Mon Sep 17 00:00:00 2001 From: jinwook219 Date: Wed, 26 Aug 2026 18:13:48 +0900 Subject: [PATCH 06/22] =?UTF-8?q?fix(gateway/user/item):=20=EA=B4=80?= =?UTF-8?q?=EB=A6=AC=EC=9E=90=20API=20=EC=9D=B4=EA=B4=80=20=EA=B2=8C?= =?UTF-8?q?=EC=9D=B4=ED=8A=B8=EC=9B=A8=EC=9D=B4=20=EB=9D=BC=EC=9A=B0?= =?UTF-8?q?=ED=8C=85=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Gateway에 관리자 사용자·공지 및 활성 공지 user-service 라우트 추가 - user-service 관리자 사용자·공지 API 경로를 /api/v1으로 변경 - item-service 기존 관리자 사용자·공지 구현 비활성화 --- .gitignore | 1 + .../src/main/resources/application.yml | 20 ++++- .../admin/service/AdminUserItemService.java | 5 ++ .../service/AdminUserItemServiceImpl.java | 5 ++ .../domain/notice/service/NoticeService.java | 5 ++ .../notice/service/NoticeServiceImpl.java | 5 ++ .../item/infra/client/UserAdminClient.java | 6 ++ .../{ => admin}/AdminPaymentController.java | 2 +- .../{ => admin}/AdminProductController.java | 2 +- .../{ => admin}/AdminUserController.java | 8 +- .../{ => admin}/NoticeController.java | 8 +- .../InternalAdminItemController.java | 2 +- .../service/AdminUserItemServiceImplTest.java | 5 ++ .../notice/service/NoticeServiceImplTest.java | 5 ++ .../controller/AdminMemberController.java | 2 +- .../controller/AdminNoticeController.java | 8 +- .../InternalAdminUserController.java | 74 +++++++++---------- 17 files changed, 114 insertions(+), 49 deletions(-) rename item-service/src/main/java/com/comatching/item/infra/controller/{ => admin}/AdminPaymentController.java (98%) rename item-service/src/main/java/com/comatching/item/infra/controller/{ => admin}/AdminProductController.java (98%) rename item-service/src/main/java/com/comatching/item/infra/controller/{ => admin}/AdminUserController.java (93%) rename item-service/src/main/java/com/comatching/item/infra/controller/{ => admin}/NoticeController.java (94%) rename item-service/src/main/java/com/comatching/item/infra/controller/{ => internal}/InternalAdminItemController.java (96%) diff --git a/.gitignore b/.gitignore index 8a83e8f..b0310c4 100644 --- a/.gitignore +++ b/.gitignore @@ -47,6 +47,7 @@ bin/ .claudeignore .claude .mcp.json +AGENTS.md CLAUDE.md scripts .idea diff --git a/gateway-service/src/main/resources/application.yml b/gateway-service/src/main/resources/application.yml index c5b2555..05c2473 100644 --- a/gateway-service/src/main/resources/application.yml +++ b/gateway-service/src/main/resources/application.yml @@ -49,7 +49,7 @@ spring: - id: user-service-protected uri: http://localhost:9000 predicates: - - Path=/api/auth/signup/profile, /api/auth/logout, /api/auth/password/change, /api/auth/withdraw, /api/members/** + - Path=/api/auth/signup/profile, /api/auth/logout, /api/auth/password/change, /api/auth/withdraw, /api/members/**, /api/notices/** filters: - AuthorizationHeaderFilter @@ -91,7 +91,7 @@ spring: - id: item-service uri: http://localhost:9006 predicates: - - Path=/api/items/**, /api/v1/** + - Path=/api/items/** filters: - AuthorizationHeaderFilter @@ -101,6 +101,22 @@ spring: - Path=/api/fcm/** filters: - AuthorizationHeaderFilter + + - id: admin-user-notice + uri: http://localhost:9000 + predicates: + - Path=/api/v1/admin/users/**, /api/v1/admin/notices/** + filters: + - AuthorizationHeaderFilter + + - id: admin-payment-product-items + uri: http://localhost:9006 + predicates: + - Path=/api/v1/admin/shop/**, /api/v1/admin/payment/** + filters: + - AuthorizationHeaderFilter + + default-filters: - DedupeResponseHeader=Access-Control-Allow-Origin Access-Control-Allow-Credentials, RETAIN_FIRST diff --git a/item-service/src/main/java/com/comatching/item/domain/admin/service/AdminUserItemService.java b/item-service/src/main/java/com/comatching/item/domain/admin/service/AdminUserItemService.java index 4c832bd..dc9d2ca 100644 --- a/item-service/src/main/java/com/comatching/item/domain/admin/service/AdminUserItemService.java +++ b/item-service/src/main/java/com/comatching/item/domain/admin/service/AdminUserItemService.java @@ -1,5 +1,9 @@ package com.comatching.item.domain.admin.service; +/* + * Temporarily disabled with the item-service admin user API. + * + import org.springframework.data.domain.Pageable; import com.comatching.common.dto.response.PagingResponse; @@ -15,3 +19,4 @@ public interface AdminUserItemService { void updateUserInventory(Long adminId, Long memberId, AdminInventoryUpdateRequest request); } +*/ diff --git a/item-service/src/main/java/com/comatching/item/domain/admin/service/AdminUserItemServiceImpl.java b/item-service/src/main/java/com/comatching/item/domain/admin/service/AdminUserItemServiceImpl.java index 4a2a5a1..4114e5e 100644 --- a/item-service/src/main/java/com/comatching/item/domain/admin/service/AdminUserItemServiceImpl.java +++ b/item-service/src/main/java/com/comatching/item/domain/admin/service/AdminUserItemServiceImpl.java @@ -1,5 +1,9 @@ package com.comatching.item.domain.admin.service; +/* + * Temporarily disabled with the item-service admin user API. + * + import java.util.HashMap; import java.util.List; import java.util.Locale; @@ -159,3 +163,4 @@ private List toSortParams(Sort sort) { .toList(); } } +*/ diff --git a/item-service/src/main/java/com/comatching/item/domain/notice/service/NoticeService.java b/item-service/src/main/java/com/comatching/item/domain/notice/service/NoticeService.java index e0505dc..54ae003 100644 --- a/item-service/src/main/java/com/comatching/item/domain/notice/service/NoticeService.java +++ b/item-service/src/main/java/com/comatching/item/domain/notice/service/NoticeService.java @@ -1,5 +1,9 @@ package com.comatching.item.domain.notice.service; +/* + * Temporarily disabled with the item-service notice API. + * + import java.util.List; import com.comatching.item.domain.notice.dto.AdminNoticeResponse; @@ -19,3 +23,4 @@ public interface NoticeService { List getAdminNotices(); } +*/ diff --git a/item-service/src/main/java/com/comatching/item/domain/notice/service/NoticeServiceImpl.java b/item-service/src/main/java/com/comatching/item/domain/notice/service/NoticeServiceImpl.java index 0797afc..91ceb6e 100644 --- a/item-service/src/main/java/com/comatching/item/domain/notice/service/NoticeServiceImpl.java +++ b/item-service/src/main/java/com/comatching/item/domain/notice/service/NoticeServiceImpl.java @@ -1,5 +1,9 @@ package com.comatching.item.domain.notice.service; +/* + * Temporarily disabled with the item-service notice API. + * + import java.time.LocalDateTime; import java.util.List; @@ -87,3 +91,4 @@ private void validatePeriod(LocalDateTime startTime, LocalDateTime endTime) { } } } +*/ diff --git a/item-service/src/main/java/com/comatching/item/infra/client/UserAdminClient.java b/item-service/src/main/java/com/comatching/item/infra/client/UserAdminClient.java index 0d524ef..73c1f47 100644 --- a/item-service/src/main/java/com/comatching/item/infra/client/UserAdminClient.java +++ b/item-service/src/main/java/com/comatching/item/infra/client/UserAdminClient.java @@ -1,5 +1,10 @@ package com.comatching.item.infra.client; +/* + * Temporarily disabled with the item-service admin user API. + * This prevents requests to user-service for the migrated endpoints. + * + import java.util.List; import org.springframework.cloud.openfeign.FeignClient; @@ -24,3 +29,4 @@ PagingResponse getUsers( @GetMapping("/{memberId}") AdminUserProfileDto getUserDetail(@PathVariable Long memberId); } +*/ diff --git a/item-service/src/main/java/com/comatching/item/infra/controller/AdminPaymentController.java b/item-service/src/main/java/com/comatching/item/infra/controller/admin/AdminPaymentController.java similarity index 98% rename from item-service/src/main/java/com/comatching/item/infra/controller/AdminPaymentController.java rename to item-service/src/main/java/com/comatching/item/infra/controller/admin/AdminPaymentController.java index 24fbb09..7dd59f9 100644 --- a/item-service/src/main/java/com/comatching/item/infra/controller/AdminPaymentController.java +++ b/item-service/src/main/java/com/comatching/item/infra/controller/admin/AdminPaymentController.java @@ -1,4 +1,4 @@ -package com.comatching.item.infra.controller; +package com.comatching.item.infra.controller.admin; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; diff --git a/item-service/src/main/java/com/comatching/item/infra/controller/AdminProductController.java b/item-service/src/main/java/com/comatching/item/infra/controller/admin/AdminProductController.java similarity index 98% rename from item-service/src/main/java/com/comatching/item/infra/controller/AdminProductController.java rename to item-service/src/main/java/com/comatching/item/infra/controller/admin/AdminProductController.java index 207b7a5..f34cf2f 100644 --- a/item-service/src/main/java/com/comatching/item/infra/controller/AdminProductController.java +++ b/item-service/src/main/java/com/comatching/item/infra/controller/admin/AdminProductController.java @@ -1,4 +1,4 @@ -package com.comatching.item.infra.controller; +package com.comatching.item.infra.controller.admin; import java.util.List; diff --git a/item-service/src/main/java/com/comatching/item/infra/controller/AdminUserController.java b/item-service/src/main/java/com/comatching/item/infra/controller/admin/AdminUserController.java similarity index 93% rename from item-service/src/main/java/com/comatching/item/infra/controller/AdminUserController.java rename to item-service/src/main/java/com/comatching/item/infra/controller/admin/AdminUserController.java index 30325e5..ad30cfc 100644 --- a/item-service/src/main/java/com/comatching/item/infra/controller/AdminUserController.java +++ b/item-service/src/main/java/com/comatching/item/infra/controller/admin/AdminUserController.java @@ -1,4 +1,9 @@ -package com.comatching.item.infra.controller; +package com.comatching.item.infra.controller.admin; + +/* + * Temporarily disabled: admin user/item API has moved out of item-service. + * Keep the prior implementation here as a comment for the migration rollback. + * import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; @@ -69,3 +74,4 @@ public ResponseEntity> updateUserInventory( return ResponseEntity.ok(ApiResponse.ok()); } } +*/ diff --git a/item-service/src/main/java/com/comatching/item/infra/controller/NoticeController.java b/item-service/src/main/java/com/comatching/item/infra/controller/admin/NoticeController.java similarity index 94% rename from item-service/src/main/java/com/comatching/item/infra/controller/NoticeController.java rename to item-service/src/main/java/com/comatching/item/infra/controller/admin/NoticeController.java index 4251032..f0424c0 100644 --- a/item-service/src/main/java/com/comatching/item/infra/controller/NoticeController.java +++ b/item-service/src/main/java/com/comatching/item/infra/controller/admin/NoticeController.java @@ -1,4 +1,9 @@ -package com.comatching.item.infra.controller; +package com.comatching.item.infra.controller.admin; + +/* + * Temporarily disabled: notice API has moved out of item-service. + * Keep the prior implementation here as a comment for the migration rollback. + * import java.util.List; @@ -88,3 +93,4 @@ public ResponseEntity>> getActiveNotices( return ResponseEntity.ok(ApiResponse.ok(noticeService.getActiveNotices())); } } +*/ diff --git a/item-service/src/main/java/com/comatching/item/infra/controller/InternalAdminItemController.java b/item-service/src/main/java/com/comatching/item/infra/controller/internal/InternalAdminItemController.java similarity index 96% rename from item-service/src/main/java/com/comatching/item/infra/controller/InternalAdminItemController.java rename to item-service/src/main/java/com/comatching/item/infra/controller/internal/InternalAdminItemController.java index 0dd7303..582c7b1 100644 --- a/item-service/src/main/java/com/comatching/item/infra/controller/InternalAdminItemController.java +++ b/item-service/src/main/java/com/comatching/item/infra/controller/internal/InternalAdminItemController.java @@ -1,4 +1,4 @@ -package com.comatching.item.infra.controller; +package com.comatching.item.infra.controller.internal; import java.util.List; import java.util.Map; diff --git a/item-service/src/test/java/com/comatching/item/domain/admin/service/AdminUserItemServiceImplTest.java b/item-service/src/test/java/com/comatching/item/domain/admin/service/AdminUserItemServiceImplTest.java index 2e0af31..54d492a 100644 --- a/item-service/src/test/java/com/comatching/item/domain/admin/service/AdminUserItemServiceImplTest.java +++ b/item-service/src/test/java/com/comatching/item/domain/admin/service/AdminUserItemServiceImplTest.java @@ -1,5 +1,9 @@ package com.comatching.item.domain.admin.service; +/* + * Temporarily disabled with the item-service admin user API. + * + import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; @@ -196,3 +200,4 @@ public Long getQuantity() { }; } } +*/ diff --git a/item-service/src/test/java/com/comatching/item/domain/notice/service/NoticeServiceImplTest.java b/item-service/src/test/java/com/comatching/item/domain/notice/service/NoticeServiceImplTest.java index 3666840..a7a2c4d 100644 --- a/item-service/src/test/java/com/comatching/item/domain/notice/service/NoticeServiceImplTest.java +++ b/item-service/src/test/java/com/comatching/item/domain/notice/service/NoticeServiceImplTest.java @@ -1,5 +1,9 @@ package com.comatching.item.domain.notice.service; +/* + * Temporarily disabled with the item-service notice API. + * + import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; @@ -239,3 +243,4 @@ void shouldThrowWhenDeletingMissingNotice() { .isEqualTo(GeneralErrorCode.NOT_FOUND); } } +*/ diff --git a/user-service/src/main/java/com/comatching/user/infra/controller/AdminMemberController.java b/user-service/src/main/java/com/comatching/user/infra/controller/AdminMemberController.java index 455a002..43b6e0f 100644 --- a/user-service/src/main/java/com/comatching/user/infra/controller/AdminMemberController.java +++ b/user-service/src/main/java/com/comatching/user/infra/controller/AdminMemberController.java @@ -30,7 +30,7 @@ @Tag(name = "Admin User API", description = "관리자 전용 사용자 조회 및 인벤토리 관리") @RestController -@RequestMapping("/api/admin/users") +@RequestMapping("/api/v1/admin/users") @RequiredArgsConstructor public class AdminMemberController { diff --git a/user-service/src/main/java/com/comatching/user/infra/controller/AdminNoticeController.java b/user-service/src/main/java/com/comatching/user/infra/controller/AdminNoticeController.java index 1ffed93..8eb3ad9 100644 --- a/user-service/src/main/java/com/comatching/user/infra/controller/AdminNoticeController.java +++ b/user-service/src/main/java/com/comatching/user/infra/controller/AdminNoticeController.java @@ -28,7 +28,7 @@ public class AdminNoticeController { @RequireRole(MemberRole.ROLE_ADMIN) @Operation(summary = "관리자 공지사항 목록 조회", description = "관리자가 등록된 전체 공지사항을 노출 시작시간 내림차순으로 조회합니다.") - @GetMapping("/admin/notices") + @GetMapping("v1/admin/notices") public ResponseEntity>> getAdminNotices( @CurrentMember MemberInfo memberInfo ) { @@ -37,7 +37,7 @@ public ResponseEntity>> getAdminNotices( @RequireRole(MemberRole.ROLE_ADMIN) @Operation(summary = "공지사항 등록", description = "관리자가 제목, 내용, 시작시간, 종료시간으로 공지사항을 등록합니다.") - @PostMapping("/admin/notices") + @PostMapping("v1/admin/notices") public ResponseEntity> createNotice( @CurrentMember MemberInfo memberInfo, @RequestBody @Valid NoticeCreateRequest request @@ -48,7 +48,7 @@ public ResponseEntity> createNotice( @RequireRole(MemberRole.ROLE_ADMIN) @Operation(summary = "공지사항 수정", description = "관리자가 기존 공지사항의 제목, 내용, 노출 기간을 수정합니다.") - @PatchMapping("/admin/notices/{noticeId}") + @PatchMapping("v1/admin/notices/{noticeId}") public ResponseEntity> updateNotice( @CurrentMember MemberInfo memberInfo, @PathVariable Long noticeId, @@ -60,7 +60,7 @@ public ResponseEntity> updateNotice( @RequireRole(MemberRole.ROLE_ADMIN) @Operation(summary = "공지사항 삭제", description = "관리자가 공지사항을 삭제합니다.") - @DeleteMapping("/admin/notices/{noticeId}") + @DeleteMapping("v1/admin/notices/{noticeId}") public ResponseEntity> deleteNotice( @CurrentMember MemberInfo memberInfo, @PathVariable Long noticeId diff --git a/user-service/src/main/java/com/comatching/user/infra/controller/InternalAdminUserController.java b/user-service/src/main/java/com/comatching/user/infra/controller/InternalAdminUserController.java index 73cb058..1761cd5 100644 --- a/user-service/src/main/java/com/comatching/user/infra/controller/InternalAdminUserController.java +++ b/user-service/src/main/java/com/comatching/user/infra/controller/InternalAdminUserController.java @@ -1,37 +1,37 @@ -package com.comatching.user.infra.controller; - -import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.Sort; -import org.springframework.data.web.PageableDefault; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestMapping; -import org.springframework.web.bind.annotation.RequestParam; -import org.springframework.web.bind.annotation.RestController; - -import com.comatching.common.dto.member.AdminUserProfileDto; -import com.comatching.common.dto.response.PagingResponse; -import com.comatching.user.domain.member.service.AdminMemberQueryService; - -import lombok.RequiredArgsConstructor; - -@RestController -@RequestMapping("/api/internal/admin/users") -@RequiredArgsConstructor -public class InternalAdminUserController { - - private final AdminMemberQueryService adminMemberQueryService; - - @GetMapping - public PagingResponse getUsers( - @RequestParam(required = false) String keyword, - @PageableDefault(size = 20, sort = "id", direction = Sort.Direction.DESC) Pageable pageable - ) { - return adminMemberQueryService.getUsers(keyword, pageable); - } - - @GetMapping("/{memberId}") - public AdminUserProfileDto getUserDetail(@PathVariable Long memberId) { - return adminMemberQueryService.getUserDetail(memberId); - } -} +//package com.comatching.user.infra.controller; +// +//import org.springframework.data.domain.Pageable; +//import org.springframework.data.domain.Sort; +//import org.springframework.data.web.PageableDefault; +//import org.springframework.web.bind.annotation.GetMapping; +//import org.springframework.web.bind.annotation.PathVariable; +//import org.springframework.web.bind.annotation.RequestMapping; +//import org.springframework.web.bind.annotation.RequestParam; +//import org.springframework.web.bind.annotation.RestController; +// +//import com.comatching.common.dto.member.AdminUserProfileDto; +//import com.comatching.common.dto.response.PagingResponse; +//import com.comatching.user.domain.member.service.AdminMemberQueryService; +// +//import lombok.RequiredArgsConstructor; +// +//@RestController +//@RequestMapping("/api/internal/admin/users") +//@RequiredArgsConstructor +//public class InternalAdminUserController { +// +// private final AdminMemberQueryService adminMemberQueryService; +// +// @GetMapping +// public PagingResponse getUsers( +// @RequestParam(required = false) String keyword, +// @PageableDefault(size = 20, sort = "id", direction = Sort.Direction.DESC) Pageable pageable +// ) { +// return adminMemberQueryService.getUsers(keyword, pageable); +// } +// +// @GetMapping("/{memberId}") +// public AdminUserProfileDto getUserDetail(@PathVariable Long memberId) { +// return adminMemberQueryService.getUserDetail(memberId); +// } +//} From 7f239ccf41caf1cbb6f5ee85eca3534e21fa4d2d Mon Sep 17 00:00:00 2001 From: jinwook219 Date: Thu, 27 Aug 2026 14:07:02 +0900 Subject: [PATCH 07/22] =?UTF-8?q?feat(item):=20=EB=A3=B0=EB=A0=9B=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=20=EC=97=94=ED=8B=B0=ED=8B=B0=20?= =?UTF-8?q?=EB=B0=8F=20=ED=83=80=EC=9E=85=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RouletteReward, RouletteHistory 엔티티 추가 - RouletteType, PrizeType enum 추가 --- .gitignore | 2 + .../roulette/entity/RouletteHistory.java | 54 +++++++++++++++ .../roulette/entity/RouletteReward.java | 66 +++++++++++++++++++ .../item/domain/roulette/enums/PrizeType.java | 7 ++ .../domain/roulette/enums/RouletteType.java | 6 ++ 5 files changed, 135 insertions(+) create mode 100644 item-service/src/main/java/com/comatching/item/domain/roulette/entity/RouletteHistory.java create mode 100644 item-service/src/main/java/com/comatching/item/domain/roulette/entity/RouletteReward.java create mode 100644 item-service/src/main/java/com/comatching/item/domain/roulette/enums/PrizeType.java create mode 100644 item-service/src/main/java/com/comatching/item/domain/roulette/enums/RouletteType.java diff --git a/.gitignore b/.gitignore index b16d467..d8a3325 100644 --- a/.gitignore +++ b/.gitignore @@ -96,3 +96,5 @@ _workspace id_rsa id_ed25519 comatching-ec2 +/AGENTS.md + diff --git a/item-service/src/main/java/com/comatching/item/domain/roulette/entity/RouletteHistory.java b/item-service/src/main/java/com/comatching/item/domain/roulette/entity/RouletteHistory.java new file mode 100644 index 0000000..281f059 --- /dev/null +++ b/item-service/src/main/java/com/comatching/item/domain/roulette/entity/RouletteHistory.java @@ -0,0 +1,54 @@ +package com.comatching.item.domain.roulette.entity; + +import java.time.LocalDateTime; + +import com.comatching.item.domain.roulette.enums.RouletteType; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class RouletteHistory { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = false) + private Long memberId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "reward_id", nullable = false) + private RouletteReward reward; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private RouletteType rouletteType; + + private final LocalDateTime participatedAt = LocalDateTime.now(); + + @Builder + public RouletteHistory( + Long memberId, + RouletteReward reward, + RouletteType rouletteType + ) { + this.memberId = memberId; + this.reward = reward; + this.rouletteType = rouletteType; + } +} \ No newline at end of file diff --git a/item-service/src/main/java/com/comatching/item/domain/roulette/entity/RouletteReward.java b/item-service/src/main/java/com/comatching/item/domain/roulette/entity/RouletteReward.java new file mode 100644 index 0000000..6a6c419 --- /dev/null +++ b/item-service/src/main/java/com/comatching/item/domain/roulette/entity/RouletteReward.java @@ -0,0 +1,66 @@ +package com.comatching.item.domain.roulette.entity; + +import com.comatching.common.domain.enums.ItemType; +import com.comatching.item.domain.roulette.enums.RouletteType; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class RouletteReward { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private RouletteType rouletteType; + + @Column(nullable = false) + private String rewardName; + + @Enumerated(EnumType.STRING) + private ItemType itemType; + + @Column(nullable = false) + private int quantity; + + @Column(nullable = false) + private int rangeStart; + + @Column(nullable = false) + private int rangeEnd; + + private Integer remainingCount; + + @Builder + public RouletteReward( + RouletteType rouletteType, + String rewardName, + ItemType itemType, + int quantity, + int rangeStart, + int rangeEnd, + Integer remainingCount + ) { + this.rouletteType = rouletteType; + this.rewardName = rewardName; + this.itemType = itemType; + this.quantity = quantity; + this.rangeStart = rangeStart; + this.rangeEnd = rangeEnd; + this.remainingCount = remainingCount; + } +} \ No newline at end of file diff --git a/item-service/src/main/java/com/comatching/item/domain/roulette/enums/PrizeType.java b/item-service/src/main/java/com/comatching/item/domain/roulette/enums/PrizeType.java new file mode 100644 index 0000000..890dc2a --- /dev/null +++ b/item-service/src/main/java/com/comatching/item/domain/roulette/enums/PrizeType.java @@ -0,0 +1,7 @@ +package com.comatching.item.domain.roulette.enums; + +public enum PrizeType { + ITEM, + GIFT_CARD, + NONE +} \ No newline at end of file diff --git a/item-service/src/main/java/com/comatching/item/domain/roulette/enums/RouletteType.java b/item-service/src/main/java/com/comatching/item/domain/roulette/enums/RouletteType.java new file mode 100644 index 0000000..d74ed40 --- /dev/null +++ b/item-service/src/main/java/com/comatching/item/domain/roulette/enums/RouletteType.java @@ -0,0 +1,6 @@ +package com.comatching.item.domain.roulette.enums; + +public enum RouletteType { + FREE, + PAID +} \ No newline at end of file From e237117c0890e64de2fef640e39cf06997a0040e Mon Sep 17 00:00:00 2001 From: jinwook219 Date: Thu, 27 Aug 2026 19:22:42 +0900 Subject: [PATCH 08/22] =?UTF-8?q?feat(item):=20=EB=A3=B0=EB=A0=9B=20?= =?UTF-8?q?=EC=B6=94=EC=B2=A8=20=EB=B0=8F=20=EB=B3=B4=EC=83=81=20=EC=A7=80?= =?UTF-8?q?=EA=B8=89=20API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - FREE 룰렛 중복 참여 검증 및 PAID 룰렛 티켓 차감 처리 - 확률 범위 기반 보상 추첨과 제한 재고 차감 로직 추가 - 아이템 보상과 아이템 사용·획득 이력 저장 - 상품권·꽝을 포함한 룰렛 참여 이력 저장 - 당첨 보상명을 반환하는 응답 DTO 및 룰렛 API 추가 - 보상 처리 예외와 난수 경계 조건 테스트 작성 --- .../java/com/comatching/common/domain/enums/ItemType.java | 3 ++- .../item/domain/item/repository/ItemRepository.java | 8 ++++++++ .../item/domain/roulette/entity/RouletteReward.java | 8 +++++++- .../comatching/item/global/exception/ItemErrorCode.java | 1 + 4 files changed, 18 insertions(+), 2 deletions(-) diff --git a/common-module/src/main/java/com/comatching/common/domain/enums/ItemType.java b/common-module/src/main/java/com/comatching/common/domain/enums/ItemType.java index a1e7c0c..33ef652 100644 --- a/common-module/src/main/java/com/comatching/common/domain/enums/ItemType.java +++ b/common-module/src/main/java/com/comatching/common/domain/enums/ItemType.java @@ -7,7 +7,8 @@ @RequiredArgsConstructor public enum ItemType { MATCHING_TICKET("매칭권", "기본 옵션만으로 매칭을 할 수 있는 아이템"), - OPTION_TICKET("옵션권", "같은 학과 제외, 중요 옵션 선택이 가능해지는 아이템") + OPTION_TICKET("옵션권", "같은 학과 제외, 중요 옵션 선택이 가능해지는 아이템"), + ROULETTE_TICKET("룰렛티켓", "룰렛을 돌릴 수 있는 티켓") ; private final String name; diff --git a/item-service/src/main/java/com/comatching/item/domain/item/repository/ItemRepository.java b/item-service/src/main/java/com/comatching/item/domain/item/repository/ItemRepository.java index 8055318..d6fcbfc 100644 --- a/item-service/src/main/java/com/comatching/item/domain/item/repository/ItemRepository.java +++ b/item-service/src/main/java/com/comatching/item/domain/item/repository/ItemRepository.java @@ -1,6 +1,8 @@ package com.comatching.item.domain.item.repository; +import java.time.LocalDateTime; import java.util.List; +import java.util.Optional; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -28,6 +30,12 @@ List findAllUsableItems( @Param("memberId") Long memberId, @Param("itemType") ItemType itemType); + Optional findFirstByMemberIdAndItemTypeAndQuantityGreaterThanEqualAndExpiredAtGreaterThanOrderByExpiredAtAscQuantityAsc( + Long memberId, + ItemType itemType, + int quantity, + LocalDateTime now); + @Query("SELECT i FROM Item i " + "WHERE i.memberId = :memberId " + "AND i.quantity > 0 " + diff --git a/item-service/src/main/java/com/comatching/item/domain/roulette/entity/RouletteReward.java b/item-service/src/main/java/com/comatching/item/domain/roulette/entity/RouletteReward.java index 6a6c419..d95ae3e 100644 --- a/item-service/src/main/java/com/comatching/item/domain/roulette/entity/RouletteReward.java +++ b/item-service/src/main/java/com/comatching/item/domain/roulette/entity/RouletteReward.java @@ -63,4 +63,10 @@ public RouletteReward( this.rangeEnd = rangeEnd; this.remainingCount = remainingCount; } -} \ No newline at end of file + + public void decreaseRemainingCount() { + if (remainingCount != null && remainingCount > 0) { + remainingCount--; + } + } +} diff --git a/item-service/src/main/java/com/comatching/item/global/exception/ItemErrorCode.java b/item-service/src/main/java/com/comatching/item/global/exception/ItemErrorCode.java index 1123123..3490cb8 100644 --- a/item-service/src/main/java/com/comatching/item/global/exception/ItemErrorCode.java +++ b/item-service/src/main/java/com/comatching/item/global/exception/ItemErrorCode.java @@ -15,6 +15,7 @@ public enum ItemErrorCode implements ErrorCode { TARGET_USER_NOT_FOUND("ITEM-004", HttpStatus.BAD_REQUEST, "대상 사용자를 찾을 수 없습니다."), USER_QUERY_FAILED("ITEM-005", HttpStatus.INTERNAL_SERVER_ERROR, "사용자 조회 중 오류가 발생했습니다."), DUPLICATE_ADMIN_INVENTORY_ADJUSTMENT("ITEM-006", HttpStatus.CONFLICT, "동일한 관리자 아이템 조정 요청이 처리 중입니다."), + ALREADY_PARTICIPATED_FREE_ROULETTE("ITEM-007", HttpStatus.BAD_REQUEST, "Free roulette already participated."), ; private final String code; From 8ab8b3c9ed3a98cd132218317fccdf4fe2b08a5c Mon Sep 17 00:00:00 2001 From: jinwook219 Date: Thu, 27 Aug 2026 20:44:53 +0900 Subject: [PATCH 09/22] =?UTF-8?q?test(item):=20=EB=A3=B0=EB=A0=9B=20?= =?UTF-8?q?=EB=B3=B4=EC=83=81=20=EC=B2=98=EB=A6=AC=20=EB=B0=8F=20API=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PAID 룰렛 티켓 실제 차감 검증 - FREE 당일 참여 조회 조건 검증 - 풀세트 전체 재고 차감 검증 - PAID 일일 제한 미적용 검증 - 보상 처리 실패 시 트랜잭션 롤백 검증 - 아이템 이력 설명과 룰렛 API 계약 검증 --- .../roulette/dto/RouletteSpinResponse.java | 6 + .../repository/RouletteHistoryRepository.java | 20 + .../repository/RouletteRewardRepository.java | 26 ++ .../roulette/service/RouletteService.java | 9 + .../roulette/service/RouletteServiceImpl.java | 104 ++++++ .../infra/controller/RouletteController.java | 30 ++ .../service/RouletteRewardRepositoryTest.java | 265 ++++++++++++++ .../service/RouletteServiceImplTest.java | 346 ++++++++++++++++++ .../controller/RouletteControllerTest.java | 85 +++++ 9 files changed, 891 insertions(+) create mode 100644 item-service/src/main/java/com/comatching/item/domain/roulette/dto/RouletteSpinResponse.java create mode 100644 item-service/src/main/java/com/comatching/item/domain/roulette/repository/RouletteHistoryRepository.java create mode 100644 item-service/src/main/java/com/comatching/item/domain/roulette/repository/RouletteRewardRepository.java create mode 100644 item-service/src/main/java/com/comatching/item/domain/roulette/service/RouletteService.java create mode 100644 item-service/src/main/java/com/comatching/item/domain/roulette/service/RouletteServiceImpl.java create mode 100644 item-service/src/main/java/com/comatching/item/infra/controller/RouletteController.java create mode 100644 item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteRewardRepositoryTest.java create mode 100644 item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteServiceImplTest.java create mode 100644 item-service/src/test/java/com/comatching/item/infra/controller/RouletteControllerTest.java diff --git a/item-service/src/main/java/com/comatching/item/domain/roulette/dto/RouletteSpinResponse.java b/item-service/src/main/java/com/comatching/item/domain/roulette/dto/RouletteSpinResponse.java new file mode 100644 index 0000000..644806f --- /dev/null +++ b/item-service/src/main/java/com/comatching/item/domain/roulette/dto/RouletteSpinResponse.java @@ -0,0 +1,6 @@ +package com.comatching.item.domain.roulette.dto; + +public record RouletteSpinResponse( + String rewardName +) { +} diff --git a/item-service/src/main/java/com/comatching/item/domain/roulette/repository/RouletteHistoryRepository.java b/item-service/src/main/java/com/comatching/item/domain/roulette/repository/RouletteHistoryRepository.java new file mode 100644 index 0000000..8571973 --- /dev/null +++ b/item-service/src/main/java/com/comatching/item/domain/roulette/repository/RouletteHistoryRepository.java @@ -0,0 +1,20 @@ +package com.comatching.item.domain.roulette.repository; + +import com.comatching.item.domain.roulette.entity.RouletteHistory; +import com.comatching.item.domain.roulette.enums.RouletteType; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface RouletteHistoryRepository extends JpaRepository { + @Query(""" + SELECT CASE WHEN COUNT(rh) > 0 THEN true ELSE false END + FROM RouletteHistory rh + WHERE rh.memberId = :memberId + AND rh.rouletteType = :rouletteType + AND rh.participatedAt >= CURRENT_DATE + """) + boolean existsTodayByMemberIdAndRouletteType( + @Param("memberId") Long memberId, + @Param("rouletteType") RouletteType rouletteType); +} diff --git a/item-service/src/main/java/com/comatching/item/domain/roulette/repository/RouletteRewardRepository.java b/item-service/src/main/java/com/comatching/item/domain/roulette/repository/RouletteRewardRepository.java new file mode 100644 index 0000000..a63b718 --- /dev/null +++ b/item-service/src/main/java/com/comatching/item/domain/roulette/repository/RouletteRewardRepository.java @@ -0,0 +1,26 @@ +package com.comatching.item.domain.roulette.repository; + +import java.util.List; + +import com.comatching.item.domain.roulette.entity.RouletteReward; +import com.comatching.item.domain.roulette.enums.RouletteType; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +public interface RouletteRewardRepository extends JpaRepository { + List findAllByRouletteType(RouletteType rouletteType); + + @Query(""" + SELECT rr + FROM RouletteReward rr + WHERE rr.rouletteType = :rouletteType + AND rr.rangeStart <= :rouletteNumber + AND rr.rangeEnd >= :rouletteNumber + AND (rr.remainingCount IS NULL OR rr.remainingCount > 0) + ORDER BY rr.id ASC + """) + List findAvailableByRouletteTypeAndRouletteNumber( + @Param("rouletteType") RouletteType rouletteType, + @Param("rouletteNumber") int rouletteNumber); +} diff --git a/item-service/src/main/java/com/comatching/item/domain/roulette/service/RouletteService.java b/item-service/src/main/java/com/comatching/item/domain/roulette/service/RouletteService.java new file mode 100644 index 0000000..55d606d --- /dev/null +++ b/item-service/src/main/java/com/comatching/item/domain/roulette/service/RouletteService.java @@ -0,0 +1,9 @@ +package com.comatching.item.domain.roulette.service; + +import com.comatching.common.dto.member.MemberInfo; +import com.comatching.item.domain.roulette.dto.RouletteSpinResponse; +import com.comatching.item.domain.roulette.enums.RouletteType; + +public interface RouletteService { + RouletteSpinResponse spinRoulette(MemberInfo memberInfo, RouletteType rouletteType); +} diff --git a/item-service/src/main/java/com/comatching/item/domain/roulette/service/RouletteServiceImpl.java b/item-service/src/main/java/com/comatching/item/domain/roulette/service/RouletteServiceImpl.java new file mode 100644 index 0000000..9608d98 --- /dev/null +++ b/item-service/src/main/java/com/comatching/item/domain/roulette/service/RouletteServiceImpl.java @@ -0,0 +1,104 @@ +package com.comatching.item.domain.roulette.service; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.concurrent.ThreadLocalRandom; + +import com.comatching.common.domain.enums.ItemType; +import com.comatching.common.dto.member.MemberInfo; +import com.comatching.common.exception.BusinessException; +import com.comatching.item.domain.item.entity.Item; +import com.comatching.item.domain.item.entity.ItemHistory; +import com.comatching.item.domain.item.enums.ItemHistoryType; +import com.comatching.item.domain.item.repository.ItemHistoryRepository; +import com.comatching.item.domain.item.repository.ItemRepository; +import com.comatching.item.domain.roulette.dto.RouletteSpinResponse; +import com.comatching.item.domain.roulette.entity.RouletteHistory; +import com.comatching.item.domain.roulette.entity.RouletteReward; +import com.comatching.item.domain.roulette.enums.RouletteType; +import com.comatching.item.domain.roulette.repository.RouletteHistoryRepository; +import com.comatching.item.domain.roulette.repository.RouletteRewardRepository; +import com.comatching.item.global.exception.ItemErrorCode; +import lombok.AllArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@AllArgsConstructor +public class RouletteServiceImpl implements RouletteService { + private static final LocalDateTime ROULETTE_REWARD_EXPIRED_AT = + LocalDateTime.of(9999, 12, 31, 23, 59, 59); + + private final RouletteHistoryRepository rouletteHistoryRepository; + private final RouletteRewardRepository rouletteRewardRepository; + private final ItemRepository itemRepository; + private final ItemHistoryRepository itemHistoryRepository; + + @Override + @Transactional + public RouletteSpinResponse spinRoulette(MemberInfo memberInfo, RouletteType rouletteType) { + // 타입이 무료인 경우 참여여부 검증 + 예외처리 + if (rouletteType == RouletteType.FREE && rouletteHistoryRepository + .existsTodayByMemberIdAndRouletteType(memberInfo.memberId(), rouletteType)) { + throw new BusinessException(ItemErrorCode.ALREADY_PARTICIPATED_FREE_ROULETTE); + } + + if (rouletteType == RouletteType.PAID) { + Item rouletteTicket = itemRepository + .findFirstByMemberIdAndItemTypeAndQuantityGreaterThanEqualAndExpiredAtGreaterThanOrderByExpiredAtAscQuantityAsc( + memberInfo.memberId(), ItemType.ROULETTE_TICKET, 1, LocalDateTime.now()) + .orElseThrow(() -> new BusinessException(ItemErrorCode.NOT_ENOUGH_ITEM)); + + rouletteTicket.decrease(1); + + itemHistoryRepository.save(ItemHistory.builder() + .memberId(memberInfo.memberId()) + .itemType(ItemType.ROULETTE_TICKET) + .historyType(ItemHistoryType.USE) + .quantity(-1) + .description(ItemType.ROULETTE_TICKET.getName()) + .build()); + } + + // 1 ~ 10000 사이의 난수 생성한 후에 db에 확률 range에 맞게 보상 가져오기 (만약 보상의 남은 갯수가 없다면 다시 난수 생성해서 추첨) + List rouletteRewards; + do { + int rouletteNumber = ThreadLocalRandom.current().nextInt(1, 10_001); + rouletteRewards = rouletteRewardRepository + .findAvailableByRouletteTypeAndRouletteNumber(rouletteType, rouletteNumber); + } while (rouletteRewards.isEmpty()); + + RouletteReward rouletteReward = rouletteRewards.get(0); + + // 보상에 따른 아이템 및 아이템 기록 룰렛 기록 추가 + rouletteRewards.stream() + .forEach(RouletteReward::decreaseRemainingCount); // 남은 아이템 수 줄이기 + + rouletteRewards.stream() + .filter(reward -> reward.getItemType() != null) + .forEach(reward -> { + itemRepository.save(Item.builder() + .memberId(memberInfo.memberId()) + .itemType(reward.getItemType()) + .quantity(reward.getQuantity()) + .expiredAt(ROULETTE_REWARD_EXPIRED_AT) + .build()); + + itemHistoryRepository.save(ItemHistory.builder() + .memberId(memberInfo.memberId()) + .itemType(reward.getItemType()) + .historyType(ItemHistoryType.EVENT) + .quantity(reward.getQuantity()) + .description(reward.getItemType().getName()) + .build()); + }); + + rouletteHistoryRepository.save(RouletteHistory.builder() + .memberId(memberInfo.memberId()) + .reward(rouletteReward) + .rouletteType(rouletteType) + .build()); + + return new RouletteSpinResponse(rouletteReward.getRewardName()); + } +} diff --git a/item-service/src/main/java/com/comatching/item/infra/controller/RouletteController.java b/item-service/src/main/java/com/comatching/item/infra/controller/RouletteController.java new file mode 100644 index 0000000..b8ba595 --- /dev/null +++ b/item-service/src/main/java/com/comatching/item/infra/controller/RouletteController.java @@ -0,0 +1,30 @@ +package com.comatching.item.infra.controller; + +import com.comatching.common.annotation.CurrentMember; +import com.comatching.common.dto.member.MemberInfo; +import com.comatching.common.dto.response.ApiResponse; +import com.comatching.item.domain.roulette.dto.RouletteSpinResponse; +import com.comatching.item.domain.roulette.enums.RouletteType; +import com.comatching.item.domain.roulette.service.RouletteService; +import lombok.AllArgsConstructor; +import org.springframework.http.ResponseEntity; +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; + +@RestController +@AllArgsConstructor +@RequestMapping("/api/roulette") +public class RouletteController { + private final RouletteService rouletteService; + + @PostMapping("/spins") + public ResponseEntity> spinRoulette( + @CurrentMember MemberInfo memberInfo, + @RequestParam RouletteType rouletteType + ) { + RouletteSpinResponse response = rouletteService.spinRoulette(memberInfo, rouletteType); + return ResponseEntity.ok(ApiResponse.ok(response)); + } +} diff --git a/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteRewardRepositoryTest.java b/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteRewardRepositoryTest.java new file mode 100644 index 0000000..70d7d41 --- /dev/null +++ b/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteRewardRepositoryTest.java @@ -0,0 +1,265 @@ +package com.comatching.item.domain.roulette.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; + +import java.sql.Timestamp; +import java.time.LocalDate; +import java.util.List; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; +import org.springframework.context.annotation.Import; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionTemplate; + +import com.comatching.common.domain.enums.ItemType; +import com.comatching.common.dto.member.MemberInfo; +import com.comatching.item.domain.item.entity.Item; +import com.comatching.item.domain.item.entity.ItemHistory; +import com.comatching.item.domain.item.repository.ItemHistoryRepository; +import com.comatching.item.domain.item.repository.ItemRepository; +import com.comatching.item.domain.roulette.entity.RouletteHistory; +import com.comatching.item.domain.roulette.entity.RouletteReward; +import com.comatching.item.domain.roulette.enums.RouletteType; +import com.comatching.item.domain.roulette.repository.RouletteHistoryRepository; +import com.comatching.item.domain.roulette.repository.RouletteRewardRepository; + +@DataJpaTest +@ContextConfiguration(classes = RouletteRewardRepositoryTest.JpaTestConfig.class) +@Import(RouletteServiceImpl.class) +@DisplayName("Roulette Repository 및 트랜잭션 테스트") +class RouletteRewardRepositoryTest { + + @Autowired + private RouletteRewardRepository rouletteRewardRepository; + + @Autowired + private TestEntityManager entityManager; + @Autowired + private RouletteHistoryRepository rouletteHistoryRepository; + @Autowired + private ItemRepository itemRepository; + @Autowired + private RouletteServiceImpl rouletteService; + @Autowired + private JdbcTemplate jdbcTemplate; + @Autowired + private PlatformTransactionManager transactionManager; + + @MockitoBean + private ItemHistoryRepository itemHistoryRepository; + + @Test + @DisplayName("난수 범위의 양 끝 1과 10000에 해당하는 보상을 조회한다") + void shouldFindRewardsAtBothBoundaries() { + RouletteReward first = persist(reward(RouletteType.FREE, "첫 보상", 1, 5000, null)); + RouletteReward last = persist(reward(RouletteType.FREE, "마지막 보상", 5001, 10000, null)); + flushAndClear(); + + List numberOne = rouletteRewardRepository + .findAvailableByRouletteTypeAndRouletteNumber(RouletteType.FREE, 1); + List numberTenThousand = rouletteRewardRepository + .findAvailableByRouletteTypeAndRouletteNumber(RouletteType.FREE, 10000); + + assertThat(numberOne).extracting(RouletteReward::getId).containsExactly(first.getId()); + assertThat(numberTenThousand).extracting(RouletteReward::getId).containsExactly(last.getId()); + } + + @Test + @DisplayName("동일 범위의 풀세트 두 행을 ID 오름차순으로 조회한다") + void shouldFindFullSetRewardsOrderedById() { + RouletteReward matchingTicket = persist(reward( + RouletteType.PAID, "풀세트", ItemType.MATCHING_TICKET, 8201, 9200, null)); + RouletteReward optionTicket = persist(reward( + RouletteType.PAID, "풀세트", ItemType.OPTION_TICKET, 8201, 9200, null)); + flushAndClear(); + + List rewards = rouletteRewardRepository + .findAvailableByRouletteTypeAndRouletteNumber(RouletteType.PAID, 8500); + + assertThat(rewards).extracting(RouletteReward::getId) + .containsExactly(matchingTicket.getId(), optionTicket.getId()); + } + + @Test + @DisplayName("재고가 소진된 제한 보상은 조회하지 않는다") + void shouldExcludeOutOfStockReward() { + persist(reward(RouletteType.PAID, "상품권", 9701, 9900, 0)); + flushAndClear(); + + List rewards = rouletteRewardRepository + .findAvailableByRouletteTypeAndRouletteNumber(RouletteType.PAID, 9800); + + assertThat(rewards).isEmpty(); + } + + @Test + @DisplayName("같은 난수 범위라도 요청한 룰렛 타입의 보상만 조회한다") + void shouldSeparateRewardsByRouletteType() { + RouletteReward freeReward = persist(reward(RouletteType.FREE, "무료 보상", 1, 10000, null)); + persist(reward(RouletteType.PAID, "유료 보상", 1, 10000, null)); + flushAndClear(); + + List rewards = rouletteRewardRepository + .findAvailableByRouletteTypeAndRouletteNumber(RouletteType.FREE, 5000); + + assertThat(rewards).extracting(RouletteReward::getId).containsExactly(freeReward.getId()); + } + + @Test + @DisplayName("오늘 같은 회원의 FREE 참여 이력이 있으면 true를 반환한다") + void shouldFindTodayFreeHistoryForSameMember() { + persistHistory(1L, RouletteType.FREE); + flushAndClear(); + + boolean exists = rouletteHistoryRepository + .existsTodayByMemberIdAndRouletteType(1L, RouletteType.FREE); + + assertThat(exists).isTrue(); + } + + @Test + @DisplayName("과거 FREE 참여 이력만 있으면 false를 반환한다") + void shouldNotFindPastFreeHistory() { + RouletteHistory history = persistHistory(1L, RouletteType.FREE); + entityManager.flush(); + jdbcTemplate.update( + "UPDATE roulette_history SET participated_at = ? WHERE id = ?", + Timestamp.valueOf(LocalDate.now().minusDays(1).atTime(12, 0)), + history.getId()); + entityManager.clear(); + + boolean exists = rouletteHistoryRepository + .existsTodayByMemberIdAndRouletteType(1L, RouletteType.FREE); + + assertThat(exists).isFalse(); + } + + @Test + @DisplayName("다른 회원의 오늘 FREE 참여 이력은 조회하지 않는다") + void shouldNotFindAnotherMembersFreeHistory() { + persistHistory(2L, RouletteType.FREE); + flushAndClear(); + + boolean exists = rouletteHistoryRepository + .existsTodayByMemberIdAndRouletteType(1L, RouletteType.FREE); + + assertThat(exists).isFalse(); + } + + @Test + @DisplayName("같은 회원의 오늘 PAID 참여 이력은 FREE 참여로 조회하지 않는다") + void shouldNotFindPaidHistoryAsFreeHistory() { + persistHistory(1L, RouletteType.PAID); + flushAndClear(); + + boolean exists = rouletteHistoryRepository + .existsTodayByMemberIdAndRouletteType(1L, RouletteType.FREE); + + assertThat(exists).isFalse(); + } + + @Test + @Transactional(propagation = Propagation.NOT_SUPPORTED) + @DirtiesContext(methodMode = DirtiesContext.MethodMode.AFTER_METHOD) + @DisplayName("아이템 이력 저장 실패 시 아이템과 룰렛 이력을 실제로 롤백한다") + void shouldRollbackSavedItemWhenItemHistorySaveFails() { + TransactionTemplate transactionTemplate = new TransactionTemplate(transactionManager); + transactionTemplate.executeWithoutResult(status -> rouletteRewardRepository.saveAndFlush( + reward(RouletteType.FREE, "옵션권 1장", ItemType.OPTION_TICKET, 1, 10000, 2))); + given(itemHistoryRepository.save(any(ItemHistory.class))) + .willThrow(new RuntimeException("item history save failed")); + + assertThatThrownBy(() -> rouletteService.spinRoulette( + new MemberInfo(1L, "member@example.com", "USER"), RouletteType.FREE)) + .isInstanceOf(RuntimeException.class) + .hasMessage("item history save failed"); + + assertThat(itemRepository.count()).isZero(); + assertThat(rouletteHistoryRepository.count()).isZero(); + assertThat(rouletteRewardRepository.findAll()) + .extracting(RouletteReward::getRemainingCount) + .containsExactly(2); + } + + private RouletteReward persist(RouletteReward reward) { + return entityManager.persist(reward); + } + + private void flushAndClear() { + entityManager.flush(); + entityManager.clear(); + } + + private RouletteHistory persistHistory(Long memberId, RouletteType rouletteType) { + RouletteReward historyReward = persist(reward( + RouletteType.FREE, "꽝", null, 1, 10000, 999999)); + return entityManager.persist(RouletteHistory.builder() + .memberId(memberId) + .reward(historyReward) + .rouletteType(rouletteType) + .build()); + } + + private RouletteReward reward( + RouletteType rouletteType, + String rewardName, + int rangeStart, + int rangeEnd, + Integer remainingCount + ) { + return reward(rouletteType, rewardName, null, rangeStart, rangeEnd, remainingCount); + } + + private RouletteReward reward( + RouletteType rouletteType, + String rewardName, + ItemType itemType, + int rangeStart, + int rangeEnd, + Integer remainingCount + ) { + return RouletteReward.builder() + .rouletteType(rouletteType) + .rewardName(rewardName) + .itemType(itemType) + .quantity(itemType == null ? 0 : 1) + .rangeStart(rangeStart) + .rangeEnd(rangeEnd) + .remainingCount(remainingCount) + .build(); + } + + @SpringBootConfiguration + @EnableAutoConfiguration + @EntityScan(basePackageClasses = { + RouletteReward.class, + RouletteHistory.class, + Item.class, + ItemHistory.class + }) + @EnableJpaRepositories(basePackageClasses = { + RouletteRewardRepository.class, + RouletteHistoryRepository.class, + ItemRepository.class, + ItemHistoryRepository.class + }) + static class JpaTestConfig { + } +} diff --git a/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteServiceImplTest.java b/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteServiceImplTest.java new file mode 100644 index 0000000..0e6d2eb --- /dev/null +++ b/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteServiceImplTest.java @@ -0,0 +1,346 @@ +package com.comatching.item.domain.roulette.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import com.comatching.common.domain.enums.ItemType; +import com.comatching.common.dto.member.MemberInfo; +import com.comatching.common.exception.BusinessException; +import com.comatching.item.domain.item.entity.Item; +import com.comatching.item.domain.item.entity.ItemHistory; +import com.comatching.item.domain.item.enums.ItemHistoryType; +import com.comatching.item.domain.item.repository.ItemHistoryRepository; +import com.comatching.item.domain.item.repository.ItemRepository; +import com.comatching.item.domain.roulette.dto.RouletteSpinResponse; +import com.comatching.item.domain.roulette.entity.RouletteHistory; +import com.comatching.item.domain.roulette.entity.RouletteReward; +import com.comatching.item.domain.roulette.enums.RouletteType; +import com.comatching.item.domain.roulette.repository.RouletteHistoryRepository; +import com.comatching.item.domain.roulette.repository.RouletteRewardRepository; +import com.comatching.item.global.exception.ItemErrorCode; + +@ExtendWith(MockitoExtension.class) +@DisplayName("RouletteServiceImpl 보상 처리 테스트") +class RouletteServiceImplTest { + + private static final MemberInfo MEMBER = new MemberInfo(1L, "member@example.com", "USER"); + private static final LocalDateTime REWARD_EXPIRED_AT = LocalDateTime.of(9999, 12, 31, 23, 59, 59); + + @Mock + private RouletteHistoryRepository rouletteHistoryRepository; + @Mock + private RouletteRewardRepository rouletteRewardRepository; + @Mock + private ItemRepository itemRepository; + @Mock + private ItemHistoryRepository itemHistoryRepository; + + @InjectMocks + private RouletteServiceImpl rouletteService; + + @Test + @DisplayName("일반 아이템 보상은 아이템과 아이템 이력을 저장하고 보상명을 반환한다") + void shouldGrantItemRewardAndReturnRewardName() { + RouletteReward reward = reward(RouletteType.FREE, "옵션권 2장", ItemType.OPTION_TICKET, 2, null); + givenRewards(RouletteType.FREE, List.of(reward)); + + RouletteSpinResponse response = rouletteService.spinRoulette(MEMBER, RouletteType.FREE); + + ArgumentCaptor itemCaptor = ArgumentCaptor.forClass(Item.class); + then(itemRepository).should().save(itemCaptor.capture()); + assertThat(itemCaptor.getValue().getMemberId()).isEqualTo(MEMBER.memberId()); + assertThat(itemCaptor.getValue().getItemType()).isEqualTo(ItemType.OPTION_TICKET); + assertThat(itemCaptor.getValue().getQuantity()).isEqualTo(2); + assertThat(itemCaptor.getValue().getExpiredAt()).isEqualTo(REWARD_EXPIRED_AT); + + ArgumentCaptor itemHistoryCaptor = ArgumentCaptor.forClass(ItemHistory.class); + then(itemHistoryRepository).should().save(itemHistoryCaptor.capture()); + assertThat(itemHistoryCaptor.getValue().getMemberId()).isEqualTo(MEMBER.memberId()); + assertThat(itemHistoryCaptor.getValue().getItemType()).isEqualTo(ItemType.OPTION_TICKET); + assertThat(itemHistoryCaptor.getValue().getHistoryType()).isEqualTo(ItemHistoryType.EVENT); + assertThat(itemHistoryCaptor.getValue().getQuantity()).isEqualTo(2); + assertThat(itemHistoryCaptor.getValue().getDescription()).isEqualTo(ItemType.OPTION_TICKET.getName()); + + assertHistorySavedWith(reward, RouletteType.FREE); + assertThat(response.rewardName()).isEqualTo("옵션권 2장"); + } + + @Test + @DisplayName("풀세트는 조회된 두 아이템을 모두 지급하고 대표 보상명을 반환한다") + void shouldGrantEveryFullSetReward() { + RouletteReward matchingTicket = reward( + RouletteType.PAID, "풀세트", ItemType.MATCHING_TICKET, 1, 2); + RouletteReward optionTicket = reward( + RouletteType.PAID, "풀세트", ItemType.OPTION_TICKET, 3, 4); + Item rouletteTicket = givenRewards(RouletteType.PAID, List.of(matchingTicket, optionTicket)); + + RouletteSpinResponse response = rouletteService.spinRoulette(MEMBER, RouletteType.PAID); + + assertThat(rouletteTicket.getQuantity()).isZero(); + assertThat(matchingTicket.getRemainingCount()).isEqualTo(1); + assertThat(optionTicket.getRemainingCount()).isEqualTo(3); + ArgumentCaptor itemCaptor = ArgumentCaptor.forClass(Item.class); + then(itemRepository).should(times(2)).save(itemCaptor.capture()); + assertThat(itemCaptor.getAllValues()) + .extracting(Item::getItemType, Item::getQuantity) + .containsExactly( + org.assertj.core.groups.Tuple.tuple(ItemType.MATCHING_TICKET, 1), + org.assertj.core.groups.Tuple.tuple(ItemType.OPTION_TICKET, 3) + ); + ArgumentCaptor itemHistoryCaptor = ArgumentCaptor.forClass(ItemHistory.class); + then(itemHistoryRepository).should(times(3)).save(itemHistoryCaptor.capture()); + assertThat(itemHistoryCaptor.getAllValues()) + .extracting(ItemHistory::getItemType, ItemHistory::getHistoryType, ItemHistory::getQuantity) + .containsExactly( + org.assertj.core.groups.Tuple.tuple(ItemType.ROULETTE_TICKET, ItemHistoryType.USE, -1), + org.assertj.core.groups.Tuple.tuple(ItemType.MATCHING_TICKET, ItemHistoryType.EVENT, 1), + org.assertj.core.groups.Tuple.tuple(ItemType.OPTION_TICKET, ItemHistoryType.EVENT, 3) + ); + assertHistorySavedWith(matchingTicket, RouletteType.PAID); + then(rouletteHistoryRepository).should(never()) + .existsTodayByMemberIdAndRouletteType(MEMBER.memberId(), RouletteType.PAID); + assertThat(response.rewardName()).isEqualTo("풀세트"); + } + + @Test + @DisplayName("상품권은 재고만 차감하고 룰렛 이력과 보상명을 남긴다") + void shouldRecordGiftCardWithoutGrantingItem() { + RouletteReward reward = reward(RouletteType.PAID, "1만원권 상품권", null, 1, 3); + givenRewards(RouletteType.PAID, List.of(reward)); + + RouletteSpinResponse response = rouletteService.spinRoulette(MEMBER, RouletteType.PAID); + + assertThat(reward.getRemainingCount()).isEqualTo(2); + then(itemRepository).should(never()).save(any(Item.class)); + ArgumentCaptor itemHistoryCaptor = ArgumentCaptor.forClass(ItemHistory.class); + then(itemHistoryRepository).should().save(itemHistoryCaptor.capture()); + assertThat(itemHistoryCaptor.getValue().getMemberId()).isEqualTo(MEMBER.memberId()); + assertThat(itemHistoryCaptor.getValue().getItemType()).isEqualTo(ItemType.ROULETTE_TICKET); + assertThat(itemHistoryCaptor.getValue().getHistoryType()).isEqualTo(ItemHistoryType.USE); + assertThat(itemHistoryCaptor.getValue().getQuantity()).isEqualTo(-1); + assertThat(itemHistoryCaptor.getValue().getDescription()).isEqualTo(ItemType.ROULETTE_TICKET.getName()); + assertHistorySavedWith(reward, RouletteType.PAID); + assertThat(response.rewardName()).isEqualTo("1만원권 상품권"); + } + + @Test + @DisplayName("꽝은 아이템을 지급하지 않고 룰렛 이력과 보상명을 남긴다") + void shouldRecordNoPrizeWithoutGrantingItem() { + RouletteReward reward = reward(RouletteType.FREE, "꽝", null, 0, null); + givenRewards(RouletteType.FREE, List.of(reward)); + + RouletteSpinResponse response = rouletteService.spinRoulette(MEMBER, RouletteType.FREE); + + then(itemRepository).should(never()).save(any(Item.class)); + then(itemHistoryRepository).shouldHaveNoInteractions(); + assertHistorySavedWith(reward, RouletteType.FREE); + assertThat(response.rewardName()).isEqualTo("꽝"); + } + + @Test + @DisplayName("이미 무료 룰렛에 참여한 회원은 예외가 발생하고 보상을 처리하지 않는다") + void shouldRejectDuplicatedFreeRouletteParticipation() { + given(rouletteHistoryRepository.existsTodayByMemberIdAndRouletteType( + MEMBER.memberId(), RouletteType.FREE)).willReturn(true); + + assertThatThrownBy(() -> rouletteService.spinRoulette(MEMBER, RouletteType.FREE)) + .isInstanceOf(BusinessException.class) + .satisfies(exception -> assertThat(((BusinessException)exception).getErrorCode()) + .isEqualTo(ItemErrorCode.ALREADY_PARTICIPATED_FREE_ROULETTE)); + + then(itemRepository).shouldHaveNoInteractions(); + then(rouletteRewardRepository).shouldHaveNoInteractions(); + then(itemHistoryRepository).shouldHaveNoInteractions(); + then(rouletteHistoryRepository).should(never()).save(any(RouletteHistory.class)); + } + + @Test + @DisplayName("룰렛 티켓이 없으면 예외가 발생하고 추첨하지 않는다") + void shouldRejectWhenRouletteTicketDoesNotExist() { + given(itemRepository + .findFirstByMemberIdAndItemTypeAndQuantityGreaterThanEqualAndExpiredAtGreaterThanOrderByExpiredAtAscQuantityAsc( + eq(MEMBER.memberId()), eq(ItemType.ROULETTE_TICKET), eq(1), any(LocalDateTime.class))) + .willReturn(Optional.empty()); + + assertThatThrownBy(() -> rouletteService.spinRoulette(MEMBER, RouletteType.PAID)) + .isInstanceOf(BusinessException.class) + .satisfies(exception -> assertThat(((BusinessException)exception).getErrorCode()) + .isEqualTo(ItemErrorCode.NOT_ENOUGH_ITEM)); + + then(rouletteRewardRepository).shouldHaveNoInteractions(); + then(itemHistoryRepository).shouldHaveNoInteractions(); + then(rouletteHistoryRepository).should(never()).save(any(RouletteHistory.class)); + } + + @Test + @DisplayName("선택된 범위의 보상이 비어 있으면 다시 추첨한다") + void shouldRetryWhenSelectedRewardIsEmpty() { + RouletteReward reward = reward(RouletteType.FREE, "꽝", null, 0, null); + given(rouletteHistoryRepository.existsTodayByMemberIdAndRouletteType( + MEMBER.memberId(), RouletteType.FREE)).willReturn(false); + given(rouletteRewardRepository + .findAvailableByRouletteTypeAndRouletteNumber(eq(RouletteType.FREE), anyInt())) + .willReturn(List.of(), List.of(reward)); + + RouletteSpinResponse response = rouletteService.spinRoulette(MEMBER, RouletteType.FREE); + + then(rouletteRewardRepository).should(times(2)) + .findAvailableByRouletteTypeAndRouletteNumber(eq(RouletteType.FREE), anyInt()); + assertHistorySavedWith(reward, RouletteType.FREE); + assertThat(response.rewardName()).isEqualTo("꽝"); + } + + @Test + @DisplayName("아이템 저장이 실패하면 아이템 이력과 룰렛 이력을 저장하지 않는다") + void shouldStopWhenItemSaveFails() { + RouletteReward reward = reward( + RouletteType.FREE, "옵션권 1장", ItemType.OPTION_TICKET, 1, null); + givenRewards(RouletteType.FREE, List.of(reward)); + given(itemRepository.save(any(Item.class))).willThrow(new RuntimeException("item save failed")); + + assertThatThrownBy(() -> rouletteService.spinRoulette(MEMBER, RouletteType.FREE)) + .isInstanceOf(RuntimeException.class) + .hasMessage("item save failed"); + + then(itemHistoryRepository).shouldHaveNoInteractions(); + then(rouletteHistoryRepository).should(never()).save(any(RouletteHistory.class)); + } + + @Test + @DisplayName("아이템 이력 저장이 실패하면 룰렛 이력을 저장하지 않는다") + void shouldStopWhenItemHistorySaveFails() { + RouletteReward reward = reward( + RouletteType.FREE, "옵션권 1장", ItemType.OPTION_TICKET, 1, null); + givenRewards(RouletteType.FREE, List.of(reward)); + given(itemHistoryRepository.save(any(ItemHistory.class))) + .willThrow(new RuntimeException("item history save failed")); + + assertThatThrownBy(() -> rouletteService.spinRoulette(MEMBER, RouletteType.FREE)) + .isInstanceOf(RuntimeException.class) + .hasMessage("item history save failed"); + + then(itemRepository).should().save(any(Item.class)); + then(rouletteHistoryRepository).should(never()).save(any(RouletteHistory.class)); + } + + @Test + @DisplayName("룰렛 이력 저장이 실패하면 예외를 그대로 전달한다") + void shouldPropagateRouletteHistorySaveFailure() { + RouletteReward reward = reward(RouletteType.FREE, "꽝", null, 0, null); + givenRewards(RouletteType.FREE, List.of(reward)); + given(rouletteHistoryRepository.save(any(RouletteHistory.class))) + .willThrow(new RuntimeException("roulette history save failed")); + + assertThatThrownBy(() -> rouletteService.spinRoulette(MEMBER, RouletteType.FREE)) + .isInstanceOf(RuntimeException.class) + .hasMessage("roulette history save failed"); + } + + @Test + @DisplayName("제한 재고는 한 개 차감한다") + void shouldDecreaseLimitedRemainingCount() { + RouletteReward reward = reward(RouletteType.PAID, "상품권", null, 1, 2); + + reward.decreaseRemainingCount(); + + assertThat(reward.getRemainingCount()).isEqualTo(1); + } + + @Test + @DisplayName("무제한 보상은 재고를 변경하지 않는다") + void shouldNotChangeUnlimitedRemainingCount() { + RouletteReward reward = reward(RouletteType.FREE, "꽝", null, 0, null); + + reward.decreaseRemainingCount(); + + assertThat(reward.getRemainingCount()).isNull(); + } + + @Test + @DisplayName("소진된 재고는 음수가 되지 않는다") + void shouldNotDecreaseRemainingCountBelowZero() { + RouletteReward reward = reward(RouletteType.PAID, "상품권", null, 1, 0); + + reward.decreaseRemainingCount(); + + assertThat(reward.getRemainingCount()).isZero(); + } + + private Item givenRewards(RouletteType rouletteType, List rewards) { + Item rouletteTicket = null; + if (rouletteType == RouletteType.PAID) { + rouletteTicket = givenTicket(); + } + if (rouletteType == RouletteType.FREE) { + given(rouletteHistoryRepository.existsTodayByMemberIdAndRouletteType(MEMBER.memberId(), rouletteType)) + .willReturn(false); + } + given(rouletteRewardRepository + .findAvailableByRouletteTypeAndRouletteNumber(eq(rouletteType), anyInt())) + .willReturn(rewards); + return rouletteTicket; + } + + private Item givenTicket() { + Item rouletteTicket = rouletteTicket(); + given(itemRepository + .findFirstByMemberIdAndItemTypeAndQuantityGreaterThanEqualAndExpiredAtGreaterThanOrderByExpiredAtAscQuantityAsc( + eq(MEMBER.memberId()), eq(ItemType.ROULETTE_TICKET), eq(1), any(LocalDateTime.class))) + .willReturn(Optional.of(rouletteTicket)); + return rouletteTicket; + } + + private void assertHistorySavedWith(RouletteReward reward, RouletteType rouletteType) { + ArgumentCaptor historyCaptor = ArgumentCaptor.forClass(RouletteHistory.class); + then(rouletteHistoryRepository).should().save(historyCaptor.capture()); + assertThat(historyCaptor.getValue().getMemberId()).isEqualTo(MEMBER.memberId()); + assertThat(historyCaptor.getValue().getReward()).isSameAs(reward); + assertThat(historyCaptor.getValue().getRouletteType()).isEqualTo(rouletteType); + } + + private RouletteReward reward( + RouletteType rouletteType, + String rewardName, + ItemType itemType, + int quantity, + Integer remainingCount + ) { + return RouletteReward.builder() + .rouletteType(rouletteType) + .rewardName(rewardName) + .itemType(itemType) + .quantity(quantity) + .rangeStart(1) + .rangeEnd(1) + .remainingCount(remainingCount) + .build(); + } + + private Item rouletteTicket() { + return Item.builder() + .memberId(MEMBER.memberId()) + .itemType(ItemType.ROULETTE_TICKET) + .quantity(1) + .expiredAt(LocalDateTime.now().plusDays(1)) + .build(); + } +} diff --git a/item-service/src/test/java/com/comatching/item/infra/controller/RouletteControllerTest.java b/item-service/src/test/java/com/comatching/item/infra/controller/RouletteControllerTest.java new file mode 100644 index 0000000..b284f89 --- /dev/null +++ b/item-service/src/test/java/com/comatching/item/infra/controller/RouletteControllerTest.java @@ -0,0 +1,85 @@ +package com.comatching.item.infra.controller; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import com.comatching.common.dto.member.MemberInfo; +import com.comatching.common.exception.code.GeneralErrorCode; +import com.comatching.common.exception.handler.GlobalExceptionHandler; +import com.comatching.common.resolver.MemberInfoArgumentResolver; +import com.comatching.item.domain.roulette.dto.RouletteSpinResponse; +import com.comatching.item.domain.roulette.enums.RouletteType; +import com.comatching.item.domain.roulette.service.RouletteService; +import com.fasterxml.jackson.databind.ObjectMapper; + +@ExtendWith(MockitoExtension.class) +@DisplayName("RouletteController API 계약 테스트") +class RouletteControllerTest { + + private MockMvc mockMvc; + + @Mock + private RouletteService rouletteService; + + @InjectMocks + private RouletteController rouletteController; + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders.standaloneSetup(rouletteController) + .setCustomArgumentResolvers(new MemberInfoArgumentResolver()) + .setControllerAdvice(new GlobalExceptionHandler(new ObjectMapper())) + .build(); + } + + @Test + @DisplayName("POST /api/roulette/spins는 회원과 룰렛 타입을 전달하고 보상명을 반환한다") + void shouldSpinRouletteAndReturnRewardName() throws Exception { + given(rouletteService.spinRoulette(any(MemberInfo.class), eq(RouletteType.PAID))) + .willReturn(new RouletteSpinResponse("2만원권 상품권")); + + mockMvc.perform(post("/api/roulette/spins") + .param("rouletteType", "PAID") + .header("X-Member-Id", "1") + .header("X-Member-Email", "member@example.com") + .header("X-Member-Role", "USER")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("GEN-000")) + .andExpect(jsonPath("$.status").value(200)) + .andExpect(jsonPath("$.data.rewardName").value("2만원권 상품권")); + + ArgumentCaptor memberCaptor = ArgumentCaptor.forClass(MemberInfo.class); + then(rouletteService).should().spinRoulette(memberCaptor.capture(), eq(RouletteType.PAID)); + assertThat(memberCaptor.getValue().memberId()).isEqualTo(1L); + assertThat(memberCaptor.getValue().email()).isEqualTo("member@example.com"); + assertThat(memberCaptor.getValue().role()).isEqualTo("USER"); + } + + @Test + @DisplayName("rouletteType 요청 파라미터가 없으면 400을 반환한다") + void shouldRejectMissingRouletteType() throws Exception { + mockMvc.perform(post("/api/roulette/spins") + .header("X-Member-Id", "1")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(GeneralErrorCode.MISSING_REQUEST_PARAMETER.getCode())); + + then(rouletteService).shouldHaveNoInteractions(); + } +} From 360285e36f6cf0049d8b3fa82e01f8772a0a0be5 Mon Sep 17 00:00:00 2001 From: choi-jin-wook Date: Fri, 28 Aug 2026 23:41:08 +0900 Subject: [PATCH 10/22] =?UTF-8?q?fix(item):=20=EB=A3=B0=EB=A0=9B=20?= =?UTF-8?q?=EB=8F=99=EC=8B=9C=20=EC=9A=94=EC=B2=AD=EC=9D=98=20=EC=B0=B8?= =?UTF-8?q?=EC=97=AC=20=EB=B0=8F=20=EC=9E=AC=EA=B3=A0=20=EC=A0=95=ED=95=A9?= =?UTF-8?q?=EC=84=B1=20=EB=B3=B4=EC=9E=A5=20=20=20=20=20-=20=EC=9C=A0?= =?UTF-8?q?=EB=A3=8C=20=EB=A3=B0=EB=A0=9B=20=ED=8B=B0=EC=BC=93=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=EC=97=90=20=EB=B9=84=EA=B4=80=EC=A0=81=20=EB=9D=BD=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9=20=20=20=20=20-=20=ED=95=9C=EC=A0=95=20?= =?UTF-8?q?=EB=B3=B4=EC=83=81=20=EC=9E=AC=EA=B3=A0=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=EC=97=90=20=EB=B9=84=EA=B4=80=EC=A0=81=20=EB=9D=BD=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9=20=20=20=20=20-=20=EB=AC=B4=EB=A3=8C=20=EB=A3=B0?= =?UTF-8?q?=EB=A0=9B=20=EC=B0=B8=EC=97=AC=20=EC=9D=B4=EB=A0=A5=EC=97=90=20?= =?UTF-8?q?=ED=9A=8C=EC=9B=90=C2=B7=EC=9C=A0=ED=98=95=C2=B7=EB=82=A0?= =?UTF-8?q?=EC=A7=9C=20=EC=9C=A0=EC=9D=BC=20=EC=A0=9C=EC=95=BD=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=20=20=20=20-=20=EB=8F=99=EC=8B=9C=20=EC=A4=91?= =?UTF-8?q?=EB=B3=B5=20=EC=B0=B8=EC=97=AC=EB=A5=BC=20ITEM-007=20=EC=98=88?= =?UTF-8?q?=EC=99=B8=EB=A1=9C=20=EB=B3=80=ED=99=98=20=20=20=20=20-=20?= =?UTF-8?q?=EB=AC=B4=EB=A3=8C=C2=B7=EC=9C=A0=EB=A3=8C=20=EB=A3=B0=EB=A0=9B?= =?UTF-8?q?=20=EB=B0=8F=20=ED=95=9C=EC=A0=95=20=EB=B3=B4=EC=83=81=20?= =?UTF-8?q?=EB=8F=99=EC=8B=9C=EC=84=B1=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../item/repository/ItemRepository.java | 1 + .../roulette/entity/RouletteHistory.java | 16 +- .../repository/RouletteRewardRepository.java | 3 + .../roulette/service/RouletteServiceImpl.java | 19 +- .../service/RouletteConcurrencyTest.java | 236 ++++++++++++++++++ 5 files changed, 269 insertions(+), 6 deletions(-) create mode 100644 item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteConcurrencyTest.java diff --git a/item-service/src/main/java/com/comatching/item/domain/item/repository/ItemRepository.java b/item-service/src/main/java/com/comatching/item/domain/item/repository/ItemRepository.java index d6fcbfc..54b9074 100644 --- a/item-service/src/main/java/com/comatching/item/domain/item/repository/ItemRepository.java +++ b/item-service/src/main/java/com/comatching/item/domain/item/repository/ItemRepository.java @@ -30,6 +30,7 @@ List findAllUsableItems( @Param("memberId") Long memberId, @Param("itemType") ItemType itemType); + @Lock(LockModeType.PESSIMISTIC_WRITE) Optional findFirstByMemberIdAndItemTypeAndQuantityGreaterThanEqualAndExpiredAtGreaterThanOrderByExpiredAtAscQuantityAsc( Long memberId, ItemType itemType, diff --git a/item-service/src/main/java/com/comatching/item/domain/roulette/entity/RouletteHistory.java b/item-service/src/main/java/com/comatching/item/domain/roulette/entity/RouletteHistory.java index 281f059..9cb64c9 100644 --- a/item-service/src/main/java/com/comatching/item/domain/roulette/entity/RouletteHistory.java +++ b/item-service/src/main/java/com/comatching/item/domain/roulette/entity/RouletteHistory.java @@ -1,5 +1,6 @@ package com.comatching.item.domain.roulette.entity; +import java.time.LocalDate; import java.time.LocalDateTime; import com.comatching.item.domain.roulette.enums.RouletteType; @@ -14,6 +15,8 @@ import jakarta.persistence.Id; import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; import lombok.AccessLevel; import lombok.Builder; import lombok.Getter; @@ -22,6 +25,13 @@ @Entity @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table( + name = "roulette_history", + uniqueConstraints = @UniqueConstraint( + name = "uk_roulette_history_member_type_date", + columnNames = {"member_id", "roulette_type", "participation_date"} + ) +) public class RouletteHistory { @Id @@ -41,6 +51,9 @@ public class RouletteHistory { private final LocalDateTime participatedAt = LocalDateTime.now(); + @Column(name = "participation_date") + private LocalDate participationDate; + @Builder public RouletteHistory( Long memberId, @@ -50,5 +63,6 @@ public RouletteHistory( this.memberId = memberId; this.reward = reward; this.rouletteType = rouletteType; + this.participationDate = rouletteType == RouletteType.FREE ? LocalDate.now() : null; } -} \ No newline at end of file +} diff --git a/item-service/src/main/java/com/comatching/item/domain/roulette/repository/RouletteRewardRepository.java b/item-service/src/main/java/com/comatching/item/domain/roulette/repository/RouletteRewardRepository.java index a63b718..31ddf96 100644 --- a/item-service/src/main/java/com/comatching/item/domain/roulette/repository/RouletteRewardRepository.java +++ b/item-service/src/main/java/com/comatching/item/domain/roulette/repository/RouletteRewardRepository.java @@ -4,13 +4,16 @@ import com.comatching.item.domain.roulette.entity.RouletteReward; import com.comatching.item.domain.roulette.enums.RouletteType; +import jakarta.persistence.LockModeType; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; public interface RouletteRewardRepository extends JpaRepository { List findAllByRouletteType(RouletteType rouletteType); + @Lock(LockModeType.PESSIMISTIC_WRITE) @Query(""" SELECT rr FROM RouletteReward rr diff --git a/item-service/src/main/java/com/comatching/item/domain/roulette/service/RouletteServiceImpl.java b/item-service/src/main/java/com/comatching/item/domain/roulette/service/RouletteServiceImpl.java index 9608d98..2bd9b9b 100644 --- a/item-service/src/main/java/com/comatching/item/domain/roulette/service/RouletteServiceImpl.java +++ b/item-service/src/main/java/com/comatching/item/domain/roulette/service/RouletteServiceImpl.java @@ -20,6 +20,7 @@ import com.comatching.item.domain.roulette.repository.RouletteRewardRepository; import com.comatching.item.global.exception.ItemErrorCode; import lombok.AllArgsConstructor; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -93,11 +94,19 @@ public RouletteSpinResponse spinRoulette(MemberInfo memberInfo, RouletteType rou .build()); }); - rouletteHistoryRepository.save(RouletteHistory.builder() - .memberId(memberInfo.memberId()) - .reward(rouletteReward) - .rouletteType(rouletteType) - .build()); + try { + rouletteHistoryRepository.save(RouletteHistory.builder() + .memberId(memberInfo.memberId()) + .reward(rouletteReward) + .rouletteType(rouletteType) + .build()); + rouletteHistoryRepository.flush(); + } catch (DataIntegrityViolationException exception) { + if (rouletteType == RouletteType.FREE) { + throw new BusinessException(ItemErrorCode.ALREADY_PARTICIPATED_FREE_ROULETTE); + } + throw exception; + } return new RouletteSpinResponse(rouletteReward.getRewardName()); } diff --git a/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteConcurrencyTest.java b/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteConcurrencyTest.java new file mode 100644 index 0000000..b6d45fc --- /dev/null +++ b/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteConcurrencyTest.java @@ -0,0 +1,236 @@ +package com.comatching.item.domain.roulette.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.transaction.PlatformTransactionManager; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionTemplate; + +import com.comatching.common.domain.enums.ItemType; +import com.comatching.common.dto.member.MemberInfo; +import com.comatching.common.exception.BusinessException; +import com.comatching.item.domain.item.entity.Item; +import com.comatching.item.domain.item.entity.ItemHistory; +import com.comatching.item.domain.item.repository.ItemHistoryRepository; +import com.comatching.item.domain.item.repository.ItemRepository; +import com.comatching.item.domain.roulette.entity.RouletteHistory; +import com.comatching.item.domain.roulette.entity.RouletteReward; +import com.comatching.item.domain.roulette.enums.RouletteType; +import com.comatching.item.domain.roulette.repository.RouletteHistoryRepository; +import com.comatching.item.domain.roulette.repository.RouletteRewardRepository; +import com.comatching.item.global.exception.ItemErrorCode; + +@DataJpaTest +@ContextConfiguration(classes = RouletteConcurrencyTest.JpaTestConfig.class) +@Import(RouletteServiceImpl.class) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD) +@DisplayName("룰렛 동시성 테스트") +class RouletteConcurrencyTest { + + private static final int REQUEST_COUNT = 20; + private static final LocalDateTime NOT_EXPIRED = LocalDateTime.of(9999, 12, 31, 23, 59, 59); + + @Autowired + private RouletteService rouletteService; + @Autowired + private RouletteRewardRepository rouletteRewardRepository; + @Autowired + private ItemRepository itemRepository; + @Autowired + private JdbcTemplate jdbcTemplate; + @Autowired + private PlatformTransactionManager transactionManager; + + @Test + @Transactional(propagation = Propagation.NOT_SUPPORTED) + @DisplayName("동시에 추첨해도 한정 보상은 준비된 재고만큼만 지급된다") + void limitedRewardIsNotGrantedBeyondItsStock() throws Exception { + int stock = 5; + Long limitedRewardId = inTransaction(() -> rouletteRewardRepository.save( + reward(RouletteType.FREE, "한정 보상", 1, 10000, stock)).getId()); + inTransaction(() -> rouletteRewardRepository.save( + reward(RouletteType.FREE, "기본 보상", 1, 10000, null))); + + List failures = runConcurrently(REQUEST_COUNT, index -> + rouletteService.spinRoulette(member(index + 1L), RouletteType.FREE)); + + assertThat(failures).isEmpty(); + assertThat(rouletteRewardRepository.findById(limitedRewardId).orElseThrow().getRemainingCount()) + .isZero(); + assertThat(count("SELECT COUNT(*) FROM roulette_history WHERE reward_id = ?", limitedRewardId)) + .isEqualTo(stock); + assertThat(count("SELECT COUNT(*) FROM roulette_history")) + .isEqualTo(REQUEST_COUNT); + } + + @Test + @Transactional(propagation = Propagation.NOT_SUPPORTED) + @DisplayName("동일 회원의 무료 룰렛 동시 요청은 하루에 한 번만 성공한다") + void freeRouletteSucceedsOnlyOnceForSameMember() throws Exception { + Long memberId = 50L; + inTransaction(() -> rouletteRewardRepository.save( + reward(RouletteType.FREE, "기본 보상", 1, 10000, null))); + + List failures = runConcurrently(REQUEST_COUNT, index -> + rouletteService.spinRoulette(member(memberId), RouletteType.FREE)); + + assertThat(failures).hasSize(REQUEST_COUNT - 1) + .allSatisfy(failure -> { + assertThat(failure).isInstanceOf(BusinessException.class); + assertThat(((BusinessException)failure).getErrorCode()) + .isEqualTo(ItemErrorCode.ALREADY_PARTICIPATED_FREE_ROULETTE); + }); + assertThat(count("SELECT COUNT(*) FROM roulette_history WHERE member_id = ?", memberId)) + .isOne(); + } + + @Test + @Transactional(propagation = Propagation.NOT_SUPPORTED) + @DisplayName("동일 회원의 유료 룰렛 동시 요청은 보유 티켓 수만큼만 성공한다") + void paidRouletteSucceedsOnlyAsManyTimesAsAvailableTickets() throws Exception { + int ticketCount = 5; + Long memberId = 100L; + Long ticketId = inTransaction(() -> itemRepository.save(Item.builder() + .memberId(memberId) + .itemType(ItemType.ROULETTE_TICKET) + .quantity(ticketCount) + .expiredAt(NOT_EXPIRED) + .build()).getId()); + inTransaction(() -> rouletteRewardRepository.save( + reward(RouletteType.PAID, "기본 보상", 1, 10000, null))); + + List failures = runConcurrently(REQUEST_COUNT, index -> + rouletteService.spinRoulette(member(memberId), RouletteType.PAID)); + + assertThat(failures).hasSize(REQUEST_COUNT - ticketCount) + .allSatisfy(failure -> { + assertThat(failure).isInstanceOf(BusinessException.class); + assertThat(((BusinessException)failure).getErrorCode()).isEqualTo(ItemErrorCode.NOT_ENOUGH_ITEM); + }); + assertThat(itemRepository.findById(ticketId).orElseThrow().getQuantity()).isZero(); + assertThat(count("SELECT COUNT(*) FROM item_history WHERE member_id = ?", memberId)) + .isEqualTo(ticketCount); + assertThat(count("SELECT COUNT(*) FROM roulette_history WHERE member_id = ?", memberId)) + .isEqualTo(ticketCount); + } + + private List runConcurrently(int threadCount, ThrowingIntConsumer task) throws Exception { + ExecutorService executor = Executors.newFixedThreadPool(threadCount); + CountDownLatch ready = new CountDownLatch(threadCount); + CountDownLatch start = new CountDownLatch(1); + List> futures = new ArrayList<>(); + + try { + for (int index = 0; index < threadCount; index++) { + int taskIndex = index; + futures.add(executor.submit(() -> { + ready.countDown(); + if (!start.await(10, TimeUnit.SECONDS)) { + return new AssertionError("동시 실행 시작 신호를 기다리다 시간 초과"); + } + try { + task.accept(taskIndex); + return null; + } catch (Throwable throwable) { + return throwable; + } + })); + } + + assertThat(ready.await(10, TimeUnit.SECONDS)).isTrue(); + start.countDown(); + + List failures = new ArrayList<>(); + for (Future future : futures) { + Throwable failure = future.get(30, TimeUnit.SECONDS); + if (failure != null) { + failures.add(failure); + } + } + return failures; + } finally { + start.countDown(); + executor.shutdownNow(); + } + } + + private RouletteReward reward( + RouletteType rouletteType, + String rewardName, + int rangeStart, + int rangeEnd, + Integer remainingCount + ) { + return RouletteReward.builder() + .rouletteType(rouletteType) + .rewardName(rewardName) + .quantity(0) + .rangeStart(rangeStart) + .rangeEnd(rangeEnd) + .remainingCount(remainingCount) + .build(); + } + + private MemberInfo member(Long memberId) { + return new MemberInfo(memberId, "member" + memberId + "@example.com", "USER"); + } + + private long count(String sql, Object... args) { + Long result = jdbcTemplate.queryForObject(sql, Long.class, args); + return result == null ? 0L : result; + } + + private T inTransaction(java.util.concurrent.Callable callback) { + return new TransactionTemplate(transactionManager).execute(status -> { + try { + return callback.call(); + } catch (Exception exception) { + throw new IllegalStateException(exception); + } + }); + } + + @FunctionalInterface + private interface ThrowingIntConsumer { + void accept(int value) throws Exception; + } + + @SpringBootConfiguration + @EnableAutoConfiguration + @EntityScan(basePackageClasses = { + RouletteReward.class, + RouletteHistory.class, + Item.class, + ItemHistory.class + }) + @EnableJpaRepositories(basePackageClasses = { + RouletteRewardRepository.class, + RouletteHistoryRepository.class, + ItemRepository.class, + ItemHistoryRepository.class + }) + static class JpaTestConfig { + } +} From 357db62fab007e36884d17c990716f037d6bc3c7 Mon Sep 17 00:00:00 2001 From: choi-jin-wook Date: Mon, 31 Aug 2026 02:42:55 +0900 Subject: [PATCH 11/22] =?UTF-8?q?refactor(item):=20=EB=A3=B0=EB=A0=9B=20?= =?UTF-8?q?=EB=B3=B4=EC=83=81=20=EB=B0=8F=20=EC=B0=B8=EC=97=AC=20=EC=9D=B4?= =?UTF-8?q?=EB=A0=A5=20=EC=B2=98=EB=A6=AC=20=EB=8B=A8=EC=88=9C=ED=99=94=20?= =?UTF-8?q?=20=20=20=20-=20=EB=A3=B0=EB=A0=9B=20=EB=B3=B4=EC=83=81?= =?UTF-8?q?=EC=9D=84=20=EB=8B=A8=EC=9D=BC=20=ED=96=89=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=ED=95=98=EB=8F=84=EB=A1=9D=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20=20=20=20=20-=20=ED=92=80=EC=84=B8=ED=8A=B8=20?= =?UTF-8?q?=ED=95=9C=20=ED=96=89=EC=9C=BC=EB=A1=9C=20=EC=98=B5=EC=85=98?= =?UTF-8?q?=EA=B6=8C=EA=B3=BC=20=EB=A7=A4=EC=B9=AD=EA=B6=8C=EC=9D=84=20?= =?UTF-8?q?=ED=95=A8=EA=BB=98=20=EC=A7=80=EA=B8=89=20=20=20=20=20-=20?= =?UTF-8?q?=EC=A7=80=EA=B8=89=20=EC=95=84=EC=9D=B4=ED=85=9C=EB=B3=84=20EVE?= =?UTF-8?q?NT=20=EC=9D=B4=EB=A0=A5=20=EC=A0=80=EC=9E=A5=20=20=20=20=20-=20?= =?UTF-8?q?=EB=AC=B4=EB=A3=8C=20=EB=A3=B0=EB=A0=9B=20=EC=B0=B8=EC=97=AC=20?= =?UTF-8?q?=EB=82=A0=EC=A7=9C=20=EC=A0=80=EC=9E=A5=20=EB=B0=8F=20=EC=9D=BC?= =?UTF-8?q?=EC=9D=BC=20=EC=9C=A0=EC=9D=BC=20=EC=A0=9C=EC=95=BD=20=EC=A0=81?= =?UTF-8?q?=EC=9A=A9=20=20=20=20=20-=20=EB=B0=98=EB=B3=B5=20=EC=BD=94?= =?UTF-8?q?=EB=93=9C=EC=9D=98=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EB=B6=84?= =?UTF-8?q?=EB=A6=AC=EB=A5=BC=20=EC=9C=84=ED=95=9C=20TODO=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80=20=20=20=20=20-=20=EB=8B=B9=EC=9D=BC=20=EC=B0=B8?= =?UTF-8?q?=EC=97=AC=20=EA=B2=80=EC=A6=9D=EA=B3=BC=20=EB=B3=B4=EC=83=81=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../roulette/entity/RouletteHistory.java | 18 ++- .../repository/RouletteHistoryRepository.java | 20 ++-- .../repository/RouletteRewardRepository.java | 4 +- .../roulette/service/RouletteServiceImpl.java | 103 +++++++++++------- .../service/RouletteRewardRepositoryTest.java | 53 ++++----- .../service/RouletteServiceImplTest.java | 58 +++++----- 6 files changed, 137 insertions(+), 119 deletions(-) diff --git a/item-service/src/main/java/com/comatching/item/domain/roulette/entity/RouletteHistory.java b/item-service/src/main/java/com/comatching/item/domain/roulette/entity/RouletteHistory.java index 9cb64c9..bb1f2f7 100644 --- a/item-service/src/main/java/com/comatching/item/domain/roulette/entity/RouletteHistory.java +++ b/item-service/src/main/java/com/comatching/item/domain/roulette/entity/RouletteHistory.java @@ -16,7 +16,6 @@ import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; -import jakarta.persistence.UniqueConstraint; import lombok.AccessLevel; import lombok.Builder; import lombok.Getter; @@ -25,20 +24,14 @@ @Entity @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) -@Table( - name = "roulette_history", - uniqueConstraints = @UniqueConstraint( - name = "uk_roulette_history_member_type_date", - columnNames = {"member_id", "roulette_type", "participation_date"} - ) -) +@Table(name = "roulette_history") public class RouletteHistory { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - @Column(nullable = false) + @Column(name = "member_id", nullable = false) private Long memberId; @ManyToOne(fetch = FetchType.LAZY) @@ -46,9 +39,10 @@ public class RouletteHistory { private RouletteReward reward; @Enumerated(EnumType.STRING) - @Column(nullable = false) + @Column(name = "roulette_type", nullable = false) private RouletteType rouletteType; + @Column(name = "participated_at",nullable = false) private final LocalDateTime participatedAt = LocalDateTime.now(); @Column(name = "participation_date") @@ -63,6 +57,8 @@ public RouletteHistory( this.memberId = memberId; this.reward = reward; this.rouletteType = rouletteType; - this.participationDate = rouletteType == RouletteType.FREE ? LocalDate.now() : null; + this.participationDate = rouletteType == RouletteType.FREE + ? participatedAt.toLocalDate() + : null; } } diff --git a/item-service/src/main/java/com/comatching/item/domain/roulette/repository/RouletteHistoryRepository.java b/item-service/src/main/java/com/comatching/item/domain/roulette/repository/RouletteHistoryRepository.java index 8571973..ef20b79 100644 --- a/item-service/src/main/java/com/comatching/item/domain/roulette/repository/RouletteHistoryRepository.java +++ b/item-service/src/main/java/com/comatching/item/domain/roulette/repository/RouletteHistoryRepository.java @@ -1,20 +1,16 @@ package com.comatching.item.domain.roulette.repository; +import java.time.LocalDateTime; + import com.comatching.item.domain.roulette.entity.RouletteHistory; import com.comatching.item.domain.roulette.enums.RouletteType; import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.data.jpa.repository.Query; -import org.springframework.data.repository.query.Param; public interface RouletteHistoryRepository extends JpaRepository { - @Query(""" - SELECT CASE WHEN COUNT(rh) > 0 THEN true ELSE false END - FROM RouletteHistory rh - WHERE rh.memberId = :memberId - AND rh.rouletteType = :rouletteType - AND rh.participatedAt >= CURRENT_DATE - """) - boolean existsTodayByMemberIdAndRouletteType( - @Param("memberId") Long memberId, - @Param("rouletteType") RouletteType rouletteType); + boolean existsByMemberIdAndRouletteTypeAndParticipatedAtGreaterThanEqualAndParticipatedAtLessThan( + Long memberId, + RouletteType rouletteType, + LocalDateTime startAt, + LocalDateTime endAt + ); } diff --git a/item-service/src/main/java/com/comatching/item/domain/roulette/repository/RouletteRewardRepository.java b/item-service/src/main/java/com/comatching/item/domain/roulette/repository/RouletteRewardRepository.java index 31ddf96..9909a0b 100644 --- a/item-service/src/main/java/com/comatching/item/domain/roulette/repository/RouletteRewardRepository.java +++ b/item-service/src/main/java/com/comatching/item/domain/roulette/repository/RouletteRewardRepository.java @@ -1,6 +1,7 @@ package com.comatching.item.domain.roulette.repository; import java.util.List; +import java.util.Optional; import com.comatching.item.domain.roulette.entity.RouletteReward; import com.comatching.item.domain.roulette.enums.RouletteType; @@ -11,7 +12,6 @@ import org.springframework.data.repository.query.Param; public interface RouletteRewardRepository extends JpaRepository { - List findAllByRouletteType(RouletteType rouletteType); @Lock(LockModeType.PESSIMISTIC_WRITE) @Query(""" @@ -23,7 +23,7 @@ public interface RouletteRewardRepository extends JpaRepository 0) ORDER BY rr.id ASC """) - List findAvailableByRouletteTypeAndRouletteNumber( + Optional findAvailableByRouletteTypeAndRouletteNumber( @Param("rouletteType") RouletteType rouletteType, @Param("rouletteNumber") int rouletteNumber); } diff --git a/item-service/src/main/java/com/comatching/item/domain/roulette/service/RouletteServiceImpl.java b/item-service/src/main/java/com/comatching/item/domain/roulette/service/RouletteServiceImpl.java index 2bd9b9b..18ebb51 100644 --- a/item-service/src/main/java/com/comatching/item/domain/roulette/service/RouletteServiceImpl.java +++ b/item-service/src/main/java/com/comatching/item/domain/roulette/service/RouletteServiceImpl.java @@ -1,7 +1,8 @@ package com.comatching.item.domain.roulette.service; +import java.time.LocalDate; import java.time.LocalDateTime; -import java.util.List; +import java.util.Optional; import java.util.concurrent.ThreadLocalRandom; import com.comatching.common.domain.enums.ItemType; @@ -20,15 +21,12 @@ import com.comatching.item.domain.roulette.repository.RouletteRewardRepository; import com.comatching.item.global.exception.ItemErrorCode; import lombok.AllArgsConstructor; -import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @Service @AllArgsConstructor public class RouletteServiceImpl implements RouletteService { - private static final LocalDateTime ROULETTE_REWARD_EXPIRED_AT = - LocalDateTime.of(9999, 12, 31, 23, 59, 59); private final RouletteHistoryRepository rouletteHistoryRepository; private final RouletteRewardRepository rouletteRewardRepository; @@ -38,12 +36,19 @@ public class RouletteServiceImpl implements RouletteService { @Override @Transactional public RouletteSpinResponse spinRoulette(MemberInfo memberInfo, RouletteType rouletteType) { + // TODO: 반복적인 코드 메서드로 따로 빼기 + // 타입이 무료인 경우 참여여부 검증 + 예외처리 + LocalDateTime todayStart = LocalDate.now().atStartOfDay(); + LocalDateTime tomorrowStart = todayStart.plusDays(1); + if (rouletteType == RouletteType.FREE && rouletteHistoryRepository - .existsTodayByMemberIdAndRouletteType(memberInfo.memberId(), rouletteType)) { + .existsByMemberIdAndRouletteTypeAndParticipatedAtGreaterThanEqualAndParticipatedAtLessThan( + memberInfo.memberId(), rouletteType, todayStart, tomorrowStart)) { throw new BusinessException(ItemErrorCode.ALREADY_PARTICIPATED_FREE_ROULETTE); } + // 유료 룰렛의 경우 티켓 감소 if (rouletteType == RouletteType.PAID) { Item rouletteTicket = itemRepository .findFirstByMemberIdAndItemTypeAndQuantityGreaterThanEqualAndExpiredAtGreaterThanOrderByExpiredAtAscQuantityAsc( @@ -56,58 +61,80 @@ public RouletteSpinResponse spinRoulette(MemberInfo memberInfo, RouletteType rou .memberId(memberInfo.memberId()) .itemType(ItemType.ROULETTE_TICKET) .historyType(ItemHistoryType.USE) - .quantity(-1) + .quantity(-1) // .description(ItemType.ROULETTE_TICKET.getName()) .build()); + + } + Optional rouletteReward; // 1 ~ 10000 사이의 난수 생성한 후에 db에 확률 range에 맞게 보상 가져오기 (만약 보상의 남은 갯수가 없다면 다시 난수 생성해서 추첨) - List rouletteRewards; do { int rouletteNumber = ThreadLocalRandom.current().nextInt(1, 10_001); - rouletteRewards = rouletteRewardRepository + rouletteReward = rouletteRewardRepository .findAvailableByRouletteTypeAndRouletteNumber(rouletteType, rouletteNumber); - } while (rouletteRewards.isEmpty()); + } while (rouletteReward.isEmpty()); - RouletteReward rouletteReward = rouletteRewards.get(0); - // 보상에 따른 아이템 및 아이템 기록 룰렛 기록 추가 - rouletteRewards.stream() - .forEach(RouletteReward::decreaseRemainingCount); // 남은 아이템 수 줄이기 - rouletteRewards.stream() - .filter(reward -> reward.getItemType() != null) - .forEach(reward -> { - itemRepository.save(Item.builder() + // 보상에 따른 아이템 및 아이템 기록 룰렛 기록 추가 + if (rouletteReward.get().getRewardName().equals("풀세트")) { + itemRepository.save(Item.builder() .memberId(memberInfo.memberId()) - .itemType(reward.getItemType()) - .quantity(reward.getQuantity()) - .expiredAt(ROULETTE_REWARD_EXPIRED_AT) + .itemType(ItemType.OPTION_TICKET) + .quantity(3) + .expiredAt(LocalDateTime.of(9999, 12, 31, 23, 59, 59)) + .build()); + itemHistoryRepository.save(ItemHistory.builder() + .memberId(memberInfo.memberId()) + .itemType(ItemType.OPTION_TICKET) + .historyType(ItemHistoryType.EVENT) + .quantity(3) + .description(ItemType.OPTION_TICKET.getName()) .build()); - itemHistoryRepository.save(ItemHistory.builder() + itemRepository.save(Item.builder() + .memberId(memberInfo.memberId()) + .itemType(ItemType.MATCHING_TICKET) + .quantity(1) + .expiredAt(LocalDateTime.of(9999, 12, 31, 23, 59, 59)) + .build()); + itemHistoryRepository.save(ItemHistory.builder() .memberId(memberInfo.memberId()) - .itemType(reward.getItemType()) + .itemType(ItemType.MATCHING_TICKET) .historyType(ItemHistoryType.EVENT) - .quantity(reward.getQuantity()) - .description(reward.getItemType().getName()) + .quantity(1) + .description(ItemType.MATCHING_TICKET.getName()) .build()); - }); - try { - rouletteHistoryRepository.save(RouletteHistory.builder() - .memberId(memberInfo.memberId()) - .reward(rouletteReward) - .rouletteType(rouletteType) - .build()); - rouletteHistoryRepository.flush(); - } catch (DataIntegrityViolationException exception) { - if (rouletteType == RouletteType.FREE) { - throw new BusinessException(ItemErrorCode.ALREADY_PARTICIPATED_FREE_ROULETTE); - } - throw exception; + } else if (rouletteReward.get().getItemType() != null) { + itemRepository.save(Item.builder() + .memberId(memberInfo.memberId()) + .quantity(rouletteReward.get().getQuantity()) + .expiredAt(LocalDateTime.of(9999, 12, 31, 23, 59, 59)) + .itemType(rouletteReward.get().getItemType()) + .build()); + itemHistoryRepository.save(ItemHistory.builder() + .memberId(memberInfo.memberId()) + .itemType(rouletteReward.get().getItemType()) + .historyType(ItemHistoryType.EVENT) + .quantity(rouletteReward.get().getQuantity()) + .description(rouletteReward.get().getItemType().getName()) + .build()); } - return new RouletteSpinResponse(rouletteReward.getRewardName()); + // 남은 아이템 수 감소 + rouletteReward.get().decreaseRemainingCount(); + + + rouletteHistoryRepository.save(RouletteHistory.builder() + .memberId(memberInfo.memberId()) + .reward(rouletteReward.get()) + .rouletteType(rouletteType) + .build()); + + + return new RouletteSpinResponse(rouletteReward.get().getRewardName()); } } diff --git a/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteRewardRepositoryTest.java b/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteRewardRepositoryTest.java index 70d7d41..9737719 100644 --- a/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteRewardRepositoryTest.java +++ b/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteRewardRepositoryTest.java @@ -7,7 +7,8 @@ import java.sql.Timestamp; import java.time.LocalDate; -import java.util.List; +import java.time.LocalDateTime; +import java.util.Optional; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -72,29 +73,26 @@ void shouldFindRewardsAtBothBoundaries() { RouletteReward last = persist(reward(RouletteType.FREE, "마지막 보상", 5001, 10000, null)); flushAndClear(); - List numberOne = rouletteRewardRepository + Optional numberOne = rouletteRewardRepository .findAvailableByRouletteTypeAndRouletteNumber(RouletteType.FREE, 1); - List numberTenThousand = rouletteRewardRepository + Optional numberTenThousand = rouletteRewardRepository .findAvailableByRouletteTypeAndRouletteNumber(RouletteType.FREE, 10000); - assertThat(numberOne).extracting(RouletteReward::getId).containsExactly(first.getId()); - assertThat(numberTenThousand).extracting(RouletteReward::getId).containsExactly(last.getId()); + assertThat(numberOne.orElseThrow().getId()).isEqualTo(first.getId()); + assertThat(numberTenThousand.orElseThrow().getId()).isEqualTo(last.getId()); } @Test - @DisplayName("동일 범위의 풀세트 두 행을 ID 오름차순으로 조회한다") - void shouldFindFullSetRewardsOrderedById() { - RouletteReward matchingTicket = persist(reward( - RouletteType.PAID, "풀세트", ItemType.MATCHING_TICKET, 8201, 9200, null)); - RouletteReward optionTicket = persist(reward( - RouletteType.PAID, "풀세트", ItemType.OPTION_TICKET, 8201, 9200, null)); + @DisplayName("풀세트 단일 행을 조회한다") + void shouldFindSingleFullSetReward() { + RouletteReward fullSet = persist(reward( + RouletteType.PAID, "풀세트", null, 8201, 9200, null)); flushAndClear(); - List rewards = rouletteRewardRepository + Optional reward = rouletteRewardRepository .findAvailableByRouletteTypeAndRouletteNumber(RouletteType.PAID, 8500); - assertThat(rewards).extracting(RouletteReward::getId) - .containsExactly(matchingTicket.getId(), optionTicket.getId()); + assertThat(reward.orElseThrow().getId()).isEqualTo(fullSet.getId()); } @Test @@ -103,10 +101,10 @@ void shouldExcludeOutOfStockReward() { persist(reward(RouletteType.PAID, "상품권", 9701, 9900, 0)); flushAndClear(); - List rewards = rouletteRewardRepository + Optional reward = rouletteRewardRepository .findAvailableByRouletteTypeAndRouletteNumber(RouletteType.PAID, 9800); - assertThat(rewards).isEmpty(); + assertThat(reward).isEmpty(); } @Test @@ -116,10 +114,10 @@ void shouldSeparateRewardsByRouletteType() { persist(reward(RouletteType.PAID, "유료 보상", 1, 10000, null)); flushAndClear(); - List rewards = rouletteRewardRepository + Optional reward = rouletteRewardRepository .findAvailableByRouletteTypeAndRouletteNumber(RouletteType.FREE, 5000); - assertThat(rewards).extracting(RouletteReward::getId).containsExactly(freeReward.getId()); + assertThat(reward.orElseThrow().getId()).isEqualTo(freeReward.getId()); } @Test @@ -128,8 +126,7 @@ void shouldFindTodayFreeHistoryForSameMember() { persistHistory(1L, RouletteType.FREE); flushAndClear(); - boolean exists = rouletteHistoryRepository - .existsTodayByMemberIdAndRouletteType(1L, RouletteType.FREE); + boolean exists = existsHistoryToday(1L, RouletteType.FREE); assertThat(exists).isTrue(); } @@ -145,8 +142,7 @@ void shouldNotFindPastFreeHistory() { history.getId()); entityManager.clear(); - boolean exists = rouletteHistoryRepository - .existsTodayByMemberIdAndRouletteType(1L, RouletteType.FREE); + boolean exists = existsHistoryToday(1L, RouletteType.FREE); assertThat(exists).isFalse(); } @@ -157,8 +153,7 @@ void shouldNotFindAnotherMembersFreeHistory() { persistHistory(2L, RouletteType.FREE); flushAndClear(); - boolean exists = rouletteHistoryRepository - .existsTodayByMemberIdAndRouletteType(1L, RouletteType.FREE); + boolean exists = existsHistoryToday(1L, RouletteType.FREE); assertThat(exists).isFalse(); } @@ -169,8 +164,7 @@ void shouldNotFindPaidHistoryAsFreeHistory() { persistHistory(1L, RouletteType.PAID); flushAndClear(); - boolean exists = rouletteHistoryRepository - .existsTodayByMemberIdAndRouletteType(1L, RouletteType.FREE); + boolean exists = existsHistoryToday(1L, RouletteType.FREE); assertThat(exists).isFalse(); } @@ -217,6 +211,13 @@ private RouletteHistory persistHistory(Long memberId, RouletteType rouletteType) .build()); } + private boolean existsHistoryToday(Long memberId, RouletteType rouletteType) { + LocalDateTime todayStart = LocalDate.now().atStartOfDay(); + return rouletteHistoryRepository + .existsByMemberIdAndRouletteTypeAndParticipatedAtGreaterThanEqualAndParticipatedAtLessThan( + memberId, rouletteType, todayStart, todayStart.plusDays(1)); + } + private RouletteReward reward( RouletteType rouletteType, String rewardName, diff --git a/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteServiceImplTest.java b/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteServiceImplTest.java index 0e6d2eb..bbe1b86 100644 --- a/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteServiceImplTest.java +++ b/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteServiceImplTest.java @@ -11,7 +11,6 @@ import static org.mockito.Mockito.times; import java.time.LocalDateTime; -import java.util.List; import java.util.Optional; import org.junit.jupiter.api.DisplayName; @@ -61,7 +60,7 @@ class RouletteServiceImplTest { @DisplayName("일반 아이템 보상은 아이템과 아이템 이력을 저장하고 보상명을 반환한다") void shouldGrantItemRewardAndReturnRewardName() { RouletteReward reward = reward(RouletteType.FREE, "옵션권 2장", ItemType.OPTION_TICKET, 2, null); - givenRewards(RouletteType.FREE, List.of(reward)); + givenReward(RouletteType.FREE, reward); RouletteSpinResponse response = rouletteService.spinRoulette(MEMBER, RouletteType.FREE); @@ -85,26 +84,22 @@ void shouldGrantItemRewardAndReturnRewardName() { } @Test - @DisplayName("풀세트는 조회된 두 아이템을 모두 지급하고 대표 보상명을 반환한다") + @DisplayName("풀세트 한 행으로 옵션권과 매칭권을 모두 지급한다") void shouldGrantEveryFullSetReward() { - RouletteReward matchingTicket = reward( - RouletteType.PAID, "풀세트", ItemType.MATCHING_TICKET, 1, 2); - RouletteReward optionTicket = reward( - RouletteType.PAID, "풀세트", ItemType.OPTION_TICKET, 3, 4); - Item rouletteTicket = givenRewards(RouletteType.PAID, List.of(matchingTicket, optionTicket)); + RouletteReward fullSet = reward(RouletteType.PAID, "풀세트", null, 0, 2); + Item rouletteTicket = givenReward(RouletteType.PAID, fullSet); RouletteSpinResponse response = rouletteService.spinRoulette(MEMBER, RouletteType.PAID); assertThat(rouletteTicket.getQuantity()).isZero(); - assertThat(matchingTicket.getRemainingCount()).isEqualTo(1); - assertThat(optionTicket.getRemainingCount()).isEqualTo(3); + assertThat(fullSet.getRemainingCount()).isEqualTo(1); ArgumentCaptor itemCaptor = ArgumentCaptor.forClass(Item.class); then(itemRepository).should(times(2)).save(itemCaptor.capture()); assertThat(itemCaptor.getAllValues()) .extracting(Item::getItemType, Item::getQuantity) .containsExactly( - org.assertj.core.groups.Tuple.tuple(ItemType.MATCHING_TICKET, 1), - org.assertj.core.groups.Tuple.tuple(ItemType.OPTION_TICKET, 3) + org.assertj.core.groups.Tuple.tuple(ItemType.OPTION_TICKET, 3), + org.assertj.core.groups.Tuple.tuple(ItemType.MATCHING_TICKET, 1) ); ArgumentCaptor itemHistoryCaptor = ArgumentCaptor.forClass(ItemHistory.class); then(itemHistoryRepository).should(times(3)).save(itemHistoryCaptor.capture()); @@ -112,12 +107,13 @@ void shouldGrantEveryFullSetReward() { .extracting(ItemHistory::getItemType, ItemHistory::getHistoryType, ItemHistory::getQuantity) .containsExactly( org.assertj.core.groups.Tuple.tuple(ItemType.ROULETTE_TICKET, ItemHistoryType.USE, -1), - org.assertj.core.groups.Tuple.tuple(ItemType.MATCHING_TICKET, ItemHistoryType.EVENT, 1), - org.assertj.core.groups.Tuple.tuple(ItemType.OPTION_TICKET, ItemHistoryType.EVENT, 3) + org.assertj.core.groups.Tuple.tuple(ItemType.OPTION_TICKET, ItemHistoryType.EVENT, 3), + org.assertj.core.groups.Tuple.tuple(ItemType.MATCHING_TICKET, ItemHistoryType.EVENT, 1) ); - assertHistorySavedWith(matchingTicket, RouletteType.PAID); + assertHistorySavedWith(fullSet, RouletteType.PAID); then(rouletteHistoryRepository).should(never()) - .existsTodayByMemberIdAndRouletteType(MEMBER.memberId(), RouletteType.PAID); + .existsByMemberIdAndRouletteTypeAndParticipatedAtGreaterThanEqualAndParticipatedAtLessThan( + eq(MEMBER.memberId()), eq(RouletteType.PAID), any(LocalDateTime.class), any(LocalDateTime.class)); assertThat(response.rewardName()).isEqualTo("풀세트"); } @@ -125,7 +121,7 @@ void shouldGrantEveryFullSetReward() { @DisplayName("상품권은 재고만 차감하고 룰렛 이력과 보상명을 남긴다") void shouldRecordGiftCardWithoutGrantingItem() { RouletteReward reward = reward(RouletteType.PAID, "1만원권 상품권", null, 1, 3); - givenRewards(RouletteType.PAID, List.of(reward)); + givenReward(RouletteType.PAID, reward); RouletteSpinResponse response = rouletteService.spinRoulette(MEMBER, RouletteType.PAID); @@ -146,7 +142,7 @@ void shouldRecordGiftCardWithoutGrantingItem() { @DisplayName("꽝은 아이템을 지급하지 않고 룰렛 이력과 보상명을 남긴다") void shouldRecordNoPrizeWithoutGrantingItem() { RouletteReward reward = reward(RouletteType.FREE, "꽝", null, 0, null); - givenRewards(RouletteType.FREE, List.of(reward)); + givenReward(RouletteType.FREE, reward); RouletteSpinResponse response = rouletteService.spinRoulette(MEMBER, RouletteType.FREE); @@ -159,8 +155,10 @@ void shouldRecordNoPrizeWithoutGrantingItem() { @Test @DisplayName("이미 무료 룰렛에 참여한 회원은 예외가 발생하고 보상을 처리하지 않는다") void shouldRejectDuplicatedFreeRouletteParticipation() { - given(rouletteHistoryRepository.existsTodayByMemberIdAndRouletteType( - MEMBER.memberId(), RouletteType.FREE)).willReturn(true); + given(rouletteHistoryRepository + .existsByMemberIdAndRouletteTypeAndParticipatedAtGreaterThanEqualAndParticipatedAtLessThan( + eq(MEMBER.memberId()), eq(RouletteType.FREE), + any(LocalDateTime.class), any(LocalDateTime.class))).willReturn(true); assertThatThrownBy(() -> rouletteService.spinRoulette(MEMBER, RouletteType.FREE)) .isInstanceOf(BusinessException.class) @@ -195,11 +193,9 @@ void shouldRejectWhenRouletteTicketDoesNotExist() { @DisplayName("선택된 범위의 보상이 비어 있으면 다시 추첨한다") void shouldRetryWhenSelectedRewardIsEmpty() { RouletteReward reward = reward(RouletteType.FREE, "꽝", null, 0, null); - given(rouletteHistoryRepository.existsTodayByMemberIdAndRouletteType( - MEMBER.memberId(), RouletteType.FREE)).willReturn(false); given(rouletteRewardRepository .findAvailableByRouletteTypeAndRouletteNumber(eq(RouletteType.FREE), anyInt())) - .willReturn(List.of(), List.of(reward)); + .willReturn(Optional.empty(), Optional.of(reward)); RouletteSpinResponse response = rouletteService.spinRoulette(MEMBER, RouletteType.FREE); @@ -214,7 +210,7 @@ void shouldRetryWhenSelectedRewardIsEmpty() { void shouldStopWhenItemSaveFails() { RouletteReward reward = reward( RouletteType.FREE, "옵션권 1장", ItemType.OPTION_TICKET, 1, null); - givenRewards(RouletteType.FREE, List.of(reward)); + givenReward(RouletteType.FREE, reward); given(itemRepository.save(any(Item.class))).willThrow(new RuntimeException("item save failed")); assertThatThrownBy(() -> rouletteService.spinRoulette(MEMBER, RouletteType.FREE)) @@ -230,7 +226,7 @@ void shouldStopWhenItemSaveFails() { void shouldStopWhenItemHistorySaveFails() { RouletteReward reward = reward( RouletteType.FREE, "옵션권 1장", ItemType.OPTION_TICKET, 1, null); - givenRewards(RouletteType.FREE, List.of(reward)); + givenReward(RouletteType.FREE, reward); given(itemHistoryRepository.save(any(ItemHistory.class))) .willThrow(new RuntimeException("item history save failed")); @@ -246,7 +242,7 @@ void shouldStopWhenItemHistorySaveFails() { @DisplayName("룰렛 이력 저장이 실패하면 예외를 그대로 전달한다") void shouldPropagateRouletteHistorySaveFailure() { RouletteReward reward = reward(RouletteType.FREE, "꽝", null, 0, null); - givenRewards(RouletteType.FREE, List.of(reward)); + givenReward(RouletteType.FREE, reward); given(rouletteHistoryRepository.save(any(RouletteHistory.class))) .willThrow(new RuntimeException("roulette history save failed")); @@ -285,18 +281,20 @@ void shouldNotDecreaseRemainingCountBelowZero() { assertThat(reward.getRemainingCount()).isZero(); } - private Item givenRewards(RouletteType rouletteType, List rewards) { + private Item givenReward(RouletteType rouletteType, RouletteReward reward) { Item rouletteTicket = null; if (rouletteType == RouletteType.PAID) { rouletteTicket = givenTicket(); } if (rouletteType == RouletteType.FREE) { - given(rouletteHistoryRepository.existsTodayByMemberIdAndRouletteType(MEMBER.memberId(), rouletteType)) - .willReturn(false); + given(rouletteHistoryRepository + .existsByMemberIdAndRouletteTypeAndParticipatedAtGreaterThanEqualAndParticipatedAtLessThan( + eq(MEMBER.memberId()), eq(rouletteType), + any(LocalDateTime.class), any(LocalDateTime.class))).willReturn(false); } given(rouletteRewardRepository .findAvailableByRouletteTypeAndRouletteNumber(eq(rouletteType), anyInt())) - .willReturn(rewards); + .willReturn(Optional.of(reward)); return rouletteTicket; } From 45b4dc9b97d066dcada1374f26f9e16a0f0360d1 Mon Sep 17 00:00:00 2001 From: jinwook219 Date: Wed, 2 Sep 2026 19:56:55 +0900 Subject: [PATCH 12/22] =?UTF-8?q?feat(item):=20=EA=B2=B0=EC=A0=9C=20?= =?UTF-8?q?=EA=B8=B0=EB=B0=98=20=EC=8A=A4=ED=8E=98=EC=85=9C=20=EB=A3=B0?= =?UTF-8?q?=EB=A0=9B=EA=B3=BC=20=EC=B0=B8=EC=97=AC=20=EC=83=81=ED=83=9C=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EC=B6=94=EA=B0=80=20=20=20-=20PAID=20?= =?UTF-8?q?=EB=A3=B0=EB=A0=9B=EC=9D=84=20SPECIAL=20=EB=A3=B0=EB=A0=9B?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD=ED=95=98=EA=B3=A0=20?= =?UTF-8?q?=EB=A3=B0=EB=A0=9B=20=ED=8B=B0=EC=BC=93=20=EC=82=AC=EC=9A=A9=20?= =?UTF-8?q?=EB=A1=9C=EC=A7=81=20=EC=A0=9C=EA=B1=B0=20=20=20-=20=EB=8B=B9?= =?UTF-8?q?=EC=9D=BC=20=EC=8A=B9=EC=9D=B8=20=EA=B2=B0=EC=A0=9C=EC=95=A1=20?= =?UTF-8?q?3,500=EC=9B=90=20=EC=9D=B4=EC=83=81=EC=9D=B8=20=ED=9A=8C?= =?UTF-8?q?=EC=9B=90=EB=A7=8C=20=EC=8A=A4=ED=8E=98=EC=85=9C=20=EB=A3=B0?= =?UTF-8?q?=EB=A0=9B=20=EC=B0=B8=EC=97=AC=20=ED=97=88=EC=9A=A9=20=20=20-?= =?UTF-8?q?=20=EB=AC=B4=EB=A3=8C=C2=B7=EC=8A=A4=ED=8E=98=EC=85=9C=20?= =?UTF-8?q?=EB=A3=B0=EB=A0=9B=EC=9D=98=20=EB=8B=B9=EC=9D=BC=20=EC=A4=91?= =?UTF-8?q?=EB=B3=B5=20=EC=B0=B8=EC=97=AC=20=EC=98=88=EC=99=B8=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=20=20-=20=EB=A3=B0=EB=A0=9B=20=EC=B0=B8=EC=97=AC?= =?UTF-8?q?=20=EA=B0=80=EB=8A=A5=20=EC=97=AC=EB=B6=80=EC=99=80=20=EB=8B=B9?= =?UTF-8?q?=EC=9D=BC=20=EB=88=84=EC=A0=81=20=EA=B2=B0=EC=A0=9C=EC=95=A1=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20API=20=EC=B6=94=EA=B0=80=20=20=20-=20?= =?UTF-8?q?=EB=A3=B0=EB=A0=9B=20=EC=8B=A4=ED=96=89=20API=EB=A5=BC=20?= =?UTF-8?q?=ED=83=80=EC=9E=85=EB=B3=84=20=EA=B2=BD=EB=A1=9C=20=EA=B5=AC?= =?UTF-8?q?=EC=A1=B0=EB=A1=9C=20=EB=B3=80=EA=B2=BD=20=20=20-=20=EC=8A=A4?= =?UTF-8?q?=ED=8E=98=EC=85=9C=20=EB=A3=B0=EB=A0=9B=EA=B3=BC=20=EC=B0=B8?= =?UTF-8?q?=EC=97=AC=20=EC=83=81=ED=83=9C=20=EC=A1=B0=ED=9A=8C=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../common/domain/enums/ItemType.java | 3 +- .../order/repository/OrderRepository.java | 14 ++ .../dto/response/RoulettePageResponse.java | 7 + .../{ => response}/RouletteSpinResponse.java | 2 +- .../domain/roulette/enums/RouletteType.java | 2 +- .../roulette/service/RouletteService.java | 5 +- .../roulette/service/RouletteServiceImpl.java | 62 ++--- .../item/global/exception/ItemErrorCode.java | 4 +- .../infra/controller/RouletteController.java | 24 +- .../service/RouletteConcurrencyTest.java | 56 ++--- .../service/RouletteRewardRepositoryTest.java | 19 +- .../service/RouletteServiceImplTest.java | 212 +++++++++++++----- .../controller/RouletteControllerTest.java | 78 ++++++- 13 files changed, 327 insertions(+), 161 deletions(-) create mode 100644 item-service/src/main/java/com/comatching/item/domain/roulette/dto/response/RoulettePageResponse.java rename item-service/src/main/java/com/comatching/item/domain/roulette/dto/{ => response}/RouletteSpinResponse.java (52%) diff --git a/common-module/src/main/java/com/comatching/common/domain/enums/ItemType.java b/common-module/src/main/java/com/comatching/common/domain/enums/ItemType.java index 33ef652..a1e7c0c 100644 --- a/common-module/src/main/java/com/comatching/common/domain/enums/ItemType.java +++ b/common-module/src/main/java/com/comatching/common/domain/enums/ItemType.java @@ -7,8 +7,7 @@ @RequiredArgsConstructor public enum ItemType { MATCHING_TICKET("매칭권", "기본 옵션만으로 매칭을 할 수 있는 아이템"), - OPTION_TICKET("옵션권", "같은 학과 제외, 중요 옵션 선택이 가능해지는 아이템"), - ROULETTE_TICKET("룰렛티켓", "룰렛을 돌릴 수 있는 티켓") + OPTION_TICKET("옵션권", "같은 학과 제외, 중요 옵션 선택이 가능해지는 아이템") ; private final String name; diff --git a/item-service/src/main/java/com/comatching/item/domain/order/repository/OrderRepository.java b/item-service/src/main/java/com/comatching/item/domain/order/repository/OrderRepository.java index f7a4827..a2bc200 100644 --- a/item-service/src/main/java/com/comatching/item/domain/order/repository/OrderRepository.java +++ b/item-service/src/main/java/com/comatching/item/domain/order/repository/OrderRepository.java @@ -81,6 +81,20 @@ long sumActivePendingQuantityByMemberIdAndItemType( @Param("now") LocalDateTime now ); + @Query(""" + SELECT COALESCE(SUM(o.expectedPrice), 0) + FROM Order o + WHERE o.memberId = :memberId + AND o.status = com.comatching.item.domain.order.enums.OrderStatus.APPROVED + AND o.decidedAt >= :startAt + AND o.decidedAt < :endAt + """) + long sumApprovedPriceByMemberIdAndDecidedAtBetween( + @Param("memberId") Long memberId, + @Param("startAt") LocalDateTime startAt, + @Param("endAt") LocalDateTime endAt + ); + List findAllByStatusOrderByRequestedAtDesc(OrderStatus status); Page findAllByStatusAndExpiresAtAfter(OrderStatus status, LocalDateTime now, Pageable pageable); diff --git a/item-service/src/main/java/com/comatching/item/domain/roulette/dto/response/RoulettePageResponse.java b/item-service/src/main/java/com/comatching/item/domain/roulette/dto/response/RoulettePageResponse.java new file mode 100644 index 0000000..fe924bd --- /dev/null +++ b/item-service/src/main/java/com/comatching/item/domain/roulette/dto/response/RoulettePageResponse.java @@ -0,0 +1,7 @@ +package com.comatching.item.domain.roulette.dto.response; + +public record RoulettePageResponse( + boolean isPossible, + Long totalPay +) { +} diff --git a/item-service/src/main/java/com/comatching/item/domain/roulette/dto/RouletteSpinResponse.java b/item-service/src/main/java/com/comatching/item/domain/roulette/dto/response/RouletteSpinResponse.java similarity index 52% rename from item-service/src/main/java/com/comatching/item/domain/roulette/dto/RouletteSpinResponse.java rename to item-service/src/main/java/com/comatching/item/domain/roulette/dto/response/RouletteSpinResponse.java index 644806f..bbfc093 100644 --- a/item-service/src/main/java/com/comatching/item/domain/roulette/dto/RouletteSpinResponse.java +++ b/item-service/src/main/java/com/comatching/item/domain/roulette/dto/response/RouletteSpinResponse.java @@ -1,4 +1,4 @@ -package com.comatching.item.domain.roulette.dto; +package com.comatching.item.domain.roulette.dto.response; public record RouletteSpinResponse( String rewardName diff --git a/item-service/src/main/java/com/comatching/item/domain/roulette/enums/RouletteType.java b/item-service/src/main/java/com/comatching/item/domain/roulette/enums/RouletteType.java index d74ed40..e58d33e 100644 --- a/item-service/src/main/java/com/comatching/item/domain/roulette/enums/RouletteType.java +++ b/item-service/src/main/java/com/comatching/item/domain/roulette/enums/RouletteType.java @@ -2,5 +2,5 @@ public enum RouletteType { FREE, - PAID + SPECIAL } \ No newline at end of file diff --git a/item-service/src/main/java/com/comatching/item/domain/roulette/service/RouletteService.java b/item-service/src/main/java/com/comatching/item/domain/roulette/service/RouletteService.java index 55d606d..155bbed 100644 --- a/item-service/src/main/java/com/comatching/item/domain/roulette/service/RouletteService.java +++ b/item-service/src/main/java/com/comatching/item/domain/roulette/service/RouletteService.java @@ -1,9 +1,12 @@ package com.comatching.item.domain.roulette.service; import com.comatching.common.dto.member.MemberInfo; -import com.comatching.item.domain.roulette.dto.RouletteSpinResponse; +import com.comatching.item.domain.roulette.dto.response.RoulettePageResponse; +import com.comatching.item.domain.roulette.dto.response.RouletteSpinResponse; import com.comatching.item.domain.roulette.enums.RouletteType; public interface RouletteService { RouletteSpinResponse spinRoulette(MemberInfo memberInfo, RouletteType rouletteType); + + RoulettePageResponse roulettePage(MemberInfo memberInfo, RouletteType rouletteType); } diff --git a/item-service/src/main/java/com/comatching/item/domain/roulette/service/RouletteServiceImpl.java b/item-service/src/main/java/com/comatching/item/domain/roulette/service/RouletteServiceImpl.java index 18ebb51..82463a7 100644 --- a/item-service/src/main/java/com/comatching/item/domain/roulette/service/RouletteServiceImpl.java +++ b/item-service/src/main/java/com/comatching/item/domain/roulette/service/RouletteServiceImpl.java @@ -13,7 +13,9 @@ import com.comatching.item.domain.item.enums.ItemHistoryType; import com.comatching.item.domain.item.repository.ItemHistoryRepository; import com.comatching.item.domain.item.repository.ItemRepository; -import com.comatching.item.domain.roulette.dto.RouletteSpinResponse; +import com.comatching.item.domain.order.repository.OrderRepository; +import com.comatching.item.domain.roulette.dto.response.RoulettePageResponse; +import com.comatching.item.domain.roulette.dto.response.RouletteSpinResponse; import com.comatching.item.domain.roulette.entity.RouletteHistory; import com.comatching.item.domain.roulette.entity.RouletteReward; import com.comatching.item.domain.roulette.enums.RouletteType; @@ -28,48 +30,37 @@ @AllArgsConstructor public class RouletteServiceImpl implements RouletteService { + private static final long SPECIAL_ROULETTE_MINIMUM_PAYMENT = 3500L; + private final RouletteHistoryRepository rouletteHistoryRepository; private final RouletteRewardRepository rouletteRewardRepository; private final ItemRepository itemRepository; private final ItemHistoryRepository itemHistoryRepository; + private final OrderRepository orderRepository; @Override @Transactional public RouletteSpinResponse spinRoulette(MemberInfo memberInfo, RouletteType rouletteType) { - // TODO: 반복적인 코드 메서드로 따로 빼기 - - // 타입이 무료인 경우 참여여부 검증 + 예외처리 LocalDateTime todayStart = LocalDate.now().atStartOfDay(); LocalDateTime tomorrowStart = todayStart.plusDays(1); - if (rouletteType == RouletteType.FREE && rouletteHistoryRepository + // 오늘 참여한 결과 더이상 불가능 + boolean isParticipatedToday = rouletteHistoryRepository .existsByMemberIdAndRouletteTypeAndParticipatedAtGreaterThanEqualAndParticipatedAtLessThan( - memberInfo.memberId(), rouletteType, todayStart, tomorrowStart)) { - throw new BusinessException(ItemErrorCode.ALREADY_PARTICIPATED_FREE_ROULETTE); + memberInfo.memberId(), rouletteType, todayStart, tomorrowStart); + if (isParticipatedToday) { + throw new BusinessException(ItemErrorCode.ALREADY_PARTICIPATED_ROULETTE); } - // 유료 룰렛의 경우 티켓 감소 - if (rouletteType == RouletteType.PAID) { - Item rouletteTicket = itemRepository - .findFirstByMemberIdAndItemTypeAndQuantityGreaterThanEqualAndExpiredAtGreaterThanOrderByExpiredAtAscQuantityAsc( - memberInfo.memberId(), ItemType.ROULETTE_TICKET, 1, LocalDateTime.now()) - .orElseThrow(() -> new BusinessException(ItemErrorCode.NOT_ENOUGH_ITEM)); - - rouletteTicket.decrease(1); - - itemHistoryRepository.save(ItemHistory.builder() - .memberId(memberInfo.memberId()) - .itemType(ItemType.ROULETTE_TICKET) - .historyType(ItemHistoryType.USE) - .quantity(-1) // - .description(ItemType.ROULETTE_TICKET.getName()) - .build()); - - + // 결제액이 3500이하면 불가능 + if (rouletteType == RouletteType.SPECIAL + && orderRepository.sumApprovedPriceByMemberIdAndDecidedAtBetween( + memberInfo.memberId(), todayStart, tomorrowStart) < SPECIAL_ROULETTE_MINIMUM_PAYMENT) { + throw new BusinessException(ItemErrorCode.NOT_ENOUGH_PAYMENT_FOR_SPECIAL_ROULETTE); } - Optional rouletteReward; // 1 ~ 10000 사이의 난수 생성한 후에 db에 확률 range에 맞게 보상 가져오기 (만약 보상의 남은 갯수가 없다면 다시 난수 생성해서 추첨) + Optional rouletteReward; do { int rouletteNumber = ThreadLocalRandom.current().nextInt(1, 10_001); rouletteReward = rouletteRewardRepository @@ -137,4 +128,23 @@ public RouletteSpinResponse spinRoulette(MemberInfo memberInfo, RouletteType rou return new RouletteSpinResponse(rouletteReward.get().getRewardName()); } + + @Override + public RoulettePageResponse roulettePage(MemberInfo memberInfo, RouletteType rouletteType) { + LocalDateTime todayStart = LocalDate.now().atStartOfDay(); + LocalDateTime tomorrowStart = todayStart.plusDays(1); + boolean isParticipated = rouletteHistoryRepository + .existsByMemberIdAndRouletteTypeAndParticipatedAtGreaterThanEqualAndParticipatedAtLessThan( + memberInfo.memberId(), rouletteType, todayStart, tomorrowStart); + + + Long totalPay = rouletteType == RouletteType.SPECIAL + ? orderRepository.sumApprovedPriceByMemberIdAndDecidedAtBetween( + memberInfo.memberId(), todayStart, tomorrowStart) + : null; + boolean isPossible = !isParticipated + && (rouletteType == RouletteType.FREE || totalPay >= SPECIAL_ROULETTE_MINIMUM_PAYMENT); + + return new RoulettePageResponse(isPossible, totalPay); + } } diff --git a/item-service/src/main/java/com/comatching/item/global/exception/ItemErrorCode.java b/item-service/src/main/java/com/comatching/item/global/exception/ItemErrorCode.java index 3490cb8..8de5eed 100644 --- a/item-service/src/main/java/com/comatching/item/global/exception/ItemErrorCode.java +++ b/item-service/src/main/java/com/comatching/item/global/exception/ItemErrorCode.java @@ -15,7 +15,9 @@ public enum ItemErrorCode implements ErrorCode { TARGET_USER_NOT_FOUND("ITEM-004", HttpStatus.BAD_REQUEST, "대상 사용자를 찾을 수 없습니다."), USER_QUERY_FAILED("ITEM-005", HttpStatus.INTERNAL_SERVER_ERROR, "사용자 조회 중 오류가 발생했습니다."), DUPLICATE_ADMIN_INVENTORY_ADJUSTMENT("ITEM-006", HttpStatus.CONFLICT, "동일한 관리자 아이템 조정 요청이 처리 중입니다."), - ALREADY_PARTICIPATED_FREE_ROULETTE("ITEM-007", HttpStatus.BAD_REQUEST, "Free roulette already participated."), + ALREADY_PARTICIPATED_ROULETTE("ITEM-007", HttpStatus.BAD_REQUEST, "이미 룰렛에 참여하셨습니다"), + NOT_ENOUGH_PAYMENT_FOR_SPECIAL_ROULETTE("ITEM-008", HttpStatus.BAD_REQUEST, + "스페셜 룰렛 참여를 위한 오늘의 누적 결제 금액이 부족합니다."), ; private final String code; diff --git a/item-service/src/main/java/com/comatching/item/infra/controller/RouletteController.java b/item-service/src/main/java/com/comatching/item/infra/controller/RouletteController.java index b8ba595..15a9caf 100644 --- a/item-service/src/main/java/com/comatching/item/infra/controller/RouletteController.java +++ b/item-service/src/main/java/com/comatching/item/infra/controller/RouletteController.java @@ -3,15 +3,15 @@ import com.comatching.common.annotation.CurrentMember; import com.comatching.common.dto.member.MemberInfo; import com.comatching.common.dto.response.ApiResponse; -import com.comatching.item.domain.roulette.dto.RouletteSpinResponse; +import com.comatching.item.domain.roulette.dto.response.RoulettePageResponse; +import com.comatching.item.domain.roulette.dto.response.RouletteSpinResponse; import com.comatching.item.domain.roulette.enums.RouletteType; import com.comatching.item.domain.roulette.service.RouletteService; +import io.swagger.v3.oas.annotations.Operation; +import jdk.jfr.Description; import lombok.AllArgsConstructor; import org.springframework.http.ResponseEntity; -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; +import org.springframework.web.bind.annotation.*; @RestController @AllArgsConstructor @@ -19,12 +19,20 @@ public class RouletteController { private final RouletteService rouletteService; - @PostMapping("/spins") + @PostMapping("/{rouletteType}/spins") + @Operation(summary = "룰렛 돌리는 api", description = "") public ResponseEntity> spinRoulette( + @CurrentMember MemberInfo memberInfo, + @PathVariable RouletteType rouletteType + ) { + return ResponseEntity.ok(ApiResponse.ok(rouletteService.spinRoulette(memberInfo, rouletteType))); + } + + @GetMapping + public ResponseEntity> roulettePage( @CurrentMember MemberInfo memberInfo, @RequestParam RouletteType rouletteType ) { - RouletteSpinResponse response = rouletteService.spinRoulette(memberInfo, rouletteType); - return ResponseEntity.ok(ApiResponse.ok(response)); + return ResponseEntity.ok(ApiResponse.ok(rouletteService.roulettePage(memberInfo, rouletteType))); } } diff --git a/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteConcurrencyTest.java b/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteConcurrencyTest.java index b6d45fc..9449e3c 100644 --- a/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteConcurrencyTest.java +++ b/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteConcurrencyTest.java @@ -2,7 +2,6 @@ import static org.assertj.core.api.Assertions.assertThat; -import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; import java.util.concurrent.CountDownLatch; @@ -11,6 +10,7 @@ import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -23,18 +23,19 @@ import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.test.annotation.DirtiesContext; import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.transaction.PlatformTransactionManager; import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.support.TransactionTemplate; -import com.comatching.common.domain.enums.ItemType; import com.comatching.common.dto.member.MemberInfo; import com.comatching.common.exception.BusinessException; import com.comatching.item.domain.item.entity.Item; import com.comatching.item.domain.item.entity.ItemHistory; import com.comatching.item.domain.item.repository.ItemHistoryRepository; import com.comatching.item.domain.item.repository.ItemRepository; +import com.comatching.item.domain.order.repository.OrderRepository; import com.comatching.item.domain.roulette.entity.RouletteHistory; import com.comatching.item.domain.roulette.entity.RouletteReward; import com.comatching.item.domain.roulette.enums.RouletteType; @@ -50,7 +51,6 @@ class RouletteConcurrencyTest { private static final int REQUEST_COUNT = 20; - private static final LocalDateTime NOT_EXPIRED = LocalDateTime.of(9999, 12, 31, 23, 59, 59); @Autowired private RouletteService rouletteService; @@ -62,30 +62,32 @@ class RouletteConcurrencyTest { private JdbcTemplate jdbcTemplate; @Autowired private PlatformTransactionManager transactionManager; + @MockitoBean + private OrderRepository orderRepository; @Test @Transactional(propagation = Propagation.NOT_SUPPORTED) - @DisplayName("동시에 추첨해도 한정 보상은 준비된 재고만큼만 지급된다") - void limitedRewardIsNotGrantedBeyondItsStock() throws Exception { - int stock = 5; + @DisplayName("단일 행 보상은 동시 추첨 횟수만큼 재고가 차감된다") + void singleRewardStockIsDecreasedForEveryConcurrentSpin() throws Exception { + int stock = REQUEST_COUNT; Long limitedRewardId = inTransaction(() -> rouletteRewardRepository.save( reward(RouletteType.FREE, "한정 보상", 1, 10000, stock)).getId()); - inTransaction(() -> rouletteRewardRepository.save( - reward(RouletteType.FREE, "기본 보상", 1, 10000, null))); List failures = runConcurrently(REQUEST_COUNT, index -> rouletteService.spinRoulette(member(index + 1L), RouletteType.FREE)); assertThat(failures).isEmpty(); - assertThat(rouletteRewardRepository.findById(limitedRewardId).orElseThrow().getRemainingCount()) - .isZero(); + RouletteReward savedReward = rouletteRewardRepository.findById(limitedRewardId).orElseThrow(); + assertThat(savedReward.getItemType()).isNull(); + assertThat(savedReward.getRemainingCount()).isZero(); assertThat(count("SELECT COUNT(*) FROM roulette_history WHERE reward_id = ?", limitedRewardId)) - .isEqualTo(stock); + .isEqualTo(REQUEST_COUNT); assertThat(count("SELECT COUNT(*) FROM roulette_history")) .isEqualTo(REQUEST_COUNT); } @Test + @Disabled("참여 이력 조회와 저장 사이의 동시성 제어를 추가할 때 활성화") @Transactional(propagation = Propagation.NOT_SUPPORTED) @DisplayName("동일 회원의 무료 룰렛 동시 요청은 하루에 한 번만 성공한다") void freeRouletteSucceedsOnlyOnceForSameMember() throws Exception { @@ -100,42 +102,12 @@ void freeRouletteSucceedsOnlyOnceForSameMember() throws Exception { .allSatisfy(failure -> { assertThat(failure).isInstanceOf(BusinessException.class); assertThat(((BusinessException)failure).getErrorCode()) - .isEqualTo(ItemErrorCode.ALREADY_PARTICIPATED_FREE_ROULETTE); + .isEqualTo(ItemErrorCode.ALREADY_PARTICIPATED_ROULETTE); }); assertThat(count("SELECT COUNT(*) FROM roulette_history WHERE member_id = ?", memberId)) .isOne(); } - @Test - @Transactional(propagation = Propagation.NOT_SUPPORTED) - @DisplayName("동일 회원의 유료 룰렛 동시 요청은 보유 티켓 수만큼만 성공한다") - void paidRouletteSucceedsOnlyAsManyTimesAsAvailableTickets() throws Exception { - int ticketCount = 5; - Long memberId = 100L; - Long ticketId = inTransaction(() -> itemRepository.save(Item.builder() - .memberId(memberId) - .itemType(ItemType.ROULETTE_TICKET) - .quantity(ticketCount) - .expiredAt(NOT_EXPIRED) - .build()).getId()); - inTransaction(() -> rouletteRewardRepository.save( - reward(RouletteType.PAID, "기본 보상", 1, 10000, null))); - - List failures = runConcurrently(REQUEST_COUNT, index -> - rouletteService.spinRoulette(member(memberId), RouletteType.PAID)); - - assertThat(failures).hasSize(REQUEST_COUNT - ticketCount) - .allSatisfy(failure -> { - assertThat(failure).isInstanceOf(BusinessException.class); - assertThat(((BusinessException)failure).getErrorCode()).isEqualTo(ItemErrorCode.NOT_ENOUGH_ITEM); - }); - assertThat(itemRepository.findById(ticketId).orElseThrow().getQuantity()).isZero(); - assertThat(count("SELECT COUNT(*) FROM item_history WHERE member_id = ?", memberId)) - .isEqualTo(ticketCount); - assertThat(count("SELECT COUNT(*) FROM roulette_history WHERE member_id = ?", memberId)) - .isEqualTo(ticketCount); - } - private List runConcurrently(int threadCount, ThrowingIntConsumer task) throws Exception { ExecutorService executor = Executors.newFixedThreadPool(threadCount); CountDownLatch ready = new CountDownLatch(threadCount); diff --git a/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteRewardRepositoryTest.java b/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteRewardRepositoryTest.java index 9737719..7e2a503 100644 --- a/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteRewardRepositoryTest.java +++ b/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteRewardRepositoryTest.java @@ -35,6 +35,7 @@ import com.comatching.item.domain.item.entity.ItemHistory; import com.comatching.item.domain.item.repository.ItemHistoryRepository; import com.comatching.item.domain.item.repository.ItemRepository; +import com.comatching.item.domain.order.repository.OrderRepository; import com.comatching.item.domain.roulette.entity.RouletteHistory; import com.comatching.item.domain.roulette.entity.RouletteReward; import com.comatching.item.domain.roulette.enums.RouletteType; @@ -65,6 +66,8 @@ class RouletteRewardRepositoryTest { @MockitoBean private ItemHistoryRepository itemHistoryRepository; + @MockitoBean + private OrderRepository orderRepository; @Test @DisplayName("난수 범위의 양 끝 1과 10000에 해당하는 보상을 조회한다") @@ -86,11 +89,11 @@ void shouldFindRewardsAtBothBoundaries() { @DisplayName("풀세트 단일 행을 조회한다") void shouldFindSingleFullSetReward() { RouletteReward fullSet = persist(reward( - RouletteType.PAID, "풀세트", null, 8201, 9200, null)); + RouletteType.SPECIAL, "풀세트", null, 8201, 9200, null)); flushAndClear(); Optional reward = rouletteRewardRepository - .findAvailableByRouletteTypeAndRouletteNumber(RouletteType.PAID, 8500); + .findAvailableByRouletteTypeAndRouletteNumber(RouletteType.SPECIAL, 8500); assertThat(reward.orElseThrow().getId()).isEqualTo(fullSet.getId()); } @@ -98,11 +101,11 @@ void shouldFindSingleFullSetReward() { @Test @DisplayName("재고가 소진된 제한 보상은 조회하지 않는다") void shouldExcludeOutOfStockReward() { - persist(reward(RouletteType.PAID, "상품권", 9701, 9900, 0)); + persist(reward(RouletteType.SPECIAL, "상품권", 9701, 9900, 0)); flushAndClear(); Optional reward = rouletteRewardRepository - .findAvailableByRouletteTypeAndRouletteNumber(RouletteType.PAID, 9800); + .findAvailableByRouletteTypeAndRouletteNumber(RouletteType.SPECIAL, 9800); assertThat(reward).isEmpty(); } @@ -111,7 +114,7 @@ void shouldExcludeOutOfStockReward() { @DisplayName("같은 난수 범위라도 요청한 룰렛 타입의 보상만 조회한다") void shouldSeparateRewardsByRouletteType() { RouletteReward freeReward = persist(reward(RouletteType.FREE, "무료 보상", 1, 10000, null)); - persist(reward(RouletteType.PAID, "유료 보상", 1, 10000, null)); + persist(reward(RouletteType.SPECIAL, "스페셜 보상", 1, 10000, null)); flushAndClear(); Optional reward = rouletteRewardRepository @@ -159,9 +162,9 @@ void shouldNotFindAnotherMembersFreeHistory() { } @Test - @DisplayName("같은 회원의 오늘 PAID 참여 이력은 FREE 참여로 조회하지 않는다") - void shouldNotFindPaidHistoryAsFreeHistory() { - persistHistory(1L, RouletteType.PAID); + @DisplayName("같은 회원의 오늘 SPECIAL 참여 이력은 FREE 참여로 조회하지 않는다") + void shouldNotFindSpecialHistoryAsFreeHistory() { + persistHistory(1L, RouletteType.SPECIAL); flushAndClear(); boolean exists = existsHistoryToday(1L, RouletteType.FREE); diff --git a/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteServiceImplTest.java b/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteServiceImplTest.java index bbe1b86..3f1275e 100644 --- a/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteServiceImplTest.java +++ b/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteServiceImplTest.java @@ -29,7 +29,9 @@ import com.comatching.item.domain.item.enums.ItemHistoryType; import com.comatching.item.domain.item.repository.ItemHistoryRepository; import com.comatching.item.domain.item.repository.ItemRepository; -import com.comatching.item.domain.roulette.dto.RouletteSpinResponse; +import com.comatching.item.domain.order.repository.OrderRepository; +import com.comatching.item.domain.roulette.dto.response.RoulettePageResponse; +import com.comatching.item.domain.roulette.dto.response.RouletteSpinResponse; import com.comatching.item.domain.roulette.entity.RouletteHistory; import com.comatching.item.domain.roulette.entity.RouletteReward; import com.comatching.item.domain.roulette.enums.RouletteType; @@ -52,10 +54,102 @@ class RouletteServiceImplTest { private ItemRepository itemRepository; @Mock private ItemHistoryRepository itemHistoryRepository; + @Mock + private OrderRepository orderRepository; @InjectMocks private RouletteServiceImpl rouletteService; + @Test + @DisplayName("오늘 무료 룰렛에 참여하지 않았으면 참여 가능하다") + void shouldAllowFreeRouletteWhenNotParticipatedToday() { + given(rouletteHistoryRepository + .existsByMemberIdAndRouletteTypeAndParticipatedAtGreaterThanEqualAndParticipatedAtLessThan( + eq(MEMBER.memberId()), eq(RouletteType.FREE), + any(LocalDateTime.class), any(LocalDateTime.class))) + .willReturn(false); + + RoulettePageResponse response = rouletteService.roulettePage(MEMBER, RouletteType.FREE); + + assertThat(response.isPossible()).isTrue(); + assertThat(response.totalPay()).isNull(); + then(orderRepository).shouldHaveNoInteractions(); + } + + @Test + @DisplayName("오늘 무료 룰렛에 이미 참여했으면 참여할 수 없다") + void shouldRejectFreeRouletteWhenAlreadyParticipatedToday() { + given(rouletteHistoryRepository + .existsByMemberIdAndRouletteTypeAndParticipatedAtGreaterThanEqualAndParticipatedAtLessThan( + eq(MEMBER.memberId()), eq(RouletteType.FREE), + any(LocalDateTime.class), any(LocalDateTime.class))) + .willReturn(true); + + RoulettePageResponse response = rouletteService.roulettePage(MEMBER, RouletteType.FREE); + + assertThat(response.isPossible()).isFalse(); + assertThat(response.totalPay()).isNull(); + then(orderRepository).shouldHaveNoInteractions(); + then(rouletteHistoryRepository).should() + .existsByMemberIdAndRouletteTypeAndParticipatedAtGreaterThanEqualAndParticipatedAtLessThan( + eq(MEMBER.memberId()), eq(RouletteType.FREE), + any(LocalDateTime.class), any(LocalDateTime.class)); + } + + @Test + @DisplayName("오늘 결제액이 3500원이고 스페셜 룰렛에 참여하지 않았으면 참여 가능하다") + void shouldAllowSpecialRouletteAtMinimumPayment() { + given(orderRepository.sumApprovedPriceByMemberIdAndDecidedAtBetween( + eq(MEMBER.memberId()), any(LocalDateTime.class), any(LocalDateTime.class))) + .willReturn(3500L); + given(rouletteHistoryRepository + .existsByMemberIdAndRouletteTypeAndParticipatedAtGreaterThanEqualAndParticipatedAtLessThan( + eq(MEMBER.memberId()), eq(RouletteType.SPECIAL), + any(LocalDateTime.class), any(LocalDateTime.class))) + .willReturn(false); + + RoulettePageResponse response = rouletteService.roulettePage(MEMBER, RouletteType.SPECIAL); + + assertThat(response.isPossible()).isTrue(); + assertThat(response.totalPay()).isEqualTo(3500L); + } + + @Test + @DisplayName("오늘 스페셜 룰렛에 이미 참여했으면 결제액이 충분해도 참여할 수 없다") + void shouldRejectSpecialRouletteWhenAlreadyParticipated() { + given(orderRepository.sumApprovedPriceByMemberIdAndDecidedAtBetween( + eq(MEMBER.memberId()), any(LocalDateTime.class), any(LocalDateTime.class))) + .willReturn(10000L); + given(rouletteHistoryRepository + .existsByMemberIdAndRouletteTypeAndParticipatedAtGreaterThanEqualAndParticipatedAtLessThan( + eq(MEMBER.memberId()), eq(RouletteType.SPECIAL), + any(LocalDateTime.class), any(LocalDateTime.class))) + .willReturn(true); + + RoulettePageResponse response = rouletteService.roulettePage(MEMBER, RouletteType.SPECIAL); + + assertThat(response.isPossible()).isFalse(); + assertThat(response.totalPay()).isEqualTo(10000L); + } + + @Test + @DisplayName("오늘 결제액이 3500원 미만이면 스페셜 룰렛에 참여할 수 없다") + void shouldRejectSpecialRouletteWhenPaymentIsInsufficient() { + given(orderRepository.sumApprovedPriceByMemberIdAndDecidedAtBetween( + eq(MEMBER.memberId()), any(LocalDateTime.class), any(LocalDateTime.class))) + .willReturn(3499L); + given(rouletteHistoryRepository + .existsByMemberIdAndRouletteTypeAndParticipatedAtGreaterThanEqualAndParticipatedAtLessThan( + eq(MEMBER.memberId()), eq(RouletteType.SPECIAL), + any(LocalDateTime.class), any(LocalDateTime.class))) + .willReturn(false); + + RoulettePageResponse response = rouletteService.roulettePage(MEMBER, RouletteType.SPECIAL); + + assertThat(response.isPossible()).isFalse(); + assertThat(response.totalPay()).isEqualTo(3499L); + } + @Test @DisplayName("일반 아이템 보상은 아이템과 아이템 이력을 저장하고 보상명을 반환한다") void shouldGrantItemRewardAndReturnRewardName() { @@ -86,12 +180,11 @@ void shouldGrantItemRewardAndReturnRewardName() { @Test @DisplayName("풀세트 한 행으로 옵션권과 매칭권을 모두 지급한다") void shouldGrantEveryFullSetReward() { - RouletteReward fullSet = reward(RouletteType.PAID, "풀세트", null, 0, 2); - Item rouletteTicket = givenReward(RouletteType.PAID, fullSet); + RouletteReward fullSet = reward(RouletteType.SPECIAL, "풀세트", null, 0, 2); + givenReward(RouletteType.SPECIAL, fullSet); - RouletteSpinResponse response = rouletteService.spinRoulette(MEMBER, RouletteType.PAID); + RouletteSpinResponse response = rouletteService.spinRoulette(MEMBER, RouletteType.SPECIAL); - assertThat(rouletteTicket.getQuantity()).isZero(); assertThat(fullSet.getRemainingCount()).isEqualTo(1); ArgumentCaptor itemCaptor = ArgumentCaptor.forClass(Item.class); then(itemRepository).should(times(2)).save(itemCaptor.capture()); @@ -102,39 +195,29 @@ void shouldGrantEveryFullSetReward() { org.assertj.core.groups.Tuple.tuple(ItemType.MATCHING_TICKET, 1) ); ArgumentCaptor itemHistoryCaptor = ArgumentCaptor.forClass(ItemHistory.class); - then(itemHistoryRepository).should(times(3)).save(itemHistoryCaptor.capture()); + then(itemHistoryRepository).should(times(2)).save(itemHistoryCaptor.capture()); assertThat(itemHistoryCaptor.getAllValues()) .extracting(ItemHistory::getItemType, ItemHistory::getHistoryType, ItemHistory::getQuantity) .containsExactly( - org.assertj.core.groups.Tuple.tuple(ItemType.ROULETTE_TICKET, ItemHistoryType.USE, -1), org.assertj.core.groups.Tuple.tuple(ItemType.OPTION_TICKET, ItemHistoryType.EVENT, 3), org.assertj.core.groups.Tuple.tuple(ItemType.MATCHING_TICKET, ItemHistoryType.EVENT, 1) ); - assertHistorySavedWith(fullSet, RouletteType.PAID); - then(rouletteHistoryRepository).should(never()) - .existsByMemberIdAndRouletteTypeAndParticipatedAtGreaterThanEqualAndParticipatedAtLessThan( - eq(MEMBER.memberId()), eq(RouletteType.PAID), any(LocalDateTime.class), any(LocalDateTime.class)); + assertHistorySavedWith(fullSet, RouletteType.SPECIAL); assertThat(response.rewardName()).isEqualTo("풀세트"); } @Test @DisplayName("상품권은 재고만 차감하고 룰렛 이력과 보상명을 남긴다") void shouldRecordGiftCardWithoutGrantingItem() { - RouletteReward reward = reward(RouletteType.PAID, "1만원권 상품권", null, 1, 3); - givenReward(RouletteType.PAID, reward); + RouletteReward reward = reward(RouletteType.SPECIAL, "1만원권 상품권", null, 1, 3); + givenReward(RouletteType.SPECIAL, reward); - RouletteSpinResponse response = rouletteService.spinRoulette(MEMBER, RouletteType.PAID); + RouletteSpinResponse response = rouletteService.spinRoulette(MEMBER, RouletteType.SPECIAL); assertThat(reward.getRemainingCount()).isEqualTo(2); then(itemRepository).should(never()).save(any(Item.class)); - ArgumentCaptor itemHistoryCaptor = ArgumentCaptor.forClass(ItemHistory.class); - then(itemHistoryRepository).should().save(itemHistoryCaptor.capture()); - assertThat(itemHistoryCaptor.getValue().getMemberId()).isEqualTo(MEMBER.memberId()); - assertThat(itemHistoryCaptor.getValue().getItemType()).isEqualTo(ItemType.ROULETTE_TICKET); - assertThat(itemHistoryCaptor.getValue().getHistoryType()).isEqualTo(ItemHistoryType.USE); - assertThat(itemHistoryCaptor.getValue().getQuantity()).isEqualTo(-1); - assertThat(itemHistoryCaptor.getValue().getDescription()).isEqualTo(ItemType.ROULETTE_TICKET.getName()); - assertHistorySavedWith(reward, RouletteType.PAID); + then(itemHistoryRepository).shouldHaveNoInteractions(); + assertHistorySavedWith(reward, RouletteType.SPECIAL); assertThat(response.rewardName()).isEqualTo("1만원권 상품권"); } @@ -163,7 +246,7 @@ void shouldRejectDuplicatedFreeRouletteParticipation() { assertThatThrownBy(() -> rouletteService.spinRoulette(MEMBER, RouletteType.FREE)) .isInstanceOf(BusinessException.class) .satisfies(exception -> assertThat(((BusinessException)exception).getErrorCode()) - .isEqualTo(ItemErrorCode.ALREADY_PARTICIPATED_FREE_ROULETTE)); + .isEqualTo(ItemErrorCode.ALREADY_PARTICIPATED_ROULETTE)); then(itemRepository).shouldHaveNoInteractions(); then(rouletteRewardRepository).shouldHaveNoInteractions(); @@ -172,19 +255,43 @@ void shouldRejectDuplicatedFreeRouletteParticipation() { } @Test - @DisplayName("룰렛 티켓이 없으면 예외가 발생하고 추첨하지 않는다") - void shouldRejectWhenRouletteTicketDoesNotExist() { - given(itemRepository - .findFirstByMemberIdAndItemTypeAndQuantityGreaterThanEqualAndExpiredAtGreaterThanOrderByExpiredAtAscQuantityAsc( - eq(MEMBER.memberId()), eq(ItemType.ROULETTE_TICKET), eq(1), any(LocalDateTime.class))) - .willReturn(Optional.empty()); - - assertThatThrownBy(() -> rouletteService.spinRoulette(MEMBER, RouletteType.PAID)) + @DisplayName("이미 스페셜 룰렛에 참여한 회원은 예외가 발생하고 결제액과 보상을 조회하지 않는다") + void shouldRejectDuplicatedSpecialRouletteParticipation() { + given(rouletteHistoryRepository + .existsByMemberIdAndRouletteTypeAndParticipatedAtGreaterThanEqualAndParticipatedAtLessThan( + eq(MEMBER.memberId()), eq(RouletteType.SPECIAL), + any(LocalDateTime.class), any(LocalDateTime.class))).willReturn(true); + + assertThatThrownBy(() -> rouletteService.spinRoulette(MEMBER, RouletteType.SPECIAL)) .isInstanceOf(BusinessException.class) .satisfies(exception -> assertThat(((BusinessException)exception).getErrorCode()) - .isEqualTo(ItemErrorCode.NOT_ENOUGH_ITEM)); + .isEqualTo(ItemErrorCode.ALREADY_PARTICIPATED_ROULETTE)); + then(orderRepository).shouldHaveNoInteractions(); then(rouletteRewardRepository).shouldHaveNoInteractions(); + then(itemRepository).shouldHaveNoInteractions(); + then(itemHistoryRepository).shouldHaveNoInteractions(); + then(rouletteHistoryRepository).should(never()).save(any(RouletteHistory.class)); + } + + @Test + @DisplayName("오늘 누적 결제액이 3500원 미만이면 예외가 발생하고 추첨하지 않는다") + void shouldRejectSpecialRouletteWhenPaymentIsBelowMinimum() { + given(rouletteHistoryRepository + .existsByMemberIdAndRouletteTypeAndParticipatedAtGreaterThanEqualAndParticipatedAtLessThan( + eq(MEMBER.memberId()), eq(RouletteType.SPECIAL), + any(LocalDateTime.class), any(LocalDateTime.class))).willReturn(false); + given(orderRepository.sumApprovedPriceByMemberIdAndDecidedAtBetween( + eq(MEMBER.memberId()), any(LocalDateTime.class), any(LocalDateTime.class))) + .willReturn(3499L); + + assertThatThrownBy(() -> rouletteService.spinRoulette(MEMBER, RouletteType.SPECIAL)) + .isInstanceOf(BusinessException.class) + .satisfies(exception -> assertThat(((BusinessException)exception).getErrorCode()) + .isEqualTo(ItemErrorCode.NOT_ENOUGH_PAYMENT_FOR_SPECIAL_ROULETTE)); + + then(rouletteRewardRepository).shouldHaveNoInteractions(); + then(itemRepository).shouldHaveNoInteractions(); then(itemHistoryRepository).shouldHaveNoInteractions(); then(rouletteHistoryRepository).should(never()).save(any(RouletteHistory.class)); } @@ -254,7 +361,7 @@ void shouldPropagateRouletteHistorySaveFailure() { @Test @DisplayName("제한 재고는 한 개 차감한다") void shouldDecreaseLimitedRemainingCount() { - RouletteReward reward = reward(RouletteType.PAID, "상품권", null, 1, 2); + RouletteReward reward = reward(RouletteType.SPECIAL, "상품권", null, 1, 2); reward.decreaseRemainingCount(); @@ -274,37 +381,26 @@ void shouldNotChangeUnlimitedRemainingCount() { @Test @DisplayName("소진된 재고는 음수가 되지 않는다") void shouldNotDecreaseRemainingCountBelowZero() { - RouletteReward reward = reward(RouletteType.PAID, "상품권", null, 1, 0); + RouletteReward reward = reward(RouletteType.SPECIAL, "상품권", null, 1, 0); reward.decreaseRemainingCount(); assertThat(reward.getRemainingCount()).isZero(); } - private Item givenReward(RouletteType rouletteType, RouletteReward reward) { - Item rouletteTicket = null; - if (rouletteType == RouletteType.PAID) { - rouletteTicket = givenTicket(); - } - if (rouletteType == RouletteType.FREE) { - given(rouletteHistoryRepository - .existsByMemberIdAndRouletteTypeAndParticipatedAtGreaterThanEqualAndParticipatedAtLessThan( - eq(MEMBER.memberId()), eq(rouletteType), - any(LocalDateTime.class), any(LocalDateTime.class))).willReturn(false); + private void givenReward(RouletteType rouletteType, RouletteReward reward) { + given(rouletteHistoryRepository + .existsByMemberIdAndRouletteTypeAndParticipatedAtGreaterThanEqualAndParticipatedAtLessThan( + eq(MEMBER.memberId()), eq(rouletteType), + any(LocalDateTime.class), any(LocalDateTime.class))).willReturn(false); + if (rouletteType == RouletteType.SPECIAL) { + given(orderRepository.sumApprovedPriceByMemberIdAndDecidedAtBetween( + eq(MEMBER.memberId()), any(LocalDateTime.class), any(LocalDateTime.class))) + .willReturn(3500L); } given(rouletteRewardRepository .findAvailableByRouletteTypeAndRouletteNumber(eq(rouletteType), anyInt())) .willReturn(Optional.of(reward)); - return rouletteTicket; - } - - private Item givenTicket() { - Item rouletteTicket = rouletteTicket(); - given(itemRepository - .findFirstByMemberIdAndItemTypeAndQuantityGreaterThanEqualAndExpiredAtGreaterThanOrderByExpiredAtAscQuantityAsc( - eq(MEMBER.memberId()), eq(ItemType.ROULETTE_TICKET), eq(1), any(LocalDateTime.class))) - .willReturn(Optional.of(rouletteTicket)); - return rouletteTicket; } private void assertHistorySavedWith(RouletteReward reward, RouletteType rouletteType) { @@ -333,12 +429,4 @@ private RouletteReward reward( .build(); } - private Item rouletteTicket() { - return Item.builder() - .memberId(MEMBER.memberId()) - .itemType(ItemType.ROULETTE_TICKET) - .quantity(1) - .expiredAt(LocalDateTime.now().plusDays(1)) - .build(); - } } diff --git a/item-service/src/test/java/com/comatching/item/infra/controller/RouletteControllerTest.java b/item-service/src/test/java/com/comatching/item/infra/controller/RouletteControllerTest.java index b284f89..da6ca51 100644 --- a/item-service/src/test/java/com/comatching/item/infra/controller/RouletteControllerTest.java +++ b/item-service/src/test/java/com/comatching/item/infra/controller/RouletteControllerTest.java @@ -5,6 +5,7 @@ import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.then; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -24,7 +25,8 @@ import com.comatching.common.exception.code.GeneralErrorCode; import com.comatching.common.exception.handler.GlobalExceptionHandler; import com.comatching.common.resolver.MemberInfoArgumentResolver; -import com.comatching.item.domain.roulette.dto.RouletteSpinResponse; +import com.comatching.item.domain.roulette.dto.response.RoulettePageResponse; +import com.comatching.item.domain.roulette.dto.response.RouletteSpinResponse; import com.comatching.item.domain.roulette.enums.RouletteType; import com.comatching.item.domain.roulette.service.RouletteService; import com.fasterxml.jackson.databind.ObjectMapper; @@ -50,13 +52,12 @@ void setUp() { } @Test - @DisplayName("POST /api/roulette/spins는 회원과 룰렛 타입을 전달하고 보상명을 반환한다") + @DisplayName("POST /api/roulette/{rouletteType}/spins는 회원과 룰렛 타입을 전달하고 보상명을 반환한다") void shouldSpinRouletteAndReturnRewardName() throws Exception { - given(rouletteService.spinRoulette(any(MemberInfo.class), eq(RouletteType.PAID))) + given(rouletteService.spinRoulette(any(MemberInfo.class), eq(RouletteType.SPECIAL))) .willReturn(new RouletteSpinResponse("2만원권 상품권")); - mockMvc.perform(post("/api/roulette/spins") - .param("rouletteType", "PAID") + mockMvc.perform(post("/api/roulette/SPECIAL/spins") .header("X-Member-Id", "1") .header("X-Member-Email", "member@example.com") .header("X-Member-Role", "USER")) @@ -66,16 +67,75 @@ void shouldSpinRouletteAndReturnRewardName() throws Exception { .andExpect(jsonPath("$.data.rewardName").value("2만원권 상품권")); ArgumentCaptor memberCaptor = ArgumentCaptor.forClass(MemberInfo.class); - then(rouletteService).should().spinRoulette(memberCaptor.capture(), eq(RouletteType.PAID)); + then(rouletteService).should().spinRoulette(memberCaptor.capture(), eq(RouletteType.SPECIAL)); assertThat(memberCaptor.getValue().memberId()).isEqualTo(1L); assertThat(memberCaptor.getValue().email()).isEqualTo("member@example.com"); assertThat(memberCaptor.getValue().role()).isEqualTo("USER"); } @Test - @DisplayName("rouletteType 요청 파라미터가 없으면 400을 반환한다") - void shouldRejectMissingRouletteType() throws Exception { - mockMvc.perform(post("/api/roulette/spins") + @DisplayName("지원하지 않는 rouletteType 경로 값이면 400을 반환한다") + void shouldRejectUnsupportedRouletteType() throws Exception { + mockMvc.perform(post("/api/roulette/PAID/spins") + .header("X-Member-Id", "1")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(GeneralErrorCode.TYPE_MISMATCH.getCode())); + + then(rouletteService).shouldHaveNoInteractions(); + } + + @Test + @DisplayName("GET /api/roulette는 FREE 룰렛 참여 가능 여부와 null 결제액을 반환한다") + void shouldReturnFreeRoulettePage() throws Exception { + given(rouletteService.roulettePage(any(MemberInfo.class), eq(RouletteType.FREE))) + .willReturn(new RoulettePageResponse(true, null)); + + mockMvc.perform(get("/api/roulette") + .param("rouletteType", "FREE") + .header("X-Member-Id", "1") + .header("X-Member-Email", "member@example.com") + .header("X-Member-Role", "USER")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("GEN-000")) + .andExpect(jsonPath("$.status").value(200)) + .andExpect(jsonPath("$.data.isPossible").value(true)) + .andExpect(jsonPath("$.data.totalPay").doesNotExist()); + + ArgumentCaptor memberCaptor = ArgumentCaptor.forClass(MemberInfo.class); + then(rouletteService).should().roulettePage(memberCaptor.capture(), eq(RouletteType.FREE)); + assertThat(memberCaptor.getValue().memberId()).isEqualTo(1L); + assertThat(memberCaptor.getValue().email()).isEqualTo("member@example.com"); + assertThat(memberCaptor.getValue().role()).isEqualTo("USER"); + } + + @Test + @DisplayName("GET /api/roulette는 SPECIAL 룰렛 참여 가능 여부와 오늘 결제액을 반환한다") + void shouldReturnSpecialRoulettePage() throws Exception { + given(rouletteService.roulettePage(any(MemberInfo.class), eq(RouletteType.SPECIAL))) + .willReturn(new RoulettePageResponse(false, 3499L)); + + mockMvc.perform(get("/api/roulette") + .param("rouletteType", "SPECIAL") + .header("X-Member-Id", "1") + .header("X-Member-Email", "member@example.com") + .header("X-Member-Role", "USER")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("GEN-000")) + .andExpect(jsonPath("$.status").value(200)) + .andExpect(jsonPath("$.data.isPossible").value(false)) + .andExpect(jsonPath("$.data.totalPay").value(3499)); + + ArgumentCaptor memberCaptor = ArgumentCaptor.forClass(MemberInfo.class); + then(rouletteService).should().roulettePage(memberCaptor.capture(), eq(RouletteType.SPECIAL)); + assertThat(memberCaptor.getValue().memberId()).isEqualTo(1L); + assertThat(memberCaptor.getValue().email()).isEqualTo("member@example.com"); + assertThat(memberCaptor.getValue().role()).isEqualTo("USER"); + } + + @Test + @DisplayName("GET /api/roulette는 rouletteType 쿼리 파라미터가 없으면 400을 반환한다") + void shouldRejectMissingRouletteTypeOnRoulettePage() throws Exception { + mockMvc.perform(get("/api/roulette") .header("X-Member-Id", "1")) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.code").value(GeneralErrorCode.MISSING_REQUEST_PARAMETER.getCode())); From b3572cf504029139584490ac2fc02a9aacbbba4f Mon Sep 17 00:00:00 2001 From: jinwook219 Date: Thu, 3 Sep 2026 17:07:05 +0900 Subject: [PATCH 13/22] =?UTF-8?q?fix(item):=20=EB=A3=B0=EB=A0=9B=20?= =?UTF-8?q?=EB=8F=99=EC=8B=9C=20=EC=B0=B8=EC=97=AC=EC=9D=98=20=EB=B3=B4?= =?UTF-8?q?=EC=83=81=20=EC=A4=91=EB=B3=B5=20=EC=A7=80=EA=B8=89=20=EB=B0=A9?= =?UTF-8?q?=EC=A7=80=20=20=20=20=20-=20FREE=EC=99=80=20SPECIAL=20=EC=B0=B8?= =?UTF-8?q?=EC=97=AC=20=EC=9D=B4=EB=A0=A5=EC=97=90=20=ED=9A=8C=EC=9B=90?= =?UTF-8?q?=C2=B7=ED=83=80=EC=9E=85=C2=B7=EB=82=A0=EC=A7=9C=20=EC=9C=A0?= =?UTF-8?q?=EC=9D=BC=20=EC=A0=9C=EC=95=BD=20=EC=A0=81=EC=9A=A9=20=20=20=20?= =?UTF-8?q?=20-=20=EC=B0=B8=EC=97=AC=20=EC=9D=B4=EB=A0=A5=EC=9D=84=20?= =?UTF-8?q?=EC=A6=89=EC=8B=9C=20flush=ED=95=98=EC=97=AC=20=EB=8F=99?= =?UTF-8?q?=EC=8B=9C=20=EC=A4=91=EB=B3=B5=20=EC=9A=94=EC=B2=AD=EC=9D=84=20?= =?UTF-8?q?ITEM-007=EB=A1=9C=20=EB=B3=80=ED=99=98=20=20=20=20=20-=20?= =?UTF-8?q?=EB=8F=99=EC=9D=BC=20=ED=9A=8C=EC=9B=90=EC=9D=98=20FREE=C2=B7SP?= =?UTF-8?q?ECIAL=20=EB=8F=99=EC=8B=9C=20=EC=9A=94=EC=B2=AD=EC=97=90?= =?UTF-8?q?=EC=84=9C=20=EB=B3=B4=EC=83=81=20=EC=A4=91=EB=B3=B5=20=EC=A7=80?= =?UTF-8?q?=EA=B8=89=20=EB=B0=A9=EC=A7=80=20=EA=B2=80=EC=A6=9D=20=20=20=20?= =?UTF-8?q?=20-=20=EB=82=A0=EC=A7=9C=EB=B3=84=20=EC=B0=B8=EC=97=AC=20?= =?UTF-8?q?=EC=9C=A0=EC=9D=BC=20=EC=A0=9C=EC=95=BD=EA=B3=BC=20=EB=8B=B9?= =?UTF-8?q?=EC=9D=BC=20=EC=8A=B9=EC=9D=B8=20=EA=B2=B0=EC=A0=9C=EC=95=A1=20?= =?UTF-8?q?=ED=95=A9=EC=82=B0=20=ED=85=8C=EC=8A=A4=ED=8A=B8=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 --- .../roulette/entity/RouletteHistory.java | 15 ++-- .../roulette/service/RouletteServiceImpl.java | 16 +++-- .../service/RouletteConcurrencyTest.java | 63 +++++++++++++++-- .../service/RouletteRewardRepositoryTest.java | 69 +++++++++++++++++++ 4 files changed, 149 insertions(+), 14 deletions(-) diff --git a/item-service/src/main/java/com/comatching/item/domain/roulette/entity/RouletteHistory.java b/item-service/src/main/java/com/comatching/item/domain/roulette/entity/RouletteHistory.java index bb1f2f7..20ba3ae 100644 --- a/item-service/src/main/java/com/comatching/item/domain/roulette/entity/RouletteHistory.java +++ b/item-service/src/main/java/com/comatching/item/domain/roulette/entity/RouletteHistory.java @@ -16,6 +16,7 @@ import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; import lombok.AccessLevel; import lombok.Builder; import lombok.Getter; @@ -24,7 +25,13 @@ @Entity @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) -@Table(name = "roulette_history") +@Table( + name = "roulette_history", + uniqueConstraints = @UniqueConstraint( + name = "uk_roulette_history_member_type_date", + columnNames = {"member_id", "roulette_type", "participation_date"} + ) +) public class RouletteHistory { @Id @@ -45,7 +52,7 @@ public class RouletteHistory { @Column(name = "participated_at",nullable = false) private final LocalDateTime participatedAt = LocalDateTime.now(); - @Column(name = "participation_date") + @Column(name = "participation_date", nullable = false) private LocalDate participationDate; @Builder @@ -57,8 +64,6 @@ public RouletteHistory( this.memberId = memberId; this.reward = reward; this.rouletteType = rouletteType; - this.participationDate = rouletteType == RouletteType.FREE - ? participatedAt.toLocalDate() - : null; + this.participationDate = participatedAt.toLocalDate(); } } diff --git a/item-service/src/main/java/com/comatching/item/domain/roulette/service/RouletteServiceImpl.java b/item-service/src/main/java/com/comatching/item/domain/roulette/service/RouletteServiceImpl.java index 82463a7..f6dbcaf 100644 --- a/item-service/src/main/java/com/comatching/item/domain/roulette/service/RouletteServiceImpl.java +++ b/item-service/src/main/java/com/comatching/item/domain/roulette/service/RouletteServiceImpl.java @@ -23,6 +23,7 @@ import com.comatching.item.domain.roulette.repository.RouletteRewardRepository; import com.comatching.item.global.exception.ItemErrorCode; import lombok.AllArgsConstructor; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -119,11 +120,16 @@ public RouletteSpinResponse spinRoulette(MemberInfo memberInfo, RouletteType rou rouletteReward.get().decreaseRemainingCount(); - rouletteHistoryRepository.save(RouletteHistory.builder() - .memberId(memberInfo.memberId()) - .reward(rouletteReward.get()) - .rouletteType(rouletteType) - .build()); + try { + rouletteHistoryRepository.save(RouletteHistory.builder() + .memberId(memberInfo.memberId()) + .reward(rouletteReward.get()) + .rouletteType(rouletteType) + .build()); + rouletteHistoryRepository.flush(); + } catch (DataIntegrityViolationException exception) { + throw new BusinessException(ItemErrorCode.ALREADY_PARTICIPATED_ROULETTE); + } return new RouletteSpinResponse(rouletteReward.get().getRewardName()); diff --git a/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteConcurrencyTest.java b/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteConcurrencyTest.java index 9449e3c..c9aee33 100644 --- a/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteConcurrencyTest.java +++ b/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteConcurrencyTest.java @@ -1,6 +1,9 @@ package com.comatching.item.domain.roulette.service; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; import java.util.ArrayList; import java.util.List; @@ -10,7 +13,6 @@ import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; -import org.junit.jupiter.api.Disabled; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -29,6 +31,7 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.support.TransactionTemplate; +import com.comatching.common.domain.enums.ItemType; import com.comatching.common.dto.member.MemberInfo; import com.comatching.common.exception.BusinessException; import com.comatching.item.domain.item.entity.Item; @@ -59,6 +62,8 @@ class RouletteConcurrencyTest { @Autowired private ItemRepository itemRepository; @Autowired + private ItemHistoryRepository itemHistoryRepository; + @Autowired private JdbcTemplate jdbcTemplate; @Autowired private PlatformTransactionManager transactionManager; @@ -87,13 +92,12 @@ void singleRewardStockIsDecreasedForEveryConcurrentSpin() throws Exception { } @Test - @Disabled("참여 이력 조회와 저장 사이의 동시성 제어를 추가할 때 활성화") @Transactional(propagation = Propagation.NOT_SUPPORTED) @DisplayName("동일 회원의 무료 룰렛 동시 요청은 하루에 한 번만 성공한다") void freeRouletteSucceedsOnlyOnceForSameMember() throws Exception { Long memberId = 50L; - inTransaction(() -> rouletteRewardRepository.save( - reward(RouletteType.FREE, "기본 보상", 1, 10000, null))); + Long rewardId = inTransaction(() -> rouletteRewardRepository.save( + itemReward(RouletteType.FREE, "옵션권 1장", REQUEST_COUNT)).getId()); List failures = runConcurrently(REQUEST_COUNT, index -> rouletteService.spinRoulette(member(memberId), RouletteType.FREE)); @@ -106,6 +110,41 @@ void freeRouletteSucceedsOnlyOnceForSameMember() throws Exception { }); assertThat(count("SELECT COUNT(*) FROM roulette_history WHERE member_id = ?", memberId)) .isOne(); + assertThat(rouletteRewardRepository.findById(rewardId).orElseThrow().getRemainingCount()) + .isEqualTo(REQUEST_COUNT - 1); + assertThat(itemRepository.count()).isOne(); + assertThat(itemHistoryRepository.count()).isOne(); + } + + @Test + @Transactional(propagation = Propagation.NOT_SUPPORTED) + @DisplayName("동일 회원의 스페셜 룰렛 동시 요청은 하루에 한 번만 성공한다") + void specialRouletteSucceedsOnlyOnceForSameMember() throws Exception { + Long memberId = 51L; + Long rewardId = inTransaction(() -> rouletteRewardRepository.save( + itemReward(RouletteType.SPECIAL, "옵션권 1장", REQUEST_COUNT)).getId()); + given(orderRepository.sumApprovedPriceByMemberIdAndDecidedAtBetween( + eq(memberId), any(), any())) + .willReturn(3500L); + + List failures = runConcurrently(REQUEST_COUNT, index -> + rouletteService.spinRoulette(member(memberId), RouletteType.SPECIAL)); + + assertThat(failures).hasSize(REQUEST_COUNT - 1) + .allSatisfy(failure -> { + assertThat(failure).isInstanceOf(BusinessException.class); + assertThat(((BusinessException)failure).getErrorCode()) + .isEqualTo(ItemErrorCode.ALREADY_PARTICIPATED_ROULETTE); + }); + assertThat(count( + "SELECT COUNT(*) FROM roulette_history WHERE member_id = ? AND roulette_type = ?", + memberId, + RouletteType.SPECIAL.name())) + .isOne(); + assertThat(rouletteRewardRepository.findById(rewardId).orElseThrow().getRemainingCount()) + .isEqualTo(REQUEST_COUNT - 1); + assertThat(itemRepository.count()).isOne(); + assertThat(itemHistoryRepository.count()).isOne(); } private List runConcurrently(int threadCount, ThrowingIntConsumer task) throws Exception { @@ -165,6 +204,22 @@ private RouletteReward reward( .build(); } + private RouletteReward itemReward( + RouletteType rouletteType, + String rewardName, + Integer remainingCount + ) { + return RouletteReward.builder() + .rouletteType(rouletteType) + .rewardName(rewardName) + .itemType(ItemType.OPTION_TICKET) + .quantity(1) + .rangeStart(1) + .rangeEnd(10000) + .remainingCount(remainingCount) + .build(); + } + private MemberInfo member(Long memberId) { return new MemberInfo(memberId, "member" + memberId + "@example.com", "USER"); } diff --git a/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteRewardRepositoryTest.java b/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteRewardRepositoryTest.java index 7e2a503..49ecaa8 100644 --- a/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteRewardRepositoryTest.java +++ b/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteRewardRepositoryTest.java @@ -12,6 +12,7 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; +import org.springframework.dao.DataIntegrityViolationException; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.SpringBootConfiguration; import org.springframework.boot.autoconfigure.EnableAutoConfiguration; @@ -45,6 +46,7 @@ @DataJpaTest @ContextConfiguration(classes = RouletteRewardRepositoryTest.JpaTestConfig.class) @Import(RouletteServiceImpl.class) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD) @DisplayName("Roulette Repository 및 트랜잭션 테스트") class RouletteRewardRepositoryTest { @@ -172,6 +174,49 @@ void shouldNotFindSpecialHistoryAsFreeHistory() { assertThat(exists).isFalse(); } + @Test + @Transactional(propagation = Propagation.NOT_SUPPORTED) + @DisplayName("같은 회원의 같은 날짜와 같은 타입 참여 이력은 한 행만 저장된다") + void shouldRejectDuplicateHistoryForSameMemberTypeAndDate() { + Long rewardId = saveRewardInTransaction(RouletteType.FREE); + saveHistoryInTransaction(10L, RouletteType.FREE, rewardId); + + assertThatThrownBy(() -> saveHistoryInTransaction(10L, RouletteType.FREE, rewardId)) + .isInstanceOf(DataIntegrityViolationException.class); + assertThat(countHistory(10L, RouletteType.FREE)).isOne(); + } + + @Test + @Transactional(propagation = Propagation.NOT_SUPPORTED) + @DisplayName("같은 회원은 같은 날짜에 무료와 스페셜 룰렛에 각각 참여할 수 있다") + void shouldAllowFreeAndSpecialHistoryOnSameDate() { + Long rewardId = saveRewardInTransaction(RouletteType.FREE); + + saveHistoryInTransaction(11L, RouletteType.FREE, rewardId); + saveHistoryInTransaction(11L, RouletteType.SPECIAL, rewardId); + + assertThat(countHistory(11L, RouletteType.FREE)).isOne(); + assertThat(countHistory(11L, RouletteType.SPECIAL)).isOne(); + } + + @Test + @Transactional(propagation = Propagation.NOT_SUPPORTED) + @DisplayName("같은 회원은 날짜가 바뀌면 같은 타입 룰렛에 다시 참여할 수 있다") + void shouldAllowSameRouletteTypeOnNextDate() { + Long rewardId = saveRewardInTransaction(RouletteType.FREE); + saveHistoryInTransaction(12L, RouletteType.FREE, rewardId); + LocalDate yesterday = LocalDate.now().minusDays(1); + jdbcTemplate.update( + "UPDATE roulette_history SET participated_at = ?, participation_date = ? WHERE member_id = ?", + Timestamp.valueOf(yesterday.atTime(12, 0)), + yesterday, + 12L); + + saveHistoryInTransaction(12L, RouletteType.FREE, rewardId); + + assertThat(countHistory(12L, RouletteType.FREE)).isEqualTo(2); + } + @Test @Transactional(propagation = Propagation.NOT_SUPPORTED) @DirtiesContext(methodMode = DirtiesContext.MethodMode.AFTER_METHOD) @@ -199,6 +244,30 @@ private RouletteReward persist(RouletteReward reward) { return entityManager.persist(reward); } + private Long saveRewardInTransaction(RouletteType rouletteType) { + return new TransactionTemplate(transactionManager).execute(status -> + rouletteRewardRepository.saveAndFlush( + reward(rouletteType, "기본 보상", 1, 10000, null)).getId()); + } + + private void saveHistoryInTransaction(Long memberId, RouletteType rouletteType, Long rewardId) { + new TransactionTemplate(transactionManager).executeWithoutResult(status -> + rouletteHistoryRepository.saveAndFlush(RouletteHistory.builder() + .memberId(memberId) + .reward(rouletteRewardRepository.findById(rewardId).orElseThrow()) + .rouletteType(rouletteType) + .build())); + } + + private long countHistory(Long memberId, RouletteType rouletteType) { + Long count = jdbcTemplate.queryForObject( + "SELECT COUNT(*) FROM roulette_history WHERE member_id = ? AND roulette_type = ?", + Long.class, + memberId, + rouletteType.name()); + return count == null ? 0L : count; + } + private void flushAndClear() { entityManager.flush(); entityManager.clear(); From 3cb0a2e6b1c7c501069d16bf6c9051a92eca87c0 Mon Sep 17 00:00:00 2001 From: jinwook219 Date: Tue, 8 Sep 2026 11:55:50 +0900 Subject: [PATCH 14/22] =?UTF-8?q?test(item):=20=EC=8A=A4=ED=8E=98=EC=85=9C?= =?UTF-8?q?=20=EB=A3=B0=EB=A0=9B=20=EB=8B=B9=EC=9D=BC=20=EC=8A=B9=EC=9D=B8?= =?UTF-8?q?=20=EA=B2=B0=EC=A0=9C=EC=95=A1=20=EC=A1=B0=ED=9A=8C=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20=20=20-=20=EB=B3=B8=EC=9D=B8=EC=9D=98=20=EC=8A=B9?= =?UTF-8?q?=EC=9D=B8=EB=90=9C=20=EC=A3=BC=EB=AC=B8=EB=A7=8C=20=EB=88=84?= =?UTF-8?q?=EC=A0=81=20=EA=B2=B0=EC=A0=9C=EC=95=A1=EC=97=90=20=ED=8F=AC?= =?UTF-8?q?=ED=95=A8=EB=90=98=EB=8A=94=EC=A7=80=20=EA=B2=80=EC=A6=9D=20=20?= =?UTF-8?q?=20-=20=EC=A1=B0=ED=9A=8C=20=EC=8B=9C=EA=B0=84=20=EB=B2=94?= =?UTF-8?q?=EC=9C=84=EC=9D=98=20=EC=8B=9C=EC=9E=91=20=ED=8F=AC=ED=95=A8?= =?UTF-8?q?=C2=B7=EC=A2=85=EB=A3=8C=20=EC=A0=9C=EC=99=B8=20=EC=A1=B0?= =?UTF-8?q?=EA=B1=B4=20=EA=B2=80=EC=A6=9D=20=20=20-=20=EB=8B=A4=EB=A5=B8?= =?UTF-8?q?=20=ED=9A=8C=EC=9B=90=EA=B3=BC=20=EC=8A=B9=EC=9D=B8=20=EA=B1=B0?= =?UTF-8?q?=EC=A0=88=20=EC=A3=BC=EB=AC=B8=EC=9D=B4=20=ED=95=A9=EC=82=B0?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EC=A0=9C=EC=99=B8=EB=90=98=EB=8A=94?= =?UTF-8?q?=EC=A7=80=20=EA=B2=80=EC=A6=9D=20=20=20-=20=EA=B2=B0=EC=A0=9C?= =?UTF-8?q?=20=EB=82=B4=EC=97=AD=EC=9D=B4=20=EC=97=86=EB=8A=94=20=ED=9A=8C?= =?UTF-8?q?=EC=9B=90=EC=9D=98=20=ED=95=A9=EA=B3=84=EA=B0=80=200=EC=9D=B8?= =?UTF-8?q?=EC=A7=80=20=EA=B2=80=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../SpecialRoulettePaymentRepositoryTest.java | 85 +++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 item-service/src/test/java/com/comatching/item/domain/roulette/service/SpecialRoulettePaymentRepositoryTest.java diff --git a/item-service/src/test/java/com/comatching/item/domain/roulette/service/SpecialRoulettePaymentRepositoryTest.java b/item-service/src/test/java/com/comatching/item/domain/roulette/service/SpecialRoulettePaymentRepositoryTest.java new file mode 100644 index 0000000..feb4d4a --- /dev/null +++ b/item-service/src/test/java/com/comatching/item/domain/roulette/service/SpecialRoulettePaymentRepositoryTest.java @@ -0,0 +1,85 @@ +package com.comatching.item.domain.roulette.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.time.LocalDate; +import java.time.LocalDateTime; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.SpringBootConfiguration; +import org.springframework.boot.autoconfigure.EnableAutoConfiguration; +import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; +import org.springframework.test.context.ContextConfiguration; + +import com.comatching.item.domain.order.entity.Order; +import com.comatching.item.domain.order.entity.OrderItem; +import com.comatching.item.domain.order.repository.OrderRepository; + +@DataJpaTest +@ContextConfiguration(classes = SpecialRoulettePaymentRepositoryTest.JpaTestConfig.class) +@DisplayName("스페셜 룰렛 결제 금액 조회 테스트") +class SpecialRoulettePaymentRepositoryTest { + + @Autowired + private OrderRepository orderRepository; + + @Test + @DisplayName("오늘 승인된 본인 주문만 시작 포함 종료 제외 조건으로 합산한다") + void shouldSumOnlyMembersApprovedPaymentsWithinToday() { + Long memberId = 1L; + LocalDateTime todayStart = LocalDate.now().atStartOfDay(); + LocalDateTime tomorrowStart = todayStart.plusDays(1); + + saveApprovedOrder(memberId, 1000, todayStart); + saveApprovedOrder(memberId, 2500, tomorrowStart.minusSeconds(1)); + saveApprovedOrder(memberId, 9000, todayStart.minusSeconds(1)); + saveApprovedOrder(memberId, 9000, tomorrowStart); + saveApprovedOrder(2L, 9000, todayStart.plusHours(1)); + saveRejectedOrder(memberId, 9000, todayStart.plusHours(1)); + + long totalPay = orderRepository.sumApprovedPriceByMemberIdAndDecidedAtBetween( + memberId, todayStart, tomorrowStart); + + assertThat(totalPay).isEqualTo(3500L); + assertThat(orderRepository.sumApprovedPriceByMemberIdAndDecidedAtBetween( + 99L, todayStart, tomorrowStart)).isZero(); + } + + private void saveApprovedOrder(Long memberId, int expectedPrice, LocalDateTime decidedAt) { + Order order = order(memberId, expectedPrice, decidedAt); + order.approve(decidedAt); + orderRepository.save(order); + } + + private void saveRejectedOrder(Long memberId, int expectedPrice, LocalDateTime decidedAt) { + Order order = order(memberId, expectedPrice, decidedAt); + order.reject(decidedAt); + orderRepository.save(order); + } + + private Order order(Long memberId, int expectedPrice, LocalDateTime requestedAt) { + return Order.builder() + .memberId(memberId) + .productId(1L) + .productCode("SPECIAL-ROULETTE-TEST") + .requestedItemName("테스트 상품") + .requesterRealName("테스트 회원") + .requesterUsername("test-user") + .requestedPrice(expectedPrice) + .expectedPrice(expectedPrice) + .requestedAt(requestedAt) + .expiresAt(requestedAt.plusMinutes(10)) + .build(); + } + + @SpringBootConfiguration + @EnableAutoConfiguration + @EntityScan(basePackageClasses = {Order.class, OrderItem.class}) + @EnableJpaRepositories(basePackageClasses = OrderRepository.class) + static class JpaTestConfig { + } +} From 48b239d947ea9253f845b226c784211b3cf5a2ae Mon Sep 17 00:00:00 2001 From: choi-jin-wook Date: Tue, 8 Sep 2026 14:22:17 +0900 Subject: [PATCH 15/22] =?UTF-8?q?refactor(item):=20=EB=A3=B0=EB=A0=9B=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EB=B0=8F=20=EB=B3=B4=EC=83=81=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC=20=EA=B5=AC=EC=A1=B0=20=EA=B0=9C=EC=84=A0=20=20=20=20?= =?UTF-8?q?=20-=20=EB=A3=B0=EB=A0=9B=20=ED=8E=98=EC=9D=B4=EC=A7=80?= =?UTF-8?q?=EC=97=90=EC=84=9C=20=EB=AC=B4=EB=A3=8C=C2=B7=EC=8A=A4=ED=8E=98?= =?UTF-8?q?=EC=85=9C=20=EC=B0=B8=EC=97=AC=20=EC=97=AC=EB=B6=80=EC=99=80=20?= =?UTF-8?q?=EB=8B=B9=EC=9D=BC=20=EA=B2=B0=EC=A0=9C=EC=95=A1=EC=9D=84=20?= =?UTF-8?q?=ED=95=A8=EA=BB=98=20=EB=B0=98=ED=99=98=20=20=20=20=20-=20?= =?UTF-8?q?=EB=A3=B0=EB=A0=9B=20=ED=83=80=EC=9E=85=EB=B3=84=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=ED=8C=8C=EB=9D=BC=EB=AF=B8=ED=84=B0=EB=A5=BC=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0=ED=95=98=EA=B3=A0=20=EC=9D=91=EB=8B=B5=20DTO?= =?UTF-8?q?=20=EA=B5=AC=EC=A1=B0=20=EB=B3=80=EA=B2=BD=20=20=20=20=20-=20?= =?UTF-8?q?=EB=B3=B4=EC=83=81=20=EC=B6=94=EC=B2=A8=EA=B3=BC=20=EC=A7=80?= =?UTF-8?q?=EA=B8=89=20=EB=A1=9C=EC=A7=81=EC=9D=84=20=EB=B3=84=EB=8F=84=20?= =?UTF-8?q?=EB=A9=94=EC=84=9C=EB=93=9C=EB=A1=9C=20=EB=B6=84=EB=A6=AC=20=20?= =?UTF-8?q?=20=20=20-=20=EB=B3=80=EA=B2=BD=EB=90=9C=20=EB=A3=B0=EB=A0=9B?= =?UTF-8?q?=20=EC=A1=B0=ED=9A=8C=20API=20=EA=B3=84=EC=95=BD=EC=97=90=20?= =?UTF-8?q?=EB=A7=9E=EC=B6=B0=20=EC=84=9C=EB=B9=84=EC=8A=A4=C2=B7=EC=BB=A8?= =?UTF-8?q?=ED=8A=B8=EB=A1=A4=EB=9F=AC=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/response/RoulettePageResponse.java | 7 +- .../repository/RouletteRewardRepository.java | 10 ++ .../roulette/service/RouletteService.java | 2 +- .../roulette/service/RouletteServiceImpl.java | 132 ++++++++---------- .../item/global/exception/ItemErrorCode.java | 2 +- .../infra/controller/RouletteController.java | 5 +- .../service/RouletteServiceImplTest.java | 68 +++++---- .../controller/RouletteControllerTest.java | 49 ++++--- 8 files changed, 151 insertions(+), 124 deletions(-) diff --git a/item-service/src/main/java/com/comatching/item/domain/roulette/dto/response/RoulettePageResponse.java b/item-service/src/main/java/com/comatching/item/domain/roulette/dto/response/RoulettePageResponse.java index fe924bd..e3ff3f3 100644 --- a/item-service/src/main/java/com/comatching/item/domain/roulette/dto/response/RoulettePageResponse.java +++ b/item-service/src/main/java/com/comatching/item/domain/roulette/dto/response/RoulettePageResponse.java @@ -1,7 +1,8 @@ package com.comatching.item.domain.roulette.dto.response; public record RoulettePageResponse( - boolean isPossible, - Long totalPay + boolean isFreeParticipated, // 무료 참여 여부 + boolean isSpecialParticipated, // 스페셜 참여 여부 + long totalPay // 결제 금액 ) { -} +} \ No newline at end of file diff --git a/item-service/src/main/java/com/comatching/item/domain/roulette/repository/RouletteRewardRepository.java b/item-service/src/main/java/com/comatching/item/domain/roulette/repository/RouletteRewardRepository.java index 9909a0b..7fd8fd8 100644 --- a/item-service/src/main/java/com/comatching/item/domain/roulette/repository/RouletteRewardRepository.java +++ b/item-service/src/main/java/com/comatching/item/domain/roulette/repository/RouletteRewardRepository.java @@ -26,4 +26,14 @@ public interface RouletteRewardRepository extends JpaRepository findAvailableByRouletteTypeAndRouletteNumber( @Param("rouletteType") RouletteType rouletteType, @Param("rouletteNumber") int rouletteNumber); + + + @Query(""" + SELECT COUNT(rr) > 0 + FROM RouletteReward rr + WHERE rr.rouletteType = :rouletteType + AND (rr.remainingCount IS NULL OR rr.remainingCount > 0) + """) + boolean existsAvailableByRouletteType( + @Param("rouletteType") RouletteType rouletteType); } diff --git a/item-service/src/main/java/com/comatching/item/domain/roulette/service/RouletteService.java b/item-service/src/main/java/com/comatching/item/domain/roulette/service/RouletteService.java index 155bbed..382eb34 100644 --- a/item-service/src/main/java/com/comatching/item/domain/roulette/service/RouletteService.java +++ b/item-service/src/main/java/com/comatching/item/domain/roulette/service/RouletteService.java @@ -8,5 +8,5 @@ public interface RouletteService { RouletteSpinResponse spinRoulette(MemberInfo memberInfo, RouletteType rouletteType); - RoulettePageResponse roulettePage(MemberInfo memberInfo, RouletteType rouletteType); + RoulettePageResponse roulettePage(MemberInfo memberInfo); } diff --git a/item-service/src/main/java/com/comatching/item/domain/roulette/service/RouletteServiceImpl.java b/item-service/src/main/java/com/comatching/item/domain/roulette/service/RouletteServiceImpl.java index f6dbcaf..12be3b5 100644 --- a/item-service/src/main/java/com/comatching/item/domain/roulette/service/RouletteServiceImpl.java +++ b/item-service/src/main/java/com/comatching/item/domain/roulette/service/RouletteServiceImpl.java @@ -53,104 +53,96 @@ public RouletteSpinResponse spinRoulette(MemberInfo memberInfo, RouletteType rou throw new BusinessException(ItemErrorCode.ALREADY_PARTICIPATED_ROULETTE); } - // 결제액이 3500이하면 불가능 + // 결제액이 3500미만이면 불가능 if (rouletteType == RouletteType.SPECIAL && orderRepository.sumApprovedPriceByMemberIdAndDecidedAtBetween( memberInfo.memberId(), todayStart, tomorrowStart) < SPECIAL_ROULETTE_MINIMUM_PAYMENT) { throw new BusinessException(ItemErrorCode.NOT_ENOUGH_PAYMENT_FOR_SPECIAL_ROULETTE); } - // 1 ~ 10000 사이의 난수 생성한 후에 db에 확률 range에 맞게 보상 가져오기 (만약 보상의 남은 갯수가 없다면 다시 난수 생성해서 추첨) - Optional rouletteReward; - do { - int rouletteNumber = ThreadLocalRandom.current().nextInt(1, 10_001); - rouletteReward = rouletteRewardRepository - .findAvailableByRouletteTypeAndRouletteNumber(rouletteType, rouletteNumber); - } while (rouletteReward.isEmpty()); - - + RouletteReward rouletteReward = drawAvailableReward(rouletteType); // 보상에 따른 아이템 및 아이템 기록 룰렛 기록 추가 - if (rouletteReward.get().getRewardName().equals("풀세트")) { - itemRepository.save(Item.builder() - .memberId(memberInfo.memberId()) - .itemType(ItemType.OPTION_TICKET) - .quantity(3) - .expiredAt(LocalDateTime.of(9999, 12, 31, 23, 59, 59)) - .build()); - itemHistoryRepository.save(ItemHistory.builder() - .memberId(memberInfo.memberId()) - .itemType(ItemType.OPTION_TICKET) - .historyType(ItemHistoryType.EVENT) - .quantity(3) - .description(ItemType.OPTION_TICKET.getName()) - .build()); - - itemRepository.save(Item.builder() - .memberId(memberInfo.memberId()) - .itemType(ItemType.MATCHING_TICKET) - .quantity(1) - .expiredAt(LocalDateTime.of(9999, 12, 31, 23, 59, 59)) - .build()); - itemHistoryRepository.save(ItemHistory.builder() - .memberId(memberInfo.memberId()) - .itemType(ItemType.MATCHING_TICKET) - .historyType(ItemHistoryType.EVENT) - .quantity(1) - .description(ItemType.MATCHING_TICKET.getName()) - .build()); - - } else if (rouletteReward.get().getItemType() != null) { - itemRepository.save(Item.builder() - .memberId(memberInfo.memberId()) - .quantity(rouletteReward.get().getQuantity()) - .expiredAt(LocalDateTime.of(9999, 12, 31, 23, 59, 59)) - .itemType(rouletteReward.get().getItemType()) - .build()); - itemHistoryRepository.save(ItemHistory.builder() - .memberId(memberInfo.memberId()) - .itemType(rouletteReward.get().getItemType()) - .historyType(ItemHistoryType.EVENT) - .quantity(rouletteReward.get().getQuantity()) - .description(rouletteReward.get().getItemType().getName()) - .build()); - } + grantReward(memberInfo.memberId(), rouletteReward); // 남은 아이템 수 감소 - rouletteReward.get().decreaseRemainingCount(); - + rouletteReward.decreaseRemainingCount(); + // 기록 남기기 try { rouletteHistoryRepository.save(RouletteHistory.builder() .memberId(memberInfo.memberId()) - .reward(rouletteReward.get()) + .reward(rouletteReward) .rouletteType(rouletteType) .build()); rouletteHistoryRepository.flush(); } catch (DataIntegrityViolationException exception) { throw new BusinessException(ItemErrorCode.ALREADY_PARTICIPATED_ROULETTE); } - - - return new RouletteSpinResponse(rouletteReward.get().getRewardName()); + return new RouletteSpinResponse(rouletteReward.getRewardName()); } @Override - public RoulettePageResponse roulettePage(MemberInfo memberInfo, RouletteType rouletteType) { + public RoulettePageResponse roulettePage(MemberInfo memberInfo) { LocalDateTime todayStart = LocalDate.now().atStartOfDay(); LocalDateTime tomorrowStart = todayStart.plusDays(1); - boolean isParticipated = rouletteHistoryRepository + + boolean isFreeParticipated = rouletteHistoryRepository .existsByMemberIdAndRouletteTypeAndParticipatedAtGreaterThanEqualAndParticipatedAtLessThan( - memberInfo.memberId(), rouletteType, todayStart, tomorrowStart); + memberInfo.memberId(), RouletteType.FREE, todayStart, tomorrowStart); + + boolean isSpecialParticipated = rouletteHistoryRepository + .existsByMemberIdAndRouletteTypeAndParticipatedAtGreaterThanEqualAndParticipatedAtLessThan( + memberInfo.memberId(), RouletteType.SPECIAL, todayStart, tomorrowStart); + + long totalPay = orderRepository.sumApprovedPriceByMemberIdAndDecidedAtBetween( + memberInfo.memberId(), todayStart, tomorrowStart); + + return new RoulettePageResponse(isFreeParticipated, isSpecialParticipated, totalPay); + } + + private RouletteReward drawAvailableReward(RouletteType rouletteType) { + Optional rouletteReward; + do { + int rouletteNumber = ThreadLocalRandom.current().nextInt(1, 10_001); + rouletteReward = rouletteRewardRepository + .findAvailableByRouletteTypeAndRouletteNumber(rouletteType, rouletteNumber); + } while (rouletteReward.isEmpty() + && rouletteRewardRepository.existsAvailableByRouletteType(rouletteType)); + + if (rouletteReward.isEmpty()) { + throw new BusinessException(ItemErrorCode.NO_AVAILABLE_ROULETTE_REWARD); + } + return rouletteReward.get(); + } + + private void grantReward(Long memberId, RouletteReward rouletteReward) { + if (rouletteReward.getRewardName().equals("풀세트")) { + saveRewardItem(memberId, ItemType.OPTION_TICKET, 3); + saveRewardItem(memberId, ItemType.MATCHING_TICKET, 1); + } else if (rouletteReward.getItemType() != null) { + saveRewardItem(memberId, rouletteReward.getItemType(), rouletteReward.getQuantity()); + } + } - Long totalPay = rouletteType == RouletteType.SPECIAL - ? orderRepository.sumApprovedPriceByMemberIdAndDecidedAtBetween( - memberInfo.memberId(), todayStart, tomorrowStart) - : null; - boolean isPossible = !isParticipated - && (rouletteType == RouletteType.FREE || totalPay >= SPECIAL_ROULETTE_MINIMUM_PAYMENT); - return new RoulettePageResponse(isPossible, totalPay); + + // 보상을 저장하고 기록을 남겨주는 메서드 + private void saveRewardItem(Long memberId, ItemType itemType, int quantity) { + itemRepository.save(Item.builder() + .memberId(memberId) + .itemType(itemType) + .quantity(quantity) + .expiredAt(LocalDateTime.of(9999, 12, 31, 23, 59, 59)) + .build()); + + itemHistoryRepository.save(ItemHistory.builder() + .memberId(memberId) + .itemType(itemType) + .historyType(ItemHistoryType.EVENT) + .quantity(quantity) + .description(itemType.getName()) + .build()); } } diff --git a/item-service/src/main/java/com/comatching/item/global/exception/ItemErrorCode.java b/item-service/src/main/java/com/comatching/item/global/exception/ItemErrorCode.java index 8de5eed..736c0d8 100644 --- a/item-service/src/main/java/com/comatching/item/global/exception/ItemErrorCode.java +++ b/item-service/src/main/java/com/comatching/item/global/exception/ItemErrorCode.java @@ -18,7 +18,7 @@ public enum ItemErrorCode implements ErrorCode { ALREADY_PARTICIPATED_ROULETTE("ITEM-007", HttpStatus.BAD_REQUEST, "이미 룰렛에 참여하셨습니다"), NOT_ENOUGH_PAYMENT_FOR_SPECIAL_ROULETTE("ITEM-008", HttpStatus.BAD_REQUEST, "스페셜 룰렛 참여를 위한 오늘의 누적 결제 금액이 부족합니다."), - ; + NO_AVAILABLE_ROULETTE_REWARD("ITEM-009", HttpStatus.BAD_REQUEST, "지급 가능한 룰렛 보상이 없습니다"); private final String code; private final HttpStatus httpStatus; diff --git a/item-service/src/main/java/com/comatching/item/infra/controller/RouletteController.java b/item-service/src/main/java/com/comatching/item/infra/controller/RouletteController.java index 15a9caf..6c945b8 100644 --- a/item-service/src/main/java/com/comatching/item/infra/controller/RouletteController.java +++ b/item-service/src/main/java/com/comatching/item/infra/controller/RouletteController.java @@ -30,9 +30,8 @@ public ResponseEntity> spinRoulette( @GetMapping public ResponseEntity> roulettePage( - @CurrentMember MemberInfo memberInfo, - @RequestParam RouletteType rouletteType + @CurrentMember MemberInfo memberInfo ) { - return ResponseEntity.ok(ApiResponse.ok(rouletteService.roulettePage(memberInfo, rouletteType))); + return ResponseEntity.ok(ApiResponse.ok(rouletteService.roulettePage(memberInfo))); } } diff --git a/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteServiceImplTest.java b/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteServiceImplTest.java index 3f1275e..7be39c4 100644 --- a/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteServiceImplTest.java +++ b/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteServiceImplTest.java @@ -61,35 +61,35 @@ class RouletteServiceImplTest { private RouletteServiceImpl rouletteService; @Test - @DisplayName("오늘 무료 룰렛에 참여하지 않았으면 참여 가능하다") - void shouldAllowFreeRouletteWhenNotParticipatedToday() { + @DisplayName("오늘 무료 룰렛에 참여하지 않았으면 미참여 상태를 반환한다") + void shouldReturnFreeRouletteAsNotParticipated() { given(rouletteHistoryRepository .existsByMemberIdAndRouletteTypeAndParticipatedAtGreaterThanEqualAndParticipatedAtLessThan( eq(MEMBER.memberId()), eq(RouletteType.FREE), any(LocalDateTime.class), any(LocalDateTime.class))) .willReturn(false); - RoulettePageResponse response = rouletteService.roulettePage(MEMBER, RouletteType.FREE); + RoulettePageResponse response = rouletteService.roulettePage(MEMBER); - assertThat(response.isPossible()).isTrue(); - assertThat(response.totalPay()).isNull(); - then(orderRepository).shouldHaveNoInteractions(); + assertThat(response.isFreeParticipated()).isFalse(); + assertThat(response.isSpecialParticipated()).isFalse(); + assertThat(response.totalPay()).isZero(); } @Test - @DisplayName("오늘 무료 룰렛에 이미 참여했으면 참여할 수 없다") - void shouldRejectFreeRouletteWhenAlreadyParticipatedToday() { + @DisplayName("오늘 무료 룰렛에 이미 참여했으면 참여 상태를 반환한다") + void shouldReturnFreeRouletteAsParticipated() { given(rouletteHistoryRepository .existsByMemberIdAndRouletteTypeAndParticipatedAtGreaterThanEqualAndParticipatedAtLessThan( eq(MEMBER.memberId()), eq(RouletteType.FREE), any(LocalDateTime.class), any(LocalDateTime.class))) .willReturn(true); - RoulettePageResponse response = rouletteService.roulettePage(MEMBER, RouletteType.FREE); + RoulettePageResponse response = rouletteService.roulettePage(MEMBER); - assertThat(response.isPossible()).isFalse(); - assertThat(response.totalPay()).isNull(); - then(orderRepository).shouldHaveNoInteractions(); + assertThat(response.isFreeParticipated()).isTrue(); + assertThat(response.isSpecialParticipated()).isFalse(); + assertThat(response.totalPay()).isZero(); then(rouletteHistoryRepository).should() .existsByMemberIdAndRouletteTypeAndParticipatedAtGreaterThanEqualAndParticipatedAtLessThan( eq(MEMBER.memberId()), eq(RouletteType.FREE), @@ -97,56 +97,74 @@ void shouldRejectFreeRouletteWhenAlreadyParticipatedToday() { } @Test - @DisplayName("오늘 결제액이 3500원이고 스페셜 룰렛에 참여하지 않았으면 참여 가능하다") - void shouldAllowSpecialRouletteAtMinimumPayment() { + @DisplayName("오늘 결제액과 스페셜 룰렛 미참여 상태를 반환한다") + void shouldReturnPaymentAndSpecialRouletteAsNotParticipated() { given(orderRepository.sumApprovedPriceByMemberIdAndDecidedAtBetween( eq(MEMBER.memberId()), any(LocalDateTime.class), any(LocalDateTime.class))) .willReturn(3500L); + given(rouletteHistoryRepository + .existsByMemberIdAndRouletteTypeAndParticipatedAtGreaterThanEqualAndParticipatedAtLessThan( + eq(MEMBER.memberId()), eq(RouletteType.FREE), + any(LocalDateTime.class), any(LocalDateTime.class))) + .willReturn(false); given(rouletteHistoryRepository .existsByMemberIdAndRouletteTypeAndParticipatedAtGreaterThanEqualAndParticipatedAtLessThan( eq(MEMBER.memberId()), eq(RouletteType.SPECIAL), any(LocalDateTime.class), any(LocalDateTime.class))) .willReturn(false); - RoulettePageResponse response = rouletteService.roulettePage(MEMBER, RouletteType.SPECIAL); + RoulettePageResponse response = rouletteService.roulettePage(MEMBER); - assertThat(response.isPossible()).isTrue(); + assertThat(response.isFreeParticipated()).isFalse(); + assertThat(response.isSpecialParticipated()).isFalse(); assertThat(response.totalPay()).isEqualTo(3500L); } @Test - @DisplayName("오늘 스페셜 룰렛에 이미 참여했으면 결제액이 충분해도 참여할 수 없다") - void shouldRejectSpecialRouletteWhenAlreadyParticipated() { + @DisplayName("오늘 스페셜 룰렛에 이미 참여했으면 참여 상태와 결제액을 반환한다") + void shouldReturnSpecialRouletteAsParticipated() { given(orderRepository.sumApprovedPriceByMemberIdAndDecidedAtBetween( eq(MEMBER.memberId()), any(LocalDateTime.class), any(LocalDateTime.class))) .willReturn(10000L); + given(rouletteHistoryRepository + .existsByMemberIdAndRouletteTypeAndParticipatedAtGreaterThanEqualAndParticipatedAtLessThan( + eq(MEMBER.memberId()), eq(RouletteType.FREE), + any(LocalDateTime.class), any(LocalDateTime.class))) + .willReturn(false); given(rouletteHistoryRepository .existsByMemberIdAndRouletteTypeAndParticipatedAtGreaterThanEqualAndParticipatedAtLessThan( eq(MEMBER.memberId()), eq(RouletteType.SPECIAL), any(LocalDateTime.class), any(LocalDateTime.class))) .willReturn(true); - RoulettePageResponse response = rouletteService.roulettePage(MEMBER, RouletteType.SPECIAL); + RoulettePageResponse response = rouletteService.roulettePage(MEMBER); - assertThat(response.isPossible()).isFalse(); + assertThat(response.isFreeParticipated()).isFalse(); + assertThat(response.isSpecialParticipated()).isTrue(); assertThat(response.totalPay()).isEqualTo(10000L); } @Test - @DisplayName("오늘 결제액이 3500원 미만이면 스페셜 룰렛에 참여할 수 없다") - void shouldRejectSpecialRouletteWhenPaymentIsInsufficient() { + @DisplayName("오늘 결제액이 3500원 미만이어도 결제액을 그대로 반환한다") + void shouldReturnPaymentBelowSpecialRouletteMinimum() { given(orderRepository.sumApprovedPriceByMemberIdAndDecidedAtBetween( eq(MEMBER.memberId()), any(LocalDateTime.class), any(LocalDateTime.class))) .willReturn(3499L); + given(rouletteHistoryRepository + .existsByMemberIdAndRouletteTypeAndParticipatedAtGreaterThanEqualAndParticipatedAtLessThan( + eq(MEMBER.memberId()), eq(RouletteType.FREE), + any(LocalDateTime.class), any(LocalDateTime.class))) + .willReturn(false); given(rouletteHistoryRepository .existsByMemberIdAndRouletteTypeAndParticipatedAtGreaterThanEqualAndParticipatedAtLessThan( eq(MEMBER.memberId()), eq(RouletteType.SPECIAL), any(LocalDateTime.class), any(LocalDateTime.class))) .willReturn(false); - RoulettePageResponse response = rouletteService.roulettePage(MEMBER, RouletteType.SPECIAL); + RoulettePageResponse response = rouletteService.roulettePage(MEMBER); - assertThat(response.isPossible()).isFalse(); + assertThat(response.isFreeParticipated()).isFalse(); + assertThat(response.isSpecialParticipated()).isFalse(); assertThat(response.totalPay()).isEqualTo(3499L); } @@ -303,6 +321,8 @@ void shouldRetryWhenSelectedRewardIsEmpty() { given(rouletteRewardRepository .findAvailableByRouletteTypeAndRouletteNumber(eq(RouletteType.FREE), anyInt())) .willReturn(Optional.empty(), Optional.of(reward)); + given(rouletteRewardRepository.existsAvailableByRouletteType(RouletteType.FREE)) + .willReturn(true); RouletteSpinResponse response = rouletteService.spinRoulette(MEMBER, RouletteType.FREE); diff --git a/item-service/src/test/java/com/comatching/item/infra/controller/RouletteControllerTest.java b/item-service/src/test/java/com/comatching/item/infra/controller/RouletteControllerTest.java index da6ca51..30f7bf0 100644 --- a/item-service/src/test/java/com/comatching/item/infra/controller/RouletteControllerTest.java +++ b/item-service/src/test/java/com/comatching/item/infra/controller/RouletteControllerTest.java @@ -85,61 +85,66 @@ void shouldRejectUnsupportedRouletteType() throws Exception { } @Test - @DisplayName("GET /api/roulette는 FREE 룰렛 참여 가능 여부와 null 결제액을 반환한다") - void shouldReturnFreeRoulettePage() throws Exception { - given(rouletteService.roulettePage(any(MemberInfo.class), eq(RouletteType.FREE))) - .willReturn(new RoulettePageResponse(true, null)); + @DisplayName("GET /api/roulette는 무료·스페셜 룰렛 참여 여부와 오늘 결제액을 반환한다") + void shouldReturnRoulettePage() throws Exception { + given(rouletteService.roulettePage(any(MemberInfo.class))) + .willReturn(new RoulettePageResponse(true, false, 3500L)); mockMvc.perform(get("/api/roulette") - .param("rouletteType", "FREE") .header("X-Member-Id", "1") .header("X-Member-Email", "member@example.com") .header("X-Member-Role", "USER")) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value("GEN-000")) .andExpect(jsonPath("$.status").value(200)) - .andExpect(jsonPath("$.data.isPossible").value(true)) - .andExpect(jsonPath("$.data.totalPay").doesNotExist()); + .andExpect(jsonPath("$.data.isFreeParticipated").value(true)) + .andExpect(jsonPath("$.data.isSpecialParticipated").value(false)) + .andExpect(jsonPath("$.data.totalPay").value(3500)); ArgumentCaptor memberCaptor = ArgumentCaptor.forClass(MemberInfo.class); - then(rouletteService).should().roulettePage(memberCaptor.capture(), eq(RouletteType.FREE)); + then(rouletteService).should().roulettePage(memberCaptor.capture()); assertThat(memberCaptor.getValue().memberId()).isEqualTo(1L); assertThat(memberCaptor.getValue().email()).isEqualTo("member@example.com"); assertThat(memberCaptor.getValue().role()).isEqualTo("USER"); } @Test - @DisplayName("GET /api/roulette는 SPECIAL 룰렛 참여 가능 여부와 오늘 결제액을 반환한다") - void shouldReturnSpecialRoulettePage() throws Exception { - given(rouletteService.roulettePage(any(MemberInfo.class), eq(RouletteType.SPECIAL))) - .willReturn(new RoulettePageResponse(false, 3499L)); + @DisplayName("GET /api/roulette는 두 룰렛의 참여 여부를 독립적으로 반환한다") + void shouldReturnEachRouletteParticipationStatus() throws Exception { + given(rouletteService.roulettePage(any(MemberInfo.class))) + .willReturn(new RoulettePageResponse(false, true, 3499L)); mockMvc.perform(get("/api/roulette") - .param("rouletteType", "SPECIAL") .header("X-Member-Id", "1") .header("X-Member-Email", "member@example.com") .header("X-Member-Role", "USER")) .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value("GEN-000")) .andExpect(jsonPath("$.status").value(200)) - .andExpect(jsonPath("$.data.isPossible").value(false)) + .andExpect(jsonPath("$.data.isFreeParticipated").value(false)) + .andExpect(jsonPath("$.data.isSpecialParticipated").value(true)) .andExpect(jsonPath("$.data.totalPay").value(3499)); ArgumentCaptor memberCaptor = ArgumentCaptor.forClass(MemberInfo.class); - then(rouletteService).should().roulettePage(memberCaptor.capture(), eq(RouletteType.SPECIAL)); + then(rouletteService).should().roulettePage(memberCaptor.capture()); assertThat(memberCaptor.getValue().memberId()).isEqualTo(1L); assertThat(memberCaptor.getValue().email()).isEqualTo("member@example.com"); assertThat(memberCaptor.getValue().role()).isEqualTo("USER"); } @Test - @DisplayName("GET /api/roulette는 rouletteType 쿼리 파라미터가 없으면 400을 반환한다") - void shouldRejectMissingRouletteTypeOnRoulettePage() throws Exception { - mockMvc.perform(get("/api/roulette") - .header("X-Member-Id", "1")) - .andExpect(status().isBadRequest()) - .andExpect(jsonPath("$.code").value(GeneralErrorCode.MISSING_REQUEST_PARAMETER.getCode())); + @DisplayName("GET /api/roulette는 rouletteType 쿼리 파라미터를 요구하지 않는다") + void shouldNotRequireRouletteTypeOnRoulettePage() throws Exception { + given(rouletteService.roulettePage(any(MemberInfo.class))) + .willReturn(new RoulettePageResponse(false, false, 0L)); - then(rouletteService).shouldHaveNoInteractions(); + mockMvc.perform(get("/api/roulette") + .header("X-Member-Id", "1") + .header("X-Member-Email", "member@example.com") + .header("X-Member-Role", "USER")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.isFreeParticipated").value(false)) + .andExpect(jsonPath("$.data.isSpecialParticipated").value(false)) + .andExpect(jsonPath("$.data.totalPay").value(0)); } } From bfe6bb9c789c7099da52890cbf2b85a7ae0fb78e Mon Sep 17 00:00:00 2001 From: jinwook219 Date: Thu, 10 Sep 2026 17:02:10 +0900 Subject: [PATCH 16/22] =?UTF-8?q?=E2=80=A2=20refactor(item):=20=EB=A3=B0?= =?UTF-8?q?=EB=A0=9B=20=EB=B3=B4=EC=83=81=20=EA=B5=AC=EB=B6=84=EC=9D=84=20?= =?UTF-8?q?RewardType=EC=9C=BC=EB=A1=9C=20=ED=86=B5=ED=95=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RouletteReward의 itemType을 rewardType으로 교체 - 일반 아이템·풀세트·상품권·꽝을 RewardType으로 구분 - 기존 보상 지급 수량과 재고·이력 처리 로직 유지 - 룰렛 테스트 데이터를 RewardType 구조에 맞게 수정 --- .../roulette/entity/RouletteReward.java | 9 ++++--- .../item/domain/roulette/enums/PrizeType.java | 7 ----- .../domain/roulette/enums/RewardType.java | 18 +++++++++++++ .../roulette/service/RouletteServiceImpl.java | 9 ++++--- .../service/RouletteConcurrencyTest.java | 7 ++--- .../service/RouletteRewardRepositoryTest.java | 12 ++++----- .../service/RouletteServiceImplTest.java | 27 ++++++++++--------- 7 files changed, 53 insertions(+), 36 deletions(-) delete mode 100644 item-service/src/main/java/com/comatching/item/domain/roulette/enums/PrizeType.java create mode 100644 item-service/src/main/java/com/comatching/item/domain/roulette/enums/RewardType.java diff --git a/item-service/src/main/java/com/comatching/item/domain/roulette/entity/RouletteReward.java b/item-service/src/main/java/com/comatching/item/domain/roulette/entity/RouletteReward.java index d95ae3e..ad73b0f 100644 --- a/item-service/src/main/java/com/comatching/item/domain/roulette/entity/RouletteReward.java +++ b/item-service/src/main/java/com/comatching/item/domain/roulette/entity/RouletteReward.java @@ -1,6 +1,6 @@ package com.comatching.item.domain.roulette.entity; -import com.comatching.common.domain.enums.ItemType; +import com.comatching.item.domain.roulette.enums.RewardType; import com.comatching.item.domain.roulette.enums.RouletteType; import jakarta.persistence.Column; @@ -32,7 +32,8 @@ public class RouletteReward { private String rewardName; @Enumerated(EnumType.STRING) - private ItemType itemType; + @Column(nullable = false) + private RewardType rewardType; @Column(nullable = false) private int quantity; @@ -49,7 +50,7 @@ public class RouletteReward { public RouletteReward( RouletteType rouletteType, String rewardName, - ItemType itemType, + RewardType rewardType, int quantity, int rangeStart, int rangeEnd, @@ -57,7 +58,7 @@ public RouletteReward( ) { this.rouletteType = rouletteType; this.rewardName = rewardName; - this.itemType = itemType; + this.rewardType = rewardType; this.quantity = quantity; this.rangeStart = rangeStart; this.rangeEnd = rangeEnd; diff --git a/item-service/src/main/java/com/comatching/item/domain/roulette/enums/PrizeType.java b/item-service/src/main/java/com/comatching/item/domain/roulette/enums/PrizeType.java deleted file mode 100644 index 890dc2a..0000000 --- a/item-service/src/main/java/com/comatching/item/domain/roulette/enums/PrizeType.java +++ /dev/null @@ -1,7 +0,0 @@ -package com.comatching.item.domain.roulette.enums; - -public enum PrizeType { - ITEM, - GIFT_CARD, - NONE -} \ No newline at end of file diff --git a/item-service/src/main/java/com/comatching/item/domain/roulette/enums/RewardType.java b/item-service/src/main/java/com/comatching/item/domain/roulette/enums/RewardType.java new file mode 100644 index 0000000..5091d4c --- /dev/null +++ b/item-service/src/main/java/com/comatching/item/domain/roulette/enums/RewardType.java @@ -0,0 +1,18 @@ +package com.comatching.item.domain.roulette.enums; + +import com.comatching.common.domain.enums.ItemType; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum RewardType { + MATCHING_TICKET(ItemType.MATCHING_TICKET), + OPTION_TICKET(ItemType.OPTION_TICKET), + FULL_SET(null), + GIFT_CARD(null), + NONE(null); + + private final ItemType itemType; +} diff --git a/item-service/src/main/java/com/comatching/item/domain/roulette/service/RouletteServiceImpl.java b/item-service/src/main/java/com/comatching/item/domain/roulette/service/RouletteServiceImpl.java index 12be3b5..042dd40 100644 --- a/item-service/src/main/java/com/comatching/item/domain/roulette/service/RouletteServiceImpl.java +++ b/item-service/src/main/java/com/comatching/item/domain/roulette/service/RouletteServiceImpl.java @@ -18,6 +18,7 @@ import com.comatching.item.domain.roulette.dto.response.RouletteSpinResponse; import com.comatching.item.domain.roulette.entity.RouletteHistory; import com.comatching.item.domain.roulette.entity.RouletteReward; +import com.comatching.item.domain.roulette.enums.RewardType; import com.comatching.item.domain.roulette.enums.RouletteType; import com.comatching.item.domain.roulette.repository.RouletteHistoryRepository; import com.comatching.item.domain.roulette.repository.RouletteRewardRepository; @@ -60,6 +61,7 @@ public RouletteSpinResponse spinRoulette(MemberInfo memberInfo, RouletteType rou throw new BusinessException(ItemErrorCode.NOT_ENOUGH_PAYMENT_FOR_SPECIAL_ROULETTE); } + // 난수로 추첨 RouletteReward rouletteReward = drawAvailableReward(rouletteType); // 보상에 따른 아이템 및 아이템 기록 룰렛 기록 추가 @@ -118,11 +120,12 @@ private RouletteReward drawAvailableReward(RouletteType rouletteType) { } private void grantReward(Long memberId, RouletteReward rouletteReward) { - if (rouletteReward.getRewardName().equals("풀세트")) { + RewardType rewardType = rouletteReward.getRewardType(); + if (rewardType == RewardType.FULL_SET) { saveRewardItem(memberId, ItemType.OPTION_TICKET, 3); saveRewardItem(memberId, ItemType.MATCHING_TICKET, 1); - } else if (rouletteReward.getItemType() != null) { - saveRewardItem(memberId, rouletteReward.getItemType(), rouletteReward.getQuantity()); + } else if (rewardType.getItemType() != null) { + saveRewardItem(memberId, rewardType.getItemType(), rouletteReward.getQuantity()); } } diff --git a/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteConcurrencyTest.java b/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteConcurrencyTest.java index c9aee33..facd00a 100644 --- a/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteConcurrencyTest.java +++ b/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteConcurrencyTest.java @@ -31,7 +31,6 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.support.TransactionTemplate; -import com.comatching.common.domain.enums.ItemType; import com.comatching.common.dto.member.MemberInfo; import com.comatching.common.exception.BusinessException; import com.comatching.item.domain.item.entity.Item; @@ -41,6 +40,7 @@ import com.comatching.item.domain.order.repository.OrderRepository; import com.comatching.item.domain.roulette.entity.RouletteHistory; import com.comatching.item.domain.roulette.entity.RouletteReward; +import com.comatching.item.domain.roulette.enums.RewardType; import com.comatching.item.domain.roulette.enums.RouletteType; import com.comatching.item.domain.roulette.repository.RouletteHistoryRepository; import com.comatching.item.domain.roulette.repository.RouletteRewardRepository; @@ -83,7 +83,7 @@ void singleRewardStockIsDecreasedForEveryConcurrentSpin() throws Exception { assertThat(failures).isEmpty(); RouletteReward savedReward = rouletteRewardRepository.findById(limitedRewardId).orElseThrow(); - assertThat(savedReward.getItemType()).isNull(); + assertThat(savedReward.getRewardType()).isEqualTo(RewardType.NONE); assertThat(savedReward.getRemainingCount()).isZero(); assertThat(count("SELECT COUNT(*) FROM roulette_history WHERE reward_id = ?", limitedRewardId)) .isEqualTo(REQUEST_COUNT); @@ -197,6 +197,7 @@ private RouletteReward reward( return RouletteReward.builder() .rouletteType(rouletteType) .rewardName(rewardName) + .rewardType(RewardType.NONE) .quantity(0) .rangeStart(rangeStart) .rangeEnd(rangeEnd) @@ -212,7 +213,7 @@ private RouletteReward itemReward( return RouletteReward.builder() .rouletteType(rouletteType) .rewardName(rewardName) - .itemType(ItemType.OPTION_TICKET) + .rewardType(RewardType.OPTION_TICKET) .quantity(1) .rangeStart(1) .rangeEnd(10000) diff --git a/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteRewardRepositoryTest.java b/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteRewardRepositoryTest.java index 49ecaa8..2582dd9 100644 --- a/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteRewardRepositoryTest.java +++ b/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteRewardRepositoryTest.java @@ -30,7 +30,6 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.support.TransactionTemplate; -import com.comatching.common.domain.enums.ItemType; import com.comatching.common.dto.member.MemberInfo; import com.comatching.item.domain.item.entity.Item; import com.comatching.item.domain.item.entity.ItemHistory; @@ -39,6 +38,7 @@ import com.comatching.item.domain.order.repository.OrderRepository; import com.comatching.item.domain.roulette.entity.RouletteHistory; import com.comatching.item.domain.roulette.entity.RouletteReward; +import com.comatching.item.domain.roulette.enums.RewardType; import com.comatching.item.domain.roulette.enums.RouletteType; import com.comatching.item.domain.roulette.repository.RouletteHistoryRepository; import com.comatching.item.domain.roulette.repository.RouletteRewardRepository; @@ -224,7 +224,7 @@ void shouldAllowSameRouletteTypeOnNextDate() { void shouldRollbackSavedItemWhenItemHistorySaveFails() { TransactionTemplate transactionTemplate = new TransactionTemplate(transactionManager); transactionTemplate.executeWithoutResult(status -> rouletteRewardRepository.saveAndFlush( - reward(RouletteType.FREE, "옵션권 1장", ItemType.OPTION_TICKET, 1, 10000, 2))); + reward(RouletteType.FREE, "옵션권 1장", RewardType.OPTION_TICKET, 1, 10000, 2))); given(itemHistoryRepository.save(any(ItemHistory.class))) .willThrow(new RuntimeException("item history save failed")); @@ -297,13 +297,13 @@ private RouletteReward reward( int rangeEnd, Integer remainingCount ) { - return reward(rouletteType, rewardName, null, rangeStart, rangeEnd, remainingCount); + return reward(rouletteType, rewardName, RewardType.NONE, rangeStart, rangeEnd, remainingCount); } private RouletteReward reward( RouletteType rouletteType, String rewardName, - ItemType itemType, + RewardType rewardType, int rangeStart, int rangeEnd, Integer remainingCount @@ -311,8 +311,8 @@ private RouletteReward reward( return RouletteReward.builder() .rouletteType(rouletteType) .rewardName(rewardName) - .itemType(itemType) - .quantity(itemType == null ? 0 : 1) + .rewardType(rewardType) + .quantity(rewardType.getItemType() == null ? 0 : 1) .rangeStart(rangeStart) .rangeEnd(rangeEnd) .remainingCount(remainingCount) diff --git a/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteServiceImplTest.java b/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteServiceImplTest.java index 7be39c4..5c927dc 100644 --- a/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteServiceImplTest.java +++ b/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteServiceImplTest.java @@ -34,6 +34,7 @@ import com.comatching.item.domain.roulette.dto.response.RouletteSpinResponse; import com.comatching.item.domain.roulette.entity.RouletteHistory; import com.comatching.item.domain.roulette.entity.RouletteReward; +import com.comatching.item.domain.roulette.enums.RewardType; import com.comatching.item.domain.roulette.enums.RouletteType; import com.comatching.item.domain.roulette.repository.RouletteHistoryRepository; import com.comatching.item.domain.roulette.repository.RouletteRewardRepository; @@ -171,7 +172,7 @@ void shouldReturnPaymentBelowSpecialRouletteMinimum() { @Test @DisplayName("일반 아이템 보상은 아이템과 아이템 이력을 저장하고 보상명을 반환한다") void shouldGrantItemRewardAndReturnRewardName() { - RouletteReward reward = reward(RouletteType.FREE, "옵션권 2장", ItemType.OPTION_TICKET, 2, null); + RouletteReward reward = reward(RouletteType.FREE, "옵션권 2장", RewardType.OPTION_TICKET, 2, null); givenReward(RouletteType.FREE, reward); RouletteSpinResponse response = rouletteService.spinRoulette(MEMBER, RouletteType.FREE); @@ -198,7 +199,7 @@ void shouldGrantItemRewardAndReturnRewardName() { @Test @DisplayName("풀세트 한 행으로 옵션권과 매칭권을 모두 지급한다") void shouldGrantEveryFullSetReward() { - RouletteReward fullSet = reward(RouletteType.SPECIAL, "풀세트", null, 0, 2); + RouletteReward fullSet = reward(RouletteType.SPECIAL, "풀세트", RewardType.FULL_SET, 0, 2); givenReward(RouletteType.SPECIAL, fullSet); RouletteSpinResponse response = rouletteService.spinRoulette(MEMBER, RouletteType.SPECIAL); @@ -227,7 +228,7 @@ void shouldGrantEveryFullSetReward() { @Test @DisplayName("상품권은 재고만 차감하고 룰렛 이력과 보상명을 남긴다") void shouldRecordGiftCardWithoutGrantingItem() { - RouletteReward reward = reward(RouletteType.SPECIAL, "1만원권 상품권", null, 1, 3); + RouletteReward reward = reward(RouletteType.SPECIAL, "1만원권 상품권", RewardType.GIFT_CARD, 1, 3); givenReward(RouletteType.SPECIAL, reward); RouletteSpinResponse response = rouletteService.spinRoulette(MEMBER, RouletteType.SPECIAL); @@ -242,7 +243,7 @@ void shouldRecordGiftCardWithoutGrantingItem() { @Test @DisplayName("꽝은 아이템을 지급하지 않고 룰렛 이력과 보상명을 남긴다") void shouldRecordNoPrizeWithoutGrantingItem() { - RouletteReward reward = reward(RouletteType.FREE, "꽝", null, 0, null); + RouletteReward reward = reward(RouletteType.FREE, "꽝", RewardType.NONE, 0, null); givenReward(RouletteType.FREE, reward); RouletteSpinResponse response = rouletteService.spinRoulette(MEMBER, RouletteType.FREE); @@ -317,7 +318,7 @@ void shouldRejectSpecialRouletteWhenPaymentIsBelowMinimum() { @Test @DisplayName("선택된 범위의 보상이 비어 있으면 다시 추첨한다") void shouldRetryWhenSelectedRewardIsEmpty() { - RouletteReward reward = reward(RouletteType.FREE, "꽝", null, 0, null); + RouletteReward reward = reward(RouletteType.FREE, "꽝", RewardType.NONE, 0, null); given(rouletteRewardRepository .findAvailableByRouletteTypeAndRouletteNumber(eq(RouletteType.FREE), anyInt())) .willReturn(Optional.empty(), Optional.of(reward)); @@ -336,7 +337,7 @@ void shouldRetryWhenSelectedRewardIsEmpty() { @DisplayName("아이템 저장이 실패하면 아이템 이력과 룰렛 이력을 저장하지 않는다") void shouldStopWhenItemSaveFails() { RouletteReward reward = reward( - RouletteType.FREE, "옵션권 1장", ItemType.OPTION_TICKET, 1, null); + RouletteType.FREE, "옵션권 1장", RewardType.OPTION_TICKET, 1, null); givenReward(RouletteType.FREE, reward); given(itemRepository.save(any(Item.class))).willThrow(new RuntimeException("item save failed")); @@ -352,7 +353,7 @@ void shouldStopWhenItemSaveFails() { @DisplayName("아이템 이력 저장이 실패하면 룰렛 이력을 저장하지 않는다") void shouldStopWhenItemHistorySaveFails() { RouletteReward reward = reward( - RouletteType.FREE, "옵션권 1장", ItemType.OPTION_TICKET, 1, null); + RouletteType.FREE, "옵션권 1장", RewardType.OPTION_TICKET, 1, null); givenReward(RouletteType.FREE, reward); given(itemHistoryRepository.save(any(ItemHistory.class))) .willThrow(new RuntimeException("item history save failed")); @@ -368,7 +369,7 @@ void shouldStopWhenItemHistorySaveFails() { @Test @DisplayName("룰렛 이력 저장이 실패하면 예외를 그대로 전달한다") void shouldPropagateRouletteHistorySaveFailure() { - RouletteReward reward = reward(RouletteType.FREE, "꽝", null, 0, null); + RouletteReward reward = reward(RouletteType.FREE, "꽝", RewardType.NONE, 0, null); givenReward(RouletteType.FREE, reward); given(rouletteHistoryRepository.save(any(RouletteHistory.class))) .willThrow(new RuntimeException("roulette history save failed")); @@ -381,7 +382,7 @@ void shouldPropagateRouletteHistorySaveFailure() { @Test @DisplayName("제한 재고는 한 개 차감한다") void shouldDecreaseLimitedRemainingCount() { - RouletteReward reward = reward(RouletteType.SPECIAL, "상품권", null, 1, 2); + RouletteReward reward = reward(RouletteType.SPECIAL, "상품권", RewardType.GIFT_CARD, 1, 2); reward.decreaseRemainingCount(); @@ -391,7 +392,7 @@ void shouldDecreaseLimitedRemainingCount() { @Test @DisplayName("무제한 보상은 재고를 변경하지 않는다") void shouldNotChangeUnlimitedRemainingCount() { - RouletteReward reward = reward(RouletteType.FREE, "꽝", null, 0, null); + RouletteReward reward = reward(RouletteType.FREE, "꽝", RewardType.NONE, 0, null); reward.decreaseRemainingCount(); @@ -401,7 +402,7 @@ void shouldNotChangeUnlimitedRemainingCount() { @Test @DisplayName("소진된 재고는 음수가 되지 않는다") void shouldNotDecreaseRemainingCountBelowZero() { - RouletteReward reward = reward(RouletteType.SPECIAL, "상품권", null, 1, 0); + RouletteReward reward = reward(RouletteType.SPECIAL, "상품권", RewardType.GIFT_CARD, 1, 0); reward.decreaseRemainingCount(); @@ -434,14 +435,14 @@ private void assertHistorySavedWith(RouletteReward reward, RouletteType roulette private RouletteReward reward( RouletteType rouletteType, String rewardName, - ItemType itemType, + RewardType rewardType, int quantity, Integer remainingCount ) { return RouletteReward.builder() .rouletteType(rouletteType) .rewardName(rewardName) - .itemType(itemType) + .rewardType(rewardType) .quantity(quantity) .rangeStart(1) .rangeEnd(1) From 22f6b79ec7341cb277f4d1ed110177609e9525d8 Mon Sep 17 00:00:00 2001 From: jinwook219 Date: Thu, 10 Sep 2026 20:27:54 +0900 Subject: [PATCH 17/22] =?UTF-8?q?=20=20feat(item):=20=EC=83=81=ED=92=88?= =?UTF-8?q?=EA=B6=8C=20=EB=8B=B9=EC=B2=A8=EC=9E=90=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EB=B0=8F=20=EC=A7=80=EA=B8=89=20=EC=99=84=EB=A3=8C=20API=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 룰렛 이력에 보상 지급 여부를 기록하고 즉시 지급 보상 상태 반영 - 미지급 상품권 당첨자 조회 및 지급 완료 처리 관리자 API 추가 - 상품권 당첨자용 사용자 DTO와 내부 일괄 조회 API 추가 - 당첨자 회원 정보 조회를 위한 Feign 연동 추가 - 동일 회원의 복수 당첨 이력을 당첨 건별로 반환 - 상품권 외 보상을 무제한 재고로 처리하고 소진 범위 재추첨 - 상품권 지급 및 회원 일괄 조회 테스트 보강 --- .../member/AdminGiftCardUserProfileDto.java | 9 + .../response/AdminGiftCardWinnerResponse.java | 34 +++ .../roulette/entity/RouletteHistory.java | 14 +- .../repository/RouletteHistoryRepository.java | 25 ++ .../repository/RouletteRewardRepository.java | 11 - .../service/AdminRouletteService.java | 14 ++ .../service/AdminRouletteServiceImpl.java | 104 ++++++++ .../roulette/service/RouletteServiceImpl.java | 56 +++-- .../item/global/exception/ItemErrorCode.java | 3 +- .../item/infra/client/UserAdminClient.java | 6 + .../controller/AdminRouletteController.java | 51 ++++ .../infra/controller/RouletteController.java | 3 +- .../service/AdminRouletteServiceImplTest.java | 226 ++++++++++++++++++ .../service/RouletteConcurrencyTest.java | 30 +-- .../service/RouletteRewardRepositoryTest.java | 67 +++++- .../service/RouletteServiceImplTest.java | 39 +-- .../AdminRouletteControllerTest.java | 133 +++++++++++ .../member/repository/MemberRepository.java | 12 + .../service/AdminMemberQueryService.java | 5 + .../service/AdminMemberQueryServiceImpl.java | 26 ++ .../InternalAdminUserController.java | 11 + .../AdminMemberQueryServiceImplTest.java | 56 +++++ .../InternalAdminUserControllerTest.java | 93 +++++++ 23 files changed, 962 insertions(+), 66 deletions(-) create mode 100644 common-module/src/main/java/com/comatching/common/dto/member/AdminGiftCardUserProfileDto.java create mode 100644 item-service/src/main/java/com/comatching/item/domain/roulette/dto/response/AdminGiftCardWinnerResponse.java create mode 100644 item-service/src/main/java/com/comatching/item/domain/roulette/service/AdminRouletteService.java create mode 100644 item-service/src/main/java/com/comatching/item/domain/roulette/service/AdminRouletteServiceImpl.java create mode 100644 item-service/src/main/java/com/comatching/item/infra/controller/AdminRouletteController.java create mode 100644 item-service/src/test/java/com/comatching/item/domain/roulette/service/AdminRouletteServiceImplTest.java create mode 100644 item-service/src/test/java/com/comatching/item/infra/controller/AdminRouletteControllerTest.java create mode 100644 user-service/src/test/java/com/comatching/user/infra/controller/InternalAdminUserControllerTest.java diff --git a/common-module/src/main/java/com/comatching/common/dto/member/AdminGiftCardUserProfileDto.java b/common-module/src/main/java/com/comatching/common/dto/member/AdminGiftCardUserProfileDto.java new file mode 100644 index 0000000..aa77eb9 --- /dev/null +++ b/common-module/src/main/java/com/comatching/common/dto/member/AdminGiftCardUserProfileDto.java @@ -0,0 +1,9 @@ +package com.comatching.common.dto.member; + +public record AdminGiftCardUserProfileDto( + Long id, + String email, + String realName, + String nickname +) { +} diff --git a/item-service/src/main/java/com/comatching/item/domain/roulette/dto/response/AdminGiftCardWinnerResponse.java b/item-service/src/main/java/com/comatching/item/domain/roulette/dto/response/AdminGiftCardWinnerResponse.java new file mode 100644 index 0000000..e01dbfb --- /dev/null +++ b/item-service/src/main/java/com/comatching/item/domain/roulette/dto/response/AdminGiftCardWinnerResponse.java @@ -0,0 +1,34 @@ +package com.comatching.item.domain.roulette.dto.response; + +import java.time.LocalDateTime; + +import com.comatching.common.dto.member.AdminGiftCardUserProfileDto; +import com.comatching.item.domain.roulette.entity.RouletteHistory; +import com.comatching.item.domain.roulette.enums.RouletteType; + +public record AdminGiftCardWinnerResponse( + Long historyId, + Long memberId, + String email, + String realName, + String nickname, + String rewardName, + RouletteType rouletteType, + LocalDateTime participatedAt +) { + public static AdminGiftCardWinnerResponse from( + RouletteHistory history, + AdminGiftCardUserProfileDto user + ) { + return new AdminGiftCardWinnerResponse( + history.getId(), + history.getMemberId(), + user.email(), + user.realName(), + user.nickname(), + history.getReward().getRewardName(), + history.getRouletteType(), + history.getParticipatedAt() + ); + } +} diff --git a/item-service/src/main/java/com/comatching/item/domain/roulette/entity/RouletteHistory.java b/item-service/src/main/java/com/comatching/item/domain/roulette/entity/RouletteHistory.java index 20ba3ae..5283173 100644 --- a/item-service/src/main/java/com/comatching/item/domain/roulette/entity/RouletteHistory.java +++ b/item-service/src/main/java/com/comatching/item/domain/roulette/entity/RouletteHistory.java @@ -55,15 +55,27 @@ public class RouletteHistory { @Column(name = "participation_date", nullable = false) private LocalDate participationDate; + @Column(name = "reward_granted", nullable = false) + // 일반 아이템과 풀세트는 룰렛 처리 중 지급이 끝나면 true가 된다. + // 상품권은 관리자가 실제 지급한 뒤 지급 완료 API를 호출하기 전까지 false로 남는다. + private boolean rewardGranted; + @Builder public RouletteHistory( Long memberId, RouletteReward reward, - RouletteType rouletteType + RouletteType rouletteType, + boolean rewardGranted ) { this.memberId = memberId; this.reward = reward; this.rouletteType = rouletteType; this.participationDate = participatedAt.toLocalDate(); + this.rewardGranted = rewardGranted; + } + + public void markRewardAsGranted() { + // 관리자가 상품권을 실제 지급한 뒤 지급 완료 상태로 변경한다. + this.rewardGranted = true; } } diff --git a/item-service/src/main/java/com/comatching/item/domain/roulette/repository/RouletteHistoryRepository.java b/item-service/src/main/java/com/comatching/item/domain/roulette/repository/RouletteHistoryRepository.java index ef20b79..c8d52f3 100644 --- a/item-service/src/main/java/com/comatching/item/domain/roulette/repository/RouletteHistoryRepository.java +++ b/item-service/src/main/java/com/comatching/item/domain/roulette/repository/RouletteHistoryRepository.java @@ -1,10 +1,19 @@ package com.comatching.item.domain.roulette.repository; import java.time.LocalDateTime; +import java.util.List; +import java.util.Optional; import com.comatching.item.domain.roulette.entity.RouletteHistory; +import com.comatching.item.domain.roulette.enums.RewardType; import com.comatching.item.domain.roulette.enums.RouletteType; +import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import jakarta.persistence.LockModeType; public interface RouletteHistoryRepository extends JpaRepository { boolean existsByMemberIdAndRouletteTypeAndParticipatedAtGreaterThanEqualAndParticipatedAtLessThan( @@ -13,4 +22,20 @@ boolean existsByMemberIdAndRouletteTypeAndParticipatedAtGreaterThanEqualAndParti LocalDateTime startAt, LocalDateTime endAt ); + + @EntityGraph(attributePaths = "reward") + // 상품권 당첨 이력 중 지급되지 않은 건만 당첨 시각 역순으로 조회한다. + List findAllByReward_RewardTypeAndRewardGrantedFalseOrderByParticipatedAtDesc( + RewardType rewardType + ); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + // 두 관리자가 같은 당첨 건을 동시에 지급 완료 처리하지 못하도록 이력 행을 잠근다. + @Query(""" + SELECT rh + FROM RouletteHistory rh + JOIN FETCH rh.reward + WHERE rh.id = :historyId + """) + Optional findByIdWithRewardForUpdate(@Param("historyId") Long historyId); } diff --git a/item-service/src/main/java/com/comatching/item/domain/roulette/repository/RouletteRewardRepository.java b/item-service/src/main/java/com/comatching/item/domain/roulette/repository/RouletteRewardRepository.java index 7fd8fd8..0b370ca 100644 --- a/item-service/src/main/java/com/comatching/item/domain/roulette/repository/RouletteRewardRepository.java +++ b/item-service/src/main/java/com/comatching/item/domain/roulette/repository/RouletteRewardRepository.java @@ -1,6 +1,5 @@ package com.comatching.item.domain.roulette.repository; -import java.util.List; import java.util.Optional; import com.comatching.item.domain.roulette.entity.RouletteReward; @@ -26,14 +25,4 @@ public interface RouletteRewardRepository extends JpaRepository findAvailableByRouletteTypeAndRouletteNumber( @Param("rouletteType") RouletteType rouletteType, @Param("rouletteNumber") int rouletteNumber); - - - @Query(""" - SELECT COUNT(rr) > 0 - FROM RouletteReward rr - WHERE rr.rouletteType = :rouletteType - AND (rr.remainingCount IS NULL OR rr.remainingCount > 0) - """) - boolean existsAvailableByRouletteType( - @Param("rouletteType") RouletteType rouletteType); } diff --git a/item-service/src/main/java/com/comatching/item/domain/roulette/service/AdminRouletteService.java b/item-service/src/main/java/com/comatching/item/domain/roulette/service/AdminRouletteService.java new file mode 100644 index 0000000..f4bd9d2 --- /dev/null +++ b/item-service/src/main/java/com/comatching/item/domain/roulette/service/AdminRouletteService.java @@ -0,0 +1,14 @@ +package com.comatching.item.domain.roulette.service; + +import java.util.List; + +import com.comatching.item.domain.roulette.dto.response.AdminGiftCardWinnerResponse; + +public interface AdminRouletteService { + + /** 아직 지급되지 않은 상품권 당첨자와 관리자 확인용 회원 정보를 반환한다. */ + List getUnpaidGiftCardWinners(); + + /** 관리자가 실제 상품권 지급을 끝낸 당첨 이력을 지급 완료 상태로 변경한다. */ + void markGiftCardAsGranted(Long historyId); +} diff --git a/item-service/src/main/java/com/comatching/item/domain/roulette/service/AdminRouletteServiceImpl.java b/item-service/src/main/java/com/comatching/item/domain/roulette/service/AdminRouletteServiceImpl.java new file mode 100644 index 0000000..e7f2f07 --- /dev/null +++ b/item-service/src/main/java/com/comatching/item/domain/roulette/service/AdminRouletteServiceImpl.java @@ -0,0 +1,104 @@ +package com.comatching.item.domain.roulette.service; + +import java.util.List; +import java.util.Map; +import java.util.function.Function; +import java.util.stream.Collectors; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.comatching.common.dto.member.AdminGiftCardUserProfileDto; +import com.comatching.common.exception.BusinessException; +import com.comatching.common.exception.code.GeneralErrorCode; +import com.comatching.item.domain.roulette.dto.response.AdminGiftCardWinnerResponse; +import com.comatching.item.domain.roulette.entity.RouletteHistory; +import com.comatching.item.domain.roulette.enums.RewardType; +import com.comatching.item.domain.roulette.repository.RouletteHistoryRepository; +import com.comatching.item.global.exception.ItemErrorCode; +import com.comatching.item.infra.client.UserAdminClient; + +import feign.FeignException; +import feign.codec.DecodeException; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class AdminRouletteServiceImpl implements AdminRouletteService { + + private final RouletteHistoryRepository rouletteHistoryRepository; + private final UserAdminClient userAdminClient; + + @Override + @Transactional(readOnly = true) + public List getUnpaidGiftCardWinners() { + // rewardType이 GIFT_CARD이고 rewardGranted가 false인 이력만 최신순으로 가져온다. + List histories = rouletteHistoryRepository + .findAllByReward_RewardTypeAndRewardGrantedFalseOrderByParticipatedAtDesc(RewardType.GIFT_CARD); + + // 미지급 당첨자가 없으면 user-service를 호출하지 않고 빈 명단을 반환한다. + if (histories.isEmpty()) { return List.of(); } + + // user-service에서 받은 회원 목록을 memberId로 찾을 수 있게 만들어 각 당첨 이력에 회원 정보를 붙인다. + Map usersById = getUsersByMemberId(histories); + + // 가져온 유저 정보 당첨 정보와 매핑 + return histories.stream() + .map(history -> AdminGiftCardWinnerResponse.from( + history, + getUserOrThrow(usersById, history.getMemberId()) + )) + .toList(); + } + + @Override + @Transactional + public void markGiftCardAsGranted(Long historyId) { + // 같은 당첨 이력에 대한 동시 요청을 직렬화해 지급 완료 상태가 중복 처리되지 않게 한다. + RouletteHistory history = rouletteHistoryRepository.findByIdWithRewardForUpdate(historyId) + .orElseThrow(() -> new BusinessException(GeneralErrorCode.NOT_FOUND)); + + // 예외처리 + if (history.getReward().getRewardType() != RewardType.GIFT_CARD) { + // 일반 아이템, 풀세트, 꽝 이력은 상품권 지급 완료 대상으로 사용할 수 없다. + throw new BusinessException(GeneralErrorCode.INVALID_INPUT_VALUE); + } + + // 미지급 상태일경우 지급처리 완료 + if (!history.isRewardGranted()) { + history.markRewardAsGranted(); + } + } + + private Map getUsersByMemberId(List histories) { + // 같은 회원이 여러 상품권에 당첨됐어도 user-service에는 회원 ID를 한 번만 전달한다. + List memberIds = histories.stream() + .map(RouletteHistory::getMemberId) + .distinct() + .toList(); + + try { + // 당첨자별 단건 호출을 피하고 현재 미지급 명단의 회원 정보를 한 번에 조회한다. + return userAdminClient.getUsersByIds(memberIds).stream() + .collect(Collectors.toMap(AdminGiftCardUserProfileDto::id, Function.identity())); + } catch (DecodeException ignored) { + throw new BusinessException(ItemErrorCode.USER_QUERY_FAILED); + } catch (FeignException ignored) { + throw new BusinessException(ItemErrorCode.USER_QUERY_FAILED); + } + } + + // user-service 배치 응답에서 당첨 이력의 memberId와 일치하는 회원 정보를 반환한다. + // 활성 회원 정보가 누락된 경우 정확한 지급 대상 명단을 만들 수 없으므로 ITEM-004 예외를 발생시킨다. + private AdminGiftCardUserProfileDto getUserOrThrow( + Map usersById, + Long memberId + ) { + AdminGiftCardUserProfileDto user = usersById.get(memberId); + if (user == null) { + throw new BusinessException(ItemErrorCode.TARGET_USER_NOT_FOUND); + } + return user; + } + +} diff --git a/item-service/src/main/java/com/comatching/item/domain/roulette/service/RouletteServiceImpl.java b/item-service/src/main/java/com/comatching/item/domain/roulette/service/RouletteServiceImpl.java index 042dd40..aa10937 100644 --- a/item-service/src/main/java/com/comatching/item/domain/roulette/service/RouletteServiceImpl.java +++ b/item-service/src/main/java/com/comatching/item/domain/roulette/service/RouletteServiceImpl.java @@ -65,68 +65,96 @@ public RouletteSpinResponse spinRoulette(MemberInfo memberInfo, RouletteType rou RouletteReward rouletteReward = drawAvailableReward(rouletteType); // 보상에 따른 아이템 및 아이템 기록 룰렛 기록 추가 - grantReward(memberInfo.memberId(), rouletteReward); + boolean rewardGranted = grantReward(memberInfo.memberId(), rouletteReward); - // 남은 아이템 수 감소 + // 제한 재고만 감소시키며, remainingCount가 null인 무제한 보상은 유지한다. rouletteReward.decreaseRemainingCount(); // 기록 남기기 try { - rouletteHistoryRepository.save(RouletteHistory.builder() - .memberId(memberInfo.memberId()) - .reward(rouletteReward) - .rouletteType(rouletteType) - .build()); - rouletteHistoryRepository.flush(); + rouletteHistoryRepository.saveAndFlush(RouletteHistory.builder() + .memberId(memberInfo.memberId()) + .reward(rouletteReward) + .rouletteType(rouletteType) + .rewardGranted(rewardGranted) + .build()); } catch (DataIntegrityViolationException exception) { throw new BusinessException(ItemErrorCode.ALREADY_PARTICIPATED_ROULETTE); } return new RouletteSpinResponse(rouletteReward.getRewardName()); } + @Override public RoulettePageResponse roulettePage(MemberInfo memberInfo) { + // 오늘 날짜 LocalDateTime todayStart = LocalDate.now().atStartOfDay(); LocalDateTime tomorrowStart = todayStart.plusDays(1); + // 오늘 무료 룰렛 참여 여부 boolean isFreeParticipated = rouletteHistoryRepository .existsByMemberIdAndRouletteTypeAndParticipatedAtGreaterThanEqualAndParticipatedAtLessThan( memberInfo.memberId(), RouletteType.FREE, todayStart, tomorrowStart); + // 오늘 유료 룰렛 참여 여부 boolean isSpecialParticipated = rouletteHistoryRepository .existsByMemberIdAndRouletteTypeAndParticipatedAtGreaterThanEqualAndParticipatedAtLessThan( memberInfo.memberId(), RouletteType.SPECIAL, todayStart, tomorrowStart); + // 오늘 결제액 long totalPay = orderRepository.sumApprovedPriceByMemberIdAndDecidedAtBetween( memberInfo.memberId(), todayStart, tomorrowStart); return new RoulettePageResponse(isFreeParticipated, isSpecialParticipated, totalPay); } + + + + + // 난수로 보상을 추첨하는 메서드 private RouletteReward drawAvailableReward(RouletteType rouletteType) { Optional rouletteReward; + do { + // 1 ~ 10000 사이의 난수 생성 int rouletteNumber = ThreadLocalRandom.current().nextInt(1, 10_001); + + // 무제한(null)이거나 남은 수량이 1 이상인 보상을 조회한다. rouletteReward = rouletteRewardRepository .findAvailableByRouletteTypeAndRouletteNumber(rouletteType, rouletteNumber); - } while (rouletteReward.isEmpty() - && rouletteRewardRepository.existsAvailableByRouletteType(rouletteType)); - if (rouletteReward.isEmpty()) { - throw new BusinessException(ItemErrorCode.NO_AVAILABLE_ROULETTE_REWARD); - } + // 선택한 범위의 제한 재고가 소진됐으면 새 난수로 다시 추첨한다. + } while (rouletteReward.isEmpty()); + + // 상품권 외 보상은 무제한 재고로 운영하며, 전체 보상 소진 시 처리 정책은 확정되지 않아 예외 처리를 비활성화한다. +// if (rouletteReward.isEmpty()) { +// throw new BusinessException(ItemErrorCode.NO_AVAILABLE_ROULETTE_REWARD); +// } return rouletteReward.get(); } - private void grantReward(Long memberId, RouletteReward rouletteReward) { + private boolean grantReward(Long memberId, RouletteReward rouletteReward) { RewardType rewardType = rouletteReward.getRewardType(); + + // 풀세트의 경우 if (rewardType == RewardType.FULL_SET) { saveRewardItem(memberId, ItemType.OPTION_TICKET, 3); saveRewardItem(memberId, ItemType.MATCHING_TICKET, 1); + + // 지급 완료 처리 + return true; + // 아닐경우 아이템 지급 } else if (rewardType.getItemType() != null) { saveRewardItem(memberId, rewardType.getItemType(), rouletteReward.getQuantity()); + + // 지급 완료 처리 + return true; } + + // 상품권은 관리자 지급 전이고, 꽝은 지급된 보상이 없으므로 false로 기록한다. + return false; } diff --git a/item-service/src/main/java/com/comatching/item/global/exception/ItemErrorCode.java b/item-service/src/main/java/com/comatching/item/global/exception/ItemErrorCode.java index 736c0d8..63d4d6a 100644 --- a/item-service/src/main/java/com/comatching/item/global/exception/ItemErrorCode.java +++ b/item-service/src/main/java/com/comatching/item/global/exception/ItemErrorCode.java @@ -17,8 +17,7 @@ public enum ItemErrorCode implements ErrorCode { DUPLICATE_ADMIN_INVENTORY_ADJUSTMENT("ITEM-006", HttpStatus.CONFLICT, "동일한 관리자 아이템 조정 요청이 처리 중입니다."), ALREADY_PARTICIPATED_ROULETTE("ITEM-007", HttpStatus.BAD_REQUEST, "이미 룰렛에 참여하셨습니다"), NOT_ENOUGH_PAYMENT_FOR_SPECIAL_ROULETTE("ITEM-008", HttpStatus.BAD_REQUEST, - "스페셜 룰렛 참여를 위한 오늘의 누적 결제 금액이 부족합니다."), - NO_AVAILABLE_ROULETTE_REWARD("ITEM-009", HttpStatus.BAD_REQUEST, "지급 가능한 룰렛 보상이 없습니다"); + "스페셜 룰렛 참여를 위한 오늘의 누적 결제 금액이 부족합니다."); private final String code; private final HttpStatus httpStatus; diff --git a/item-service/src/main/java/com/comatching/item/infra/client/UserAdminClient.java b/item-service/src/main/java/com/comatching/item/infra/client/UserAdminClient.java index 0d524ef..fb8f055 100644 --- a/item-service/src/main/java/com/comatching/item/infra/client/UserAdminClient.java +++ b/item-service/src/main/java/com/comatching/item/infra/client/UserAdminClient.java @@ -5,8 +5,11 @@ import org.springframework.cloud.openfeign.FeignClient; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestParam; +import com.comatching.common.dto.member.AdminGiftCardUserProfileDto; import com.comatching.common.dto.member.AdminUserProfileDto; import com.comatching.common.dto.response.PagingResponse; @@ -23,4 +26,7 @@ PagingResponse getUsers( @GetMapping("/{memberId}") AdminUserProfileDto getUserDetail(@PathVariable Long memberId); + + @PostMapping("/bulk") + List getUsersByIds(@RequestBody List memberIds); } diff --git a/item-service/src/main/java/com/comatching/item/infra/controller/AdminRouletteController.java b/item-service/src/main/java/com/comatching/item/infra/controller/AdminRouletteController.java new file mode 100644 index 0000000..4965ff4 --- /dev/null +++ b/item-service/src/main/java/com/comatching/item/infra/controller/AdminRouletteController.java @@ -0,0 +1,51 @@ +package com.comatching.item.infra.controller; + +import java.util.List; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.comatching.common.annotation.CurrentMember; +import com.comatching.common.annotation.RequireRole; +import com.comatching.common.domain.enums.MemberRole; +import com.comatching.common.dto.member.MemberInfo; +import com.comatching.common.dto.response.ApiResponse; +import com.comatching.item.domain.roulette.dto.response.AdminGiftCardWinnerResponse; +import com.comatching.item.domain.roulette.service.AdminRouletteService; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; + +@Tag(name = "Admin Roulette API", description = "관리자 전용 룰렛 상품권 관리") +@RestController +@RequestMapping("/api/v1/admin/roulette") +@RequiredArgsConstructor +public class AdminRouletteController { + + private final AdminRouletteService adminRouletteService; + + @RequireRole(MemberRole.ROLE_ADMIN) + @Operation(summary = "미지급 상품권 당첨자 조회", description = "상품권에 당첨됐지만 아직 지급되지 않은 이력을 최신순으로 조회합니다.") + @GetMapping("/gift-cards/unpaid") + public ResponseEntity>> getUnpaidGiftCardWinners( + @CurrentMember MemberInfo memberInfo + ) { + return ResponseEntity.ok(ApiResponse.ok(adminRouletteService.getUnpaidGiftCardWinners())); + } + + @RequireRole(MemberRole.ROLE_ADMIN) + @Operation(summary = "상품권 지급 완료 처리", description = "상품권 지급을 완료한 당첨 이력을 지급 완료 상태로 변경합니다.") + @PatchMapping("/gift-cards/{historyId}/grant") + public ResponseEntity> markGiftCardAsGranted( + @CurrentMember MemberInfo memberInfo, + @PathVariable Long historyId + ) { + adminRouletteService.markGiftCardAsGranted(historyId); + return ResponseEntity.ok(ApiResponse.ok()); + } +} diff --git a/item-service/src/main/java/com/comatching/item/infra/controller/RouletteController.java b/item-service/src/main/java/com/comatching/item/infra/controller/RouletteController.java index 6c945b8..81e338f 100644 --- a/item-service/src/main/java/com/comatching/item/infra/controller/RouletteController.java +++ b/item-service/src/main/java/com/comatching/item/infra/controller/RouletteController.java @@ -8,7 +8,6 @@ import com.comatching.item.domain.roulette.enums.RouletteType; import com.comatching.item.domain.roulette.service.RouletteService; import io.swagger.v3.oas.annotations.Operation; -import jdk.jfr.Description; import lombok.AllArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.*; @@ -20,7 +19,7 @@ public class RouletteController { private final RouletteService rouletteService; @PostMapping("/{rouletteType}/spins") - @Operation(summary = "룰렛 돌리는 api", description = "") + @Operation(summary = "룰렛 돌리는 api", description = "무료 룰렛과 유료 룰렛 (1일 1회 제한)") public ResponseEntity> spinRoulette( @CurrentMember MemberInfo memberInfo, @PathVariable RouletteType rouletteType diff --git a/item-service/src/test/java/com/comatching/item/domain/roulette/service/AdminRouletteServiceImplTest.java b/item-service/src/test/java/com/comatching/item/domain/roulette/service/AdminRouletteServiceImplTest.java new file mode 100644 index 0000000..255a27a --- /dev/null +++ b/item-service/src/test/java/com/comatching/item/domain/roulette/service/AdminRouletteServiceImplTest.java @@ -0,0 +1,226 @@ +package com.comatching.item.domain.roulette.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.mockito.Mockito.mock; + +import java.util.List; +import java.util.Optional; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.util.ReflectionTestUtils; + +import com.comatching.common.dto.member.AdminGiftCardUserProfileDto; +import com.comatching.common.exception.BusinessException; +import com.comatching.common.exception.code.GeneralErrorCode; +import com.comatching.item.domain.roulette.dto.response.AdminGiftCardWinnerResponse; +import com.comatching.item.domain.roulette.entity.RouletteHistory; +import com.comatching.item.domain.roulette.entity.RouletteReward; +import com.comatching.item.domain.roulette.enums.RewardType; +import com.comatching.item.domain.roulette.enums.RouletteType; +import com.comatching.item.domain.roulette.repository.RouletteHistoryRepository; +import com.comatching.item.global.exception.ItemErrorCode; +import com.comatching.item.infra.client.UserAdminClient; + +import feign.FeignException; +import feign.codec.DecodeException; + +@ExtendWith(MockitoExtension.class) +@DisplayName("AdminRouletteServiceImpl 테스트") +class AdminRouletteServiceImplTest { + + @Mock + private RouletteHistoryRepository rouletteHistoryRepository; + + @Mock + private UserAdminClient userAdminClient; + + @InjectMocks + private AdminRouletteServiceImpl adminRouletteService; + + @Test + @DisplayName("미지급 상품권 이력이 없으면 회원 조회 없이 빈 목록을 반환한다") + void shouldReturnEmptyListWithoutRequestingUsers() { + given(rouletteHistoryRepository + .findAllByReward_RewardTypeAndRewardGrantedFalseOrderByParticipatedAtDesc(RewardType.GIFT_CARD)) + .willReturn(List.of()); + + List responses = adminRouletteService.getUnpaidGiftCardWinners(); + + assertThat(responses).isEmpty(); + then(userAdminClient).shouldHaveNoInteractions(); + } + + @Test + @DisplayName("같은 회원의 여러 당첨 이력은 회원을 한 번만 조회하고 당첨 건별로 반환한다") + void shouldRequestDistinctUsersAndReturnEveryWinningHistory() { + RouletteHistory newestHistory = history(101L, 1L, "2만원권 상품권", false); + RouletteHistory sameMemberHistory = history(100L, 1L, "1만원권 상품권", false); + RouletteHistory anotherMemberHistory = history(99L, 2L, "1만원권 상품권", false); + given(rouletteHistoryRepository + .findAllByReward_RewardTypeAndRewardGrantedFalseOrderByParticipatedAtDesc(RewardType.GIFT_CARD)) + .willReturn(List.of(newestHistory, sameMemberHistory, anotherMemberHistory)); + given(userAdminClient.getUsersByIds(List.of(1L, 2L))).willReturn(List.of( + new AdminGiftCardUserProfileDto(2L, "second@example.com", "두번째", "두번"), + new AdminGiftCardUserProfileDto(1L, "first@example.com", "첫번째", "첫번") + )); + + List responses = adminRouletteService.getUnpaidGiftCardWinners(); + + then(userAdminClient).should().getUsersByIds(List.of(1L, 2L)); + assertThat(responses).hasSize(3); + assertThat(responses).extracting(AdminGiftCardWinnerResponse::historyId) + .containsExactly(101L, 100L, 99L); + assertThat(responses).extracting(AdminGiftCardWinnerResponse::memberId) + .containsExactly(1L, 1L, 2L); + assertThat(responses.get(0).email()).isEqualTo("first@example.com"); + assertThat(responses.get(0).realName()).isEqualTo("첫번째"); + assertThat(responses.get(0).nickname()).isEqualTo("첫번"); + assertThat(responses.get(0).rewardName()).isEqualTo("2만원권 상품권"); + assertThat(responses.get(0).rouletteType()).isEqualTo(RouletteType.SPECIAL); + assertThat(responses.get(0).participatedAt()).isEqualTo(newestHistory.getParticipatedAt()); + } + + @Test + @DisplayName("당첨 회원 정보가 응답에서 누락되면 대상 사용자 없음 예외가 발생한다") + void shouldThrowWhenWinnerProfileIsMissing() { + RouletteHistory history = history(101L, 1L, "1만원권 상품권", false); + given(rouletteHistoryRepository + .findAllByReward_RewardTypeAndRewardGrantedFalseOrderByParticipatedAtDesc(RewardType.GIFT_CARD)) + .willReturn(List.of(history)); + given(userAdminClient.getUsersByIds(List.of(1L))).willReturn(List.of()); + + assertThatThrownBy(adminRouletteService::getUnpaidGiftCardWinners) + .isInstanceOf(BusinessException.class) + .satisfies(exception -> assertThat(((BusinessException)exception).getErrorCode()) + .isEqualTo(ItemErrorCode.TARGET_USER_NOT_FOUND)); + } + + @Test + @DisplayName("회원 응답 디코딩에 실패하면 사용자 조회 실패 예외로 변환한다") + void shouldConvertDecodeException() { + givenGiftCardHistory(); + given(userAdminClient.getUsersByIds(List.of(1L))).willThrow(mock(DecodeException.class)); + + assertUserQueryFailed(); + } + + @Test + @DisplayName("회원 서비스 호출에 실패하면 사용자 조회 실패 예외로 변환한다") + void shouldConvertFeignException() { + givenGiftCardHistory(); + given(userAdminClient.getUsersByIds(List.of(1L))).willThrow(mock(FeignException.class)); + + assertUserQueryFailed(); + } + + @Test + @DisplayName("미지급 상품권 이력을 지급 완료로 변경한다") + void shouldMarkGiftCardAsGranted() { + RouletteHistory history = history(101L, 1L, "1만원권 상품권", false); + given(rouletteHistoryRepository.findByIdWithRewardForUpdate(101L)) + .willReturn(Optional.of(history)); + + adminRouletteService.markGiftCardAsGranted(101L); + + assertThat(history.isRewardGranted()).isTrue(); + } + + @Test + @DisplayName("이미 지급된 상품권 이력은 그대로 유지한다") + void shouldKeepAlreadyGrantedGiftCardGranted() { + RouletteHistory history = history(101L, 1L, "1만원권 상품권", true); + given(rouletteHistoryRepository.findByIdWithRewardForUpdate(101L)) + .willReturn(Optional.of(history)); + + adminRouletteService.markGiftCardAsGranted(101L); + + assertThat(history.isRewardGranted()).isTrue(); + } + + @Test + @DisplayName("존재하지 않는 룰렛 이력이면 찾을 수 없음 예외가 발생한다") + void shouldThrowWhenHistoryDoesNotExist() { + given(rouletteHistoryRepository.findByIdWithRewardForUpdate(404L)) + .willReturn(Optional.empty()); + + assertThatThrownBy(() -> adminRouletteService.markGiftCardAsGranted(404L)) + .isInstanceOf(BusinessException.class) + .satisfies(exception -> assertThat(((BusinessException)exception).getErrorCode()) + .isEqualTo(GeneralErrorCode.NOT_FOUND)); + } + + @Test + @DisplayName("상품권이 아닌 룰렛 이력은 지급 완료로 변경할 수 없다") + void shouldRejectNonGiftCardHistory() { + RouletteHistory history = history( + 101L, + 1L, + "옵션권 1장", + RewardType.OPTION_TICKET, + true + ); + given(rouletteHistoryRepository.findByIdWithRewardForUpdate(101L)) + .willReturn(Optional.of(history)); + + assertThatThrownBy(() -> adminRouletteService.markGiftCardAsGranted(101L)) + .isInstanceOf(BusinessException.class) + .satisfies(exception -> assertThat(((BusinessException)exception).getErrorCode()) + .isEqualTo(GeneralErrorCode.INVALID_INPUT_VALUE)); + } + + private void givenGiftCardHistory() { + given(rouletteHistoryRepository + .findAllByReward_RewardTypeAndRewardGrantedFalseOrderByParticipatedAtDesc(RewardType.GIFT_CARD)) + .willReturn(List.of(history(101L, 1L, "1만원권 상품권", false))); + } + + private void assertUserQueryFailed() { + assertThatThrownBy(adminRouletteService::getUnpaidGiftCardWinners) + .isInstanceOf(BusinessException.class) + .satisfies(exception -> assertThat(((BusinessException)exception).getErrorCode()) + .isEqualTo(ItemErrorCode.USER_QUERY_FAILED)); + } + + private RouletteHistory history( + Long historyId, + Long memberId, + String rewardName, + boolean rewardGranted + ) { + return history(historyId, memberId, rewardName, RewardType.GIFT_CARD, rewardGranted); + } + + private RouletteHistory history( + Long historyId, + Long memberId, + String rewardName, + RewardType rewardType, + boolean rewardGranted + ) { + RouletteReward reward = RouletteReward.builder() + .rouletteType(RouletteType.SPECIAL) + .rewardName(rewardName) + .rewardType(rewardType) + .quantity(rewardType == RewardType.GIFT_CARD ? 1 : 0) + .rangeStart(1) + .rangeEnd(10000) + .remainingCount(rewardType == RewardType.GIFT_CARD ? 1 : null) + .build(); + RouletteHistory history = RouletteHistory.builder() + .memberId(memberId) + .reward(reward) + .rouletteType(RouletteType.SPECIAL) + .rewardGranted(rewardGranted) + .build(); + ReflectionTestUtils.setField(history, "id", historyId); + return history; + } +} diff --git a/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteConcurrencyTest.java b/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteConcurrencyTest.java index facd00a..b87df75 100644 --- a/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteConcurrencyTest.java +++ b/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteConcurrencyTest.java @@ -72,18 +72,20 @@ class RouletteConcurrencyTest { @Test @Transactional(propagation = Propagation.NOT_SUPPORTED) - @DisplayName("단일 행 보상은 동시 추첨 횟수만큼 재고가 차감된다") - void singleRewardStockIsDecreasedForEveryConcurrentSpin() throws Exception { + @DisplayName("단일 행 상품권은 동시 추첨 횟수만큼 재고가 차감된다") + void giftCardStockIsDecreasedForEveryConcurrentSpin() throws Exception { int stock = REQUEST_COUNT; Long limitedRewardId = inTransaction(() -> rouletteRewardRepository.save( - reward(RouletteType.FREE, "한정 보상", 1, 10000, stock)).getId()); + reward(RouletteType.SPECIAL, "상품권", RewardType.GIFT_CARD, 1, 10000, stock)).getId()); + given(orderRepository.sumApprovedPriceByMemberIdAndDecidedAtBetween(any(), any(), any())) + .willReturn(3500L); List failures = runConcurrently(REQUEST_COUNT, index -> - rouletteService.spinRoulette(member(index + 1L), RouletteType.FREE)); + rouletteService.spinRoulette(member(index + 1L), RouletteType.SPECIAL)); assertThat(failures).isEmpty(); RouletteReward savedReward = rouletteRewardRepository.findById(limitedRewardId).orElseThrow(); - assertThat(savedReward.getRewardType()).isEqualTo(RewardType.NONE); + assertThat(savedReward.getRewardType()).isEqualTo(RewardType.GIFT_CARD); assertThat(savedReward.getRemainingCount()).isZero(); assertThat(count("SELECT COUNT(*) FROM roulette_history WHERE reward_id = ?", limitedRewardId)) .isEqualTo(REQUEST_COUNT); @@ -97,7 +99,7 @@ void singleRewardStockIsDecreasedForEveryConcurrentSpin() throws Exception { void freeRouletteSucceedsOnlyOnceForSameMember() throws Exception { Long memberId = 50L; Long rewardId = inTransaction(() -> rouletteRewardRepository.save( - itemReward(RouletteType.FREE, "옵션권 1장", REQUEST_COUNT)).getId()); + itemReward(RouletteType.FREE, "옵션권 1장")).getId()); List failures = runConcurrently(REQUEST_COUNT, index -> rouletteService.spinRoulette(member(memberId), RouletteType.FREE)); @@ -111,7 +113,7 @@ void freeRouletteSucceedsOnlyOnceForSameMember() throws Exception { assertThat(count("SELECT COUNT(*) FROM roulette_history WHERE member_id = ?", memberId)) .isOne(); assertThat(rouletteRewardRepository.findById(rewardId).orElseThrow().getRemainingCount()) - .isEqualTo(REQUEST_COUNT - 1); + .isNull(); assertThat(itemRepository.count()).isOne(); assertThat(itemHistoryRepository.count()).isOne(); } @@ -122,7 +124,7 @@ void freeRouletteSucceedsOnlyOnceForSameMember() throws Exception { void specialRouletteSucceedsOnlyOnceForSameMember() throws Exception { Long memberId = 51L; Long rewardId = inTransaction(() -> rouletteRewardRepository.save( - itemReward(RouletteType.SPECIAL, "옵션권 1장", REQUEST_COUNT)).getId()); + itemReward(RouletteType.SPECIAL, "옵션권 1장")).getId()); given(orderRepository.sumApprovedPriceByMemberIdAndDecidedAtBetween( eq(memberId), any(), any())) .willReturn(3500L); @@ -142,7 +144,7 @@ void specialRouletteSucceedsOnlyOnceForSameMember() throws Exception { RouletteType.SPECIAL.name())) .isOne(); assertThat(rouletteRewardRepository.findById(rewardId).orElseThrow().getRemainingCount()) - .isEqualTo(REQUEST_COUNT - 1); + .isNull(); assertThat(itemRepository.count()).isOne(); assertThat(itemHistoryRepository.count()).isOne(); } @@ -190,6 +192,7 @@ private List runConcurrently(int threadCount, ThrowingIntConsumer tas private RouletteReward reward( RouletteType rouletteType, String rewardName, + RewardType rewardType, int rangeStart, int rangeEnd, Integer remainingCount @@ -197,8 +200,8 @@ private RouletteReward reward( return RouletteReward.builder() .rouletteType(rouletteType) .rewardName(rewardName) - .rewardType(RewardType.NONE) - .quantity(0) + .rewardType(rewardType) + .quantity(rewardType == RewardType.GIFT_CARD ? 1 : 0) .rangeStart(rangeStart) .rangeEnd(rangeEnd) .remainingCount(remainingCount) @@ -207,8 +210,7 @@ private RouletteReward reward( private RouletteReward itemReward( RouletteType rouletteType, - String rewardName, - Integer remainingCount + String rewardName ) { return RouletteReward.builder() .rouletteType(rouletteType) @@ -217,7 +219,7 @@ private RouletteReward itemReward( .quantity(1) .rangeStart(1) .rangeEnd(10000) - .remainingCount(remainingCount) + .remainingCount(null) .build(); } diff --git a/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteRewardRepositoryTest.java b/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteRewardRepositoryTest.java index 2582dd9..c7209e3 100644 --- a/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteRewardRepositoryTest.java +++ b/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteRewardRepositoryTest.java @@ -8,6 +8,7 @@ import java.sql.Timestamp; import java.time.LocalDate; import java.time.LocalDateTime; +import java.util.List; import java.util.Optional; import org.junit.jupiter.api.DisplayName; @@ -91,7 +92,7 @@ void shouldFindRewardsAtBothBoundaries() { @DisplayName("풀세트 단일 행을 조회한다") void shouldFindSingleFullSetReward() { RouletteReward fullSet = persist(reward( - RouletteType.SPECIAL, "풀세트", null, 8201, 9200, null)); + RouletteType.SPECIAL, "풀세트", RewardType.FULL_SET, 8201, 9200, null)); flushAndClear(); Optional reward = rouletteRewardRepository @@ -103,7 +104,8 @@ void shouldFindSingleFullSetReward() { @Test @DisplayName("재고가 소진된 제한 보상은 조회하지 않는다") void shouldExcludeOutOfStockReward() { - persist(reward(RouletteType.SPECIAL, "상품권", 9701, 9900, 0)); + persist(reward( + RouletteType.SPECIAL, "상품권", RewardType.GIFT_CARD, 9701, 9900, 0)); flushAndClear(); Optional reward = rouletteRewardRepository @@ -125,6 +127,61 @@ void shouldSeparateRewardsByRouletteType() { assertThat(reward.orElseThrow().getId()).isEqualTo(freeReward.getId()); } + @Test + @DisplayName("미지급 상품권 이력만 당첨 시각 역순으로 조회한다") + void shouldFindOnlyUnpaidGiftCardHistoriesInLatestOrder() { + RouletteReward giftCard = persist(reward( + RouletteType.SPECIAL, "1만원권 상품권", RewardType.GIFT_CARD, 9701, 9900, 3)); + RouletteReward item = persist(reward( + RouletteType.SPECIAL, "옵션권 2장", RewardType.OPTION_TICKET, 1, 3900, null)); + RouletteHistory olderUnpaid = entityManager.persist(RouletteHistory.builder() + .memberId(20L) + .reward(giftCard) + .rouletteType(RouletteType.SPECIAL) + .rewardGranted(false) + .build()); + RouletteHistory paidGiftCard = entityManager.persist(RouletteHistory.builder() + .memberId(21L) + .reward(giftCard) + .rouletteType(RouletteType.SPECIAL) + .rewardGranted(true) + .build()); + entityManager.persist(RouletteHistory.builder() + .memberId(22L) + .reward(item) + .rouletteType(RouletteType.SPECIAL) + .rewardGranted(false) + .build()); + RouletteHistory newerUnpaid = entityManager.persist(RouletteHistory.builder() + .memberId(23L) + .reward(giftCard) + .rouletteType(RouletteType.SPECIAL) + .rewardGranted(false) + .build()); + entityManager.flush(); + jdbcTemplate.update( + "UPDATE roulette_history SET participated_at = ? WHERE id = ?", + Timestamp.valueOf(LocalDate.now().atTime(10, 0)), + olderUnpaid.getId()); + jdbcTemplate.update( + "UPDATE roulette_history SET participated_at = ? WHERE id = ?", + Timestamp.valueOf(LocalDate.now().atTime(12, 0)), + newerUnpaid.getId()); + entityManager.clear(); + + List histories = rouletteHistoryRepository + .findAllByReward_RewardTypeAndRewardGrantedFalseOrderByParticipatedAtDesc(RewardType.GIFT_CARD); + + assertThat(histories).extracting(RouletteHistory::getId) + .containsExactly(newerUnpaid.getId(), olderUnpaid.getId()); + assertThat(histories).allSatisfy(history -> { + assertThat(history.isRewardGranted()).isFalse(); + assertThat(history.getReward().getRewardType()).isEqualTo(RewardType.GIFT_CARD); + }); + assertThat(histories).extracting(RouletteHistory::getId) + .doesNotContain(paidGiftCard.getId()); + } + @Test @DisplayName("오늘 같은 회원의 FREE 참여 이력이 있으면 true를 반환한다") void shouldFindTodayFreeHistoryForSameMember() { @@ -224,7 +281,7 @@ void shouldAllowSameRouletteTypeOnNextDate() { void shouldRollbackSavedItemWhenItemHistorySaveFails() { TransactionTemplate transactionTemplate = new TransactionTemplate(transactionManager); transactionTemplate.executeWithoutResult(status -> rouletteRewardRepository.saveAndFlush( - reward(RouletteType.FREE, "옵션권 1장", RewardType.OPTION_TICKET, 1, 10000, 2))); + reward(RouletteType.FREE, "옵션권 1장", RewardType.OPTION_TICKET, 1, 10000, null))); given(itemHistoryRepository.save(any(ItemHistory.class))) .willThrow(new RuntimeException("item history save failed")); @@ -237,7 +294,7 @@ void shouldRollbackSavedItemWhenItemHistorySaveFails() { assertThat(rouletteHistoryRepository.count()).isZero(); assertThat(rouletteRewardRepository.findAll()) .extracting(RouletteReward::getRemainingCount) - .containsExactly(2); + .containsExactly((Integer)null); } private RouletteReward persist(RouletteReward reward) { @@ -275,7 +332,7 @@ private void flushAndClear() { private RouletteHistory persistHistory(Long memberId, RouletteType rouletteType) { RouletteReward historyReward = persist(reward( - RouletteType.FREE, "꽝", null, 1, 10000, 999999)); + RouletteType.FREE, "꽝", RewardType.NONE, 1, 10000, null)); return entityManager.persist(RouletteHistory.builder() .memberId(memberId) .reward(historyReward) diff --git a/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteServiceImplTest.java b/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteServiceImplTest.java index 5c927dc..6fca4d0 100644 --- a/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteServiceImplTest.java +++ b/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteServiceImplTest.java @@ -192,19 +192,20 @@ void shouldGrantItemRewardAndReturnRewardName() { assertThat(itemHistoryCaptor.getValue().getQuantity()).isEqualTo(2); assertThat(itemHistoryCaptor.getValue().getDescription()).isEqualTo(ItemType.OPTION_TICKET.getName()); - assertHistorySavedWith(reward, RouletteType.FREE); + assertThat(reward.getRemainingCount()).isNull(); + assertHistorySavedWith(reward, RouletteType.FREE, true); assertThat(response.rewardName()).isEqualTo("옵션권 2장"); } @Test @DisplayName("풀세트 한 행으로 옵션권과 매칭권을 모두 지급한다") void shouldGrantEveryFullSetReward() { - RouletteReward fullSet = reward(RouletteType.SPECIAL, "풀세트", RewardType.FULL_SET, 0, 2); + RouletteReward fullSet = reward(RouletteType.SPECIAL, "풀세트", RewardType.FULL_SET, 0, null); givenReward(RouletteType.SPECIAL, fullSet); RouletteSpinResponse response = rouletteService.spinRoulette(MEMBER, RouletteType.SPECIAL); - assertThat(fullSet.getRemainingCount()).isEqualTo(1); + assertThat(fullSet.getRemainingCount()).isNull(); ArgumentCaptor itemCaptor = ArgumentCaptor.forClass(Item.class); then(itemRepository).should(times(2)).save(itemCaptor.capture()); assertThat(itemCaptor.getAllValues()) @@ -221,7 +222,7 @@ void shouldGrantEveryFullSetReward() { org.assertj.core.groups.Tuple.tuple(ItemType.OPTION_TICKET, ItemHistoryType.EVENT, 3), org.assertj.core.groups.Tuple.tuple(ItemType.MATCHING_TICKET, ItemHistoryType.EVENT, 1) ); - assertHistorySavedWith(fullSet, RouletteType.SPECIAL); + assertHistorySavedWith(fullSet, RouletteType.SPECIAL, true); assertThat(response.rewardName()).isEqualTo("풀세트"); } @@ -236,7 +237,7 @@ void shouldRecordGiftCardWithoutGrantingItem() { assertThat(reward.getRemainingCount()).isEqualTo(2); then(itemRepository).should(never()).save(any(Item.class)); then(itemHistoryRepository).shouldHaveNoInteractions(); - assertHistorySavedWith(reward, RouletteType.SPECIAL); + assertHistorySavedWith(reward, RouletteType.SPECIAL, false); assertThat(response.rewardName()).isEqualTo("1만원권 상품권"); } @@ -250,7 +251,8 @@ void shouldRecordNoPrizeWithoutGrantingItem() { then(itemRepository).should(never()).save(any(Item.class)); then(itemHistoryRepository).shouldHaveNoInteractions(); - assertHistorySavedWith(reward, RouletteType.FREE); + assertThat(reward.getRemainingCount()).isNull(); + assertHistorySavedWith(reward, RouletteType.FREE, false); assertThat(response.rewardName()).isEqualTo("꽝"); } @@ -270,7 +272,7 @@ void shouldRejectDuplicatedFreeRouletteParticipation() { then(itemRepository).shouldHaveNoInteractions(); then(rouletteRewardRepository).shouldHaveNoInteractions(); then(itemHistoryRepository).shouldHaveNoInteractions(); - then(rouletteHistoryRepository).should(never()).save(any(RouletteHistory.class)); + then(rouletteHistoryRepository).should(never()).saveAndFlush(any(RouletteHistory.class)); } @Test @@ -290,7 +292,7 @@ void shouldRejectDuplicatedSpecialRouletteParticipation() { then(rouletteRewardRepository).shouldHaveNoInteractions(); then(itemRepository).shouldHaveNoInteractions(); then(itemHistoryRepository).shouldHaveNoInteractions(); - then(rouletteHistoryRepository).should(never()).save(any(RouletteHistory.class)); + then(rouletteHistoryRepository).should(never()).saveAndFlush(any(RouletteHistory.class)); } @Test @@ -312,7 +314,7 @@ void shouldRejectSpecialRouletteWhenPaymentIsBelowMinimum() { then(rouletteRewardRepository).shouldHaveNoInteractions(); then(itemRepository).shouldHaveNoInteractions(); then(itemHistoryRepository).shouldHaveNoInteractions(); - then(rouletteHistoryRepository).should(never()).save(any(RouletteHistory.class)); + then(rouletteHistoryRepository).should(never()).saveAndFlush(any(RouletteHistory.class)); } @Test @@ -322,14 +324,12 @@ void shouldRetryWhenSelectedRewardIsEmpty() { given(rouletteRewardRepository .findAvailableByRouletteTypeAndRouletteNumber(eq(RouletteType.FREE), anyInt())) .willReturn(Optional.empty(), Optional.of(reward)); - given(rouletteRewardRepository.existsAvailableByRouletteType(RouletteType.FREE)) - .willReturn(true); RouletteSpinResponse response = rouletteService.spinRoulette(MEMBER, RouletteType.FREE); then(rouletteRewardRepository).should(times(2)) .findAvailableByRouletteTypeAndRouletteNumber(eq(RouletteType.FREE), anyInt()); - assertHistorySavedWith(reward, RouletteType.FREE); + assertHistorySavedWith(reward, RouletteType.FREE, false); assertThat(response.rewardName()).isEqualTo("꽝"); } @@ -346,7 +346,7 @@ void shouldStopWhenItemSaveFails() { .hasMessage("item save failed"); then(itemHistoryRepository).shouldHaveNoInteractions(); - then(rouletteHistoryRepository).should(never()).save(any(RouletteHistory.class)); + then(rouletteHistoryRepository).should(never()).saveAndFlush(any(RouletteHistory.class)); } @Test @@ -363,7 +363,7 @@ void shouldStopWhenItemHistorySaveFails() { .hasMessage("item history save failed"); then(itemRepository).should().save(any(Item.class)); - then(rouletteHistoryRepository).should(never()).save(any(RouletteHistory.class)); + then(rouletteHistoryRepository).should(never()).saveAndFlush(any(RouletteHistory.class)); } @Test @@ -371,7 +371,7 @@ void shouldStopWhenItemHistorySaveFails() { void shouldPropagateRouletteHistorySaveFailure() { RouletteReward reward = reward(RouletteType.FREE, "꽝", RewardType.NONE, 0, null); givenReward(RouletteType.FREE, reward); - given(rouletteHistoryRepository.save(any(RouletteHistory.class))) + given(rouletteHistoryRepository.saveAndFlush(any(RouletteHistory.class))) .willThrow(new RuntimeException("roulette history save failed")); assertThatThrownBy(() -> rouletteService.spinRoulette(MEMBER, RouletteType.FREE)) @@ -424,12 +424,17 @@ private void givenReward(RouletteType rouletteType, RouletteReward reward) { .willReturn(Optional.of(reward)); } - private void assertHistorySavedWith(RouletteReward reward, RouletteType rouletteType) { + private void assertHistorySavedWith( + RouletteReward reward, + RouletteType rouletteType, + boolean rewardGranted + ) { ArgumentCaptor historyCaptor = ArgumentCaptor.forClass(RouletteHistory.class); - then(rouletteHistoryRepository).should().save(historyCaptor.capture()); + then(rouletteHistoryRepository).should().saveAndFlush(historyCaptor.capture()); assertThat(historyCaptor.getValue().getMemberId()).isEqualTo(MEMBER.memberId()); assertThat(historyCaptor.getValue().getReward()).isSameAs(reward); assertThat(historyCaptor.getValue().getRouletteType()).isEqualTo(rouletteType); + assertThat(historyCaptor.getValue().isRewardGranted()).isEqualTo(rewardGranted); } private RouletteReward reward( diff --git a/item-service/src/test/java/com/comatching/item/infra/controller/AdminRouletteControllerTest.java b/item-service/src/test/java/com/comatching/item/infra/controller/AdminRouletteControllerTest.java new file mode 100644 index 0000000..574ee18 --- /dev/null +++ b/item-service/src/test/java/com/comatching/item/infra/controller/AdminRouletteControllerTest.java @@ -0,0 +1,133 @@ +package com.comatching.item.infra.controller; + +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.time.LocalDateTime; +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import com.comatching.common.exception.code.GeneralErrorCode; +import com.comatching.common.exception.handler.GlobalExceptionHandler; +import com.comatching.common.resolver.MemberInfoArgumentResolver; +import com.comatching.item.domain.roulette.dto.response.AdminGiftCardWinnerResponse; +import com.comatching.item.domain.roulette.enums.RouletteType; +import com.comatching.item.domain.roulette.service.AdminRouletteService; +import com.fasterxml.jackson.databind.ObjectMapper; + +@ExtendWith(MockitoExtension.class) +@DisplayName("AdminRouletteController API 계약 테스트") +class AdminRouletteControllerTest { + + private MockMvc mockMvc; + + @Mock + private AdminRouletteService adminRouletteService; + + @InjectMocks + private AdminRouletteController adminRouletteController; + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders.standaloneSetup(adminRouletteController) + .setCustomArgumentResolvers(new MemberInfoArgumentResolver()) + .setControllerAdvice(new GlobalExceptionHandler(new ObjectMapper())) + .build(); + } + + @Test + @DisplayName("GET /api/v1/admin/roulette/gift-cards/unpaid는 미지급 상품권 당첨자 목록을 반환한다") + void shouldReturnUnpaidGiftCardWinners() throws Exception { + LocalDateTime participatedAt = LocalDateTime.of(2026, 9, 10, 12, 30); + given(adminRouletteService.getUnpaidGiftCardWinners()).willReturn(List.of( + new AdminGiftCardWinnerResponse( + 101L, + 1L, + "winner@example.com", + "당첨자", + "행운", + "2만원권 상품권", + RouletteType.SPECIAL, + participatedAt + ) + )); + + mockMvc.perform(get("/api/v1/admin/roulette/gift-cards/unpaid") + .header("X-Member-Id", "900") + .header("X-Member-Email", "admin@example.com") + .header("X-Member-Role", "ROLE_ADMIN")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("GEN-000")) + .andExpect(jsonPath("$.status").value(200)) + .andExpect(jsonPath("$.data[0].historyId").value(101)) + .andExpect(jsonPath("$.data[0].memberId").value(1)) + .andExpect(jsonPath("$.data[0].email").value("winner@example.com")) + .andExpect(jsonPath("$.data[0].realName").value("당첨자")) + .andExpect(jsonPath("$.data[0].nickname").value("행운")) + .andExpect(jsonPath("$.data[0].rewardName").value("2만원권 상품권")) + .andExpect(jsonPath("$.data[0].rouletteType").value("SPECIAL")) + .andExpect(jsonPath("$.data[0].participatedAt[0]").value(2026)) + .andExpect(jsonPath("$.data[0].participatedAt[1]").value(9)) + .andExpect(jsonPath("$.data[0].participatedAt[2]").value(10)) + .andExpect(jsonPath("$.data[0].participatedAt[3]").value(12)) + .andExpect(jsonPath("$.data[0].participatedAt[4]").value(30)); + + then(adminRouletteService).should().getUnpaidGiftCardWinners(); + } + + @Test + @DisplayName("GET 미지급 상품권 당첨자 조회는 관리자 회원 헤더를 해석한다") + void shouldResolveAdminMemberHeaders() throws Exception { + given(adminRouletteService.getUnpaidGiftCardWinners()).willReturn(List.of()); + + mockMvc.perform(get("/api/v1/admin/roulette/gift-cards/unpaid") + .header("X-Member-Id", "900") + .header("X-Member-Email", "admin@example.com") + .header("X-Member-Role", "ROLE_ADMIN")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data").isArray()) + .andExpect(jsonPath("$.data").isEmpty()); + + then(adminRouletteService).should().getUnpaidGiftCardWinners(); + } + + @Test + @DisplayName("PATCH /api/v1/admin/roulette/gift-cards/{historyId}/grant는 상품권 지급 완료를 위임한다") + void shouldMarkGiftCardAsGranted() throws Exception { + mockMvc.perform(patch("/api/v1/admin/roulette/gift-cards/{historyId}/grant", 101L) + .header("X-Member-Id", "900") + .header("X-Member-Email", "admin@example.com") + .header("X-Member-Role", "ROLE_ADMIN")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("GEN-000")) + .andExpect(jsonPath("$.status").value(200)); + + then(adminRouletteService).should().markGiftCardAsGranted(101L); + } + + @Test + @DisplayName("상품권 지급 완료 경로의 historyId가 숫자가 아니면 400을 반환한다") + void shouldRejectInvalidHistoryId() throws Exception { + mockMvc.perform(patch("/api/v1/admin/roulette/gift-cards/not-a-number/grant") + .header("X-Member-Id", "900") + .header("X-Member-Email", "admin@example.com") + .header("X-Member-Role", "ROLE_ADMIN")) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value(GeneralErrorCode.TYPE_MISMATCH.getCode())); + + then(adminRouletteService).shouldHaveNoInteractions(); + } +} diff --git a/user-service/src/main/java/com/comatching/user/domain/member/repository/MemberRepository.java b/user-service/src/main/java/com/comatching/user/domain/member/repository/MemberRepository.java index 99544fd..51f570f 100644 --- a/user-service/src/main/java/com/comatching/user/domain/member/repository/MemberRepository.java +++ b/user-service/src/main/java/com/comatching/user/domain/member/repository/MemberRepository.java @@ -1,5 +1,6 @@ package com.comatching.user.domain.member.repository; +import java.util.List; import java.util.Optional; import org.springframework.data.domain.Page; @@ -58,4 +59,15 @@ Optional findAdminMemberById( @Param("status") MemberStatus status, @Param("role") MemberRole role ); + + @Query("SELECT m FROM Member m " + + "JOIN FETCH m.profile p " + + "WHERE m.id IN :memberIds " + + "AND m.status = :status " + + "AND m.role = :role") + List findAdminMembersByIds( + @Param("memberIds") List memberIds, + @Param("status") MemberStatus status, + @Param("role") MemberRole role + ); } diff --git a/user-service/src/main/java/com/comatching/user/domain/member/service/AdminMemberQueryService.java b/user-service/src/main/java/com/comatching/user/domain/member/service/AdminMemberQueryService.java index 6aca5a8..f06dbfc 100644 --- a/user-service/src/main/java/com/comatching/user/domain/member/service/AdminMemberQueryService.java +++ b/user-service/src/main/java/com/comatching/user/domain/member/service/AdminMemberQueryService.java @@ -1,7 +1,10 @@ package com.comatching.user.domain.member.service; +import java.util.List; + import org.springframework.data.domain.Pageable; +import com.comatching.common.dto.member.AdminGiftCardUserProfileDto; import com.comatching.common.dto.member.AdminUserProfileDto; import com.comatching.common.dto.response.PagingResponse; @@ -10,4 +13,6 @@ public interface AdminMemberQueryService { PagingResponse getUsers(String keyword, Pageable pageable); AdminUserProfileDto getUserDetail(Long memberId); + + List getUsersByIds(List memberIds); } diff --git a/user-service/src/main/java/com/comatching/user/domain/member/service/AdminMemberQueryServiceImpl.java b/user-service/src/main/java/com/comatching/user/domain/member/service/AdminMemberQueryServiceImpl.java index 63aefdd..6d697cc 100644 --- a/user-service/src/main/java/com/comatching/user/domain/member/service/AdminMemberQueryServiceImpl.java +++ b/user-service/src/main/java/com/comatching/user/domain/member/service/AdminMemberQueryServiceImpl.java @@ -1,5 +1,7 @@ package com.comatching.user.domain.member.service; +import java.util.List; + import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; @@ -8,6 +10,7 @@ import com.comatching.common.domain.enums.MemberRole; import com.comatching.common.domain.enums.MemberStatus; +import com.comatching.common.dto.member.AdminGiftCardUserProfileDto; import com.comatching.common.dto.member.AdminUserProfileDto; import com.comatching.common.dto.response.PagingResponse; import com.comatching.common.exception.BusinessException; @@ -47,6 +50,29 @@ public AdminUserProfileDto getUserDetail(Long memberId) { return toAdminUserProfileDto(member); } + @Override + public List getUsersByIds(List memberIds) { + // 미지급 상품권 명단에 표시할 이메일, 실명, 닉네임을 회원 ID 목록으로 한 번에 조회한다. + // 반환 순서는 보장하지 않으며 호출 서비스가 회원 ID를 기준으로 원래 명단에 결합한다. + return memberRepository.findAdminMembersByIds( + memberIds, + MemberStatus.ACTIVE, + MemberRole.ROLE_USER + ) + .stream() + .map(this::toAdminGiftCardUserProfileDto) + .toList(); + } + + private AdminGiftCardUserProfileDto toAdminGiftCardUserProfileDto(Member member) { + return new AdminGiftCardUserProfileDto( + member.getId(), + member.getEmail(), + member.getRealName(), + member.getProfile().getNickname() + ); + } + private AdminUserProfileDto toAdminUserProfileDto(Member member) { return new AdminUserProfileDto( member.getId(), diff --git a/user-service/src/main/java/com/comatching/user/infra/controller/InternalAdminUserController.java b/user-service/src/main/java/com/comatching/user/infra/controller/InternalAdminUserController.java index 73cb058..bddbec8 100644 --- a/user-service/src/main/java/com/comatching/user/infra/controller/InternalAdminUserController.java +++ b/user-service/src/main/java/com/comatching/user/infra/controller/InternalAdminUserController.java @@ -1,14 +1,19 @@ package com.comatching.user.infra.controller; +import java.util.List; + import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; import org.springframework.data.web.PageableDefault; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import com.comatching.common.dto.member.AdminGiftCardUserProfileDto; import com.comatching.common.dto.member.AdminUserProfileDto; import com.comatching.common.dto.response.PagingResponse; import com.comatching.user.domain.member.service.AdminMemberQueryService; @@ -34,4 +39,10 @@ public PagingResponse getUsers( public AdminUserProfileDto getUserDetail(@PathVariable Long memberId) { return adminMemberQueryService.getUserDetail(memberId); } + + @PostMapping("/bulk") + public List getUsersByIds(@RequestBody List memberIds) { + // item-service가 상품권 당첨자 명단을 만들 때 회원별 단건 조회를 반복하지 않도록 제공한다. + return adminMemberQueryService.getUsersByIds(memberIds); + } } diff --git a/user-service/src/test/java/com/comatching/user/domain/member/service/AdminMemberQueryServiceImplTest.java b/user-service/src/test/java/com/comatching/user/domain/member/service/AdminMemberQueryServiceImplTest.java index 77a279e..d0f5f44 100644 --- a/user-service/src/test/java/com/comatching/user/domain/member/service/AdminMemberQueryServiceImplTest.java +++ b/user-service/src/test/java/com/comatching/user/domain/member/service/AdminMemberQueryServiceImplTest.java @@ -2,6 +2,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; import java.util.List; import java.util.Optional; @@ -19,6 +20,7 @@ import com.comatching.common.domain.enums.Gender; import com.comatching.common.domain.enums.MemberRole; import com.comatching.common.domain.enums.MemberStatus; +import com.comatching.common.dto.member.AdminGiftCardUserProfileDto; import com.comatching.common.dto.member.AdminUserProfileDto; import com.comatching.common.dto.response.PagingResponse; import com.comatching.user.domain.member.entity.Member; @@ -92,6 +94,60 @@ void shouldReturnAdminUserDetail() { assertThat(result.nickname()).isEqualTo("상세유저"); } + @Test + @DisplayName("회원 ID 목록으로 활성 일반 회원 정보를 한 번에 조회한다") + void shouldReturnUsersByIds() { + List memberIds = List.of(3L, 1L); + Member third = createMemberWithProfile( + 3L, + "third@test.com", + "세번째", + "셋", + Gender.MALE, + "https://third" + ); + Member first = createMemberWithProfile( + 1L, + "first@test.com", + "첫번째", + "하나", + Gender.FEMALE, + "https://first" + ); + given(memberRepository.findAdminMembersByIds( + memberIds, + MemberStatus.ACTIVE, + MemberRole.ROLE_USER + )).willReturn(List.of(third, first)); + + List results = adminMemberQueryService.getUsersByIds(memberIds); + + then(memberRepository).should().findAdminMembersByIds( + memberIds, + MemberStatus.ACTIVE, + MemberRole.ROLE_USER + ); + assertThat(results).containsExactly( + new AdminGiftCardUserProfileDto(3L, "third@test.com", "세번째", "셋"), + new AdminGiftCardUserProfileDto(1L, "first@test.com", "첫번째", "하나") + ); + } + + @Test + @DisplayName("조회된 회원이 없으면 빈 사용자 목록을 반환한다") + void shouldReturnEmptyUsersByIds() { + List memberIds = List.of(404L); + given(memberRepository.findAdminMembersByIds( + memberIds, + MemberStatus.ACTIVE, + MemberRole.ROLE_USER + )).willReturn(List.of()); + + List results = adminMemberQueryService.getUsersByIds(memberIds); + + assertThat(results).isEmpty(); + } + private static Member createMemberWithProfile( Long id, String email, diff --git a/user-service/src/test/java/com/comatching/user/infra/controller/InternalAdminUserControllerTest.java b/user-service/src/test/java/com/comatching/user/infra/controller/InternalAdminUserControllerTest.java new file mode 100644 index 0000000..2f6e87f --- /dev/null +++ b/user-service/src/test/java/com/comatching/user/infra/controller/InternalAdminUserControllerTest.java @@ -0,0 +1,93 @@ +package com.comatching.user.infra.controller; + +import static org.mockito.BDDMockito.given; +import static org.mockito.BDDMockito.then; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.List; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.http.MediaType; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; + +import com.comatching.common.dto.member.AdminGiftCardUserProfileDto; +import com.comatching.user.domain.member.service.AdminMemberQueryService; +import com.fasterxml.jackson.databind.ObjectMapper; + +@ExtendWith(MockitoExtension.class) +@DisplayName("InternalAdminUserController API 계약 테스트") +class InternalAdminUserControllerTest { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + private MockMvc mockMvc; + + @Mock + private AdminMemberQueryService adminMemberQueryService; + + @InjectMocks + private InternalAdminUserController internalAdminUserController; + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders.standaloneSetup(internalAdminUserController).build(); + } + + @Test + @DisplayName("POST /api/internal/admin/users/bulk는 회원 ID 목록을 전달하고 사용자 정보를 반환한다") + void shouldReturnUsersByIds() throws Exception { + List memberIds = List.of(3L, 1L); + given(adminMemberQueryService.getUsersByIds(memberIds)).willReturn(List.of( + new AdminGiftCardUserProfileDto(3L, "third@example.com", "세번째", "셋"), + new AdminGiftCardUserProfileDto(1L, "first@example.com", "첫번째", "하나") + )); + + mockMvc.perform(post("/api/internal/admin/users/bulk") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(memberIds))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$[0].id").value(3)) + .andExpect(jsonPath("$[0].email").value("third@example.com")) + .andExpect(jsonPath("$[0].realName").value("세번째")) + .andExpect(jsonPath("$[0].nickname").value("셋")) + .andExpect(jsonPath("$[1].id").value(1)) + .andExpect(jsonPath("$[1].email").value("first@example.com")); + + then(adminMemberQueryService).should().getUsersByIds(memberIds); + } + + @Test + @DisplayName("POST 일괄 회원 조회는 빈 ID 목록도 서비스에 그대로 전달한다") + void shouldDelegateEmptyMemberIds() throws Exception { + given(adminMemberQueryService.getUsersByIds(List.of())).willReturn(List.of()); + + mockMvc.perform(post("/api/internal/admin/users/bulk") + .contentType(MediaType.APPLICATION_JSON) + .content("[]")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$").isArray()) + .andExpect(jsonPath("$").isEmpty()); + + then(adminMemberQueryService).should().getUsersByIds(List.of()); + } + + @Test + @DisplayName("POST 일괄 회원 조회의 요청 본문이 배열이 아니면 400을 반환한다") + void shouldRejectMalformedMemberIds() throws Exception { + mockMvc.perform(post("/api/internal/admin/users/bulk") + .contentType(MediaType.APPLICATION_JSON) + .content("{\"memberId\":1}")) + .andExpect(status().isBadRequest()); + + then(adminMemberQueryService).shouldHaveNoInteractions(); + } +} From 9ad60d02f43eccfb806f1c77a7a0930a9ea535be Mon Sep 17 00:00:00 2001 From: jinwook219 Date: Thu, 10 Sep 2026 20:50:29 +0900 Subject: [PATCH 18/22] =?UTF-8?q?refactor(item):=20=EB=A3=B0=EB=A0=9B=20AP?= =?UTF-8?q?I=20=EA=B2=BD=EB=A1=9C=EB=A5=BC=20item-service=20prefix?= =?UTF-8?q?=EB=A1=9C=20=ED=86=B5=EC=9D=BC=20=20=20=20=20-=20=EB=A3=B0?= =?UTF-8?q?=EB=A0=9B=20API=20=EA=B2=BD=EB=A1=9C=EB=A5=BC=20/api/roulette?= =?UTF-8?q?=EC=97=90=EC=84=9C=20/api/items/roulette=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20=20=20=20=20-=20Gateway=EC=9D=98=20=EA=B8=B0?= =?UTF-8?q?=EC=A1=B4=20/api/items/**=20=EB=9D=BC=EC=9A=B0=ED=8C=85=20?= =?UTF-8?q?=EA=B7=9C=EC=B9=99=EC=97=90=20=EB=A7=9E=EA=B2=8C=20=EA=B2=BD?= =?UTF-8?q?=EB=A1=9C=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../comatching/item/infra/controller/RouletteController.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/item-service/src/main/java/com/comatching/item/infra/controller/RouletteController.java b/item-service/src/main/java/com/comatching/item/infra/controller/RouletteController.java index 81e338f..44d3a8f 100644 --- a/item-service/src/main/java/com/comatching/item/infra/controller/RouletteController.java +++ b/item-service/src/main/java/com/comatching/item/infra/controller/RouletteController.java @@ -14,7 +14,7 @@ @RestController @AllArgsConstructor -@RequestMapping("/api/roulette") +@RequestMapping("/api/items/roulette") public class RouletteController { private final RouletteService rouletteService; From d10b4d1104d5c604503c5d851ab304cc7d989223 Mon Sep 17 00:00:00 2001 From: choi-jin-wook Date: Sat, 12 Sep 2026 02:29:49 +0900 Subject: [PATCH 19/22] =?UTF-8?q?test(item):=20=EB=A3=B0=EB=A0=9B=20API=20?= =?UTF-8?q?=EA=B2=BD=EB=A1=9C=20=EB=B3=80=EA=B2=BD=EC=9D=84=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=EC=97=90=20=EB=B0=98=EC=98=81=20=20=20=20=20?= =?UTF-8?q?-=20RouletteControllerTest=20=EC=9A=94=EC=B2=AD=20=EA=B2=BD?= =?UTF-8?q?=EB=A1=9C=EB=A5=BC=20/api/items/roulette=EB=A1=9C=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD=20=20=20=20=20-=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=ED=91=9C=EC=8B=9C=EB=AA=85=EC=9D=84=20=EB=B3=80=EA=B2=BD?= =?UTF-8?q?=EB=90=9C=20API=20=EA=B2=BD=EB=A1=9C=EC=97=90=20=EB=A7=9E?= =?UTF-8?q?=EA=B2=8C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/RouletteControllerTest.java | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/item-service/src/test/java/com/comatching/item/infra/controller/RouletteControllerTest.java b/item-service/src/test/java/com/comatching/item/infra/controller/RouletteControllerTest.java index 30f7bf0..80af38d 100644 --- a/item-service/src/test/java/com/comatching/item/infra/controller/RouletteControllerTest.java +++ b/item-service/src/test/java/com/comatching/item/infra/controller/RouletteControllerTest.java @@ -52,12 +52,12 @@ void setUp() { } @Test - @DisplayName("POST /api/roulette/{rouletteType}/spins는 회원과 룰렛 타입을 전달하고 보상명을 반환한다") + @DisplayName("POST /api/items/roulette/{rouletteType}/spins는 회원과 룰렛 타입을 전달하고 보상명을 반환한다") void shouldSpinRouletteAndReturnRewardName() throws Exception { given(rouletteService.spinRoulette(any(MemberInfo.class), eq(RouletteType.SPECIAL))) .willReturn(new RouletteSpinResponse("2만원권 상품권")); - mockMvc.perform(post("/api/roulette/SPECIAL/spins") + mockMvc.perform(post("/api/items/roulette/SPECIAL/spins") .header("X-Member-Id", "1") .header("X-Member-Email", "member@example.com") .header("X-Member-Role", "USER")) @@ -76,7 +76,7 @@ void shouldSpinRouletteAndReturnRewardName() throws Exception { @Test @DisplayName("지원하지 않는 rouletteType 경로 값이면 400을 반환한다") void shouldRejectUnsupportedRouletteType() throws Exception { - mockMvc.perform(post("/api/roulette/PAID/spins") + mockMvc.perform(post("/api/items/roulette/PAID/spins") .header("X-Member-Id", "1")) .andExpect(status().isBadRequest()) .andExpect(jsonPath("$.code").value(GeneralErrorCode.TYPE_MISMATCH.getCode())); @@ -85,12 +85,12 @@ void shouldRejectUnsupportedRouletteType() throws Exception { } @Test - @DisplayName("GET /api/roulette는 무료·스페셜 룰렛 참여 여부와 오늘 결제액을 반환한다") + @DisplayName("GET /api/items/roulette는 무료·스페셜 룰렛 참여 여부와 오늘 결제액을 반환한다") void shouldReturnRoulettePage() throws Exception { given(rouletteService.roulettePage(any(MemberInfo.class))) .willReturn(new RoulettePageResponse(true, false, 3500L)); - mockMvc.perform(get("/api/roulette") + mockMvc.perform(get("/api/items/roulette") .header("X-Member-Id", "1") .header("X-Member-Email", "member@example.com") .header("X-Member-Role", "USER")) @@ -109,12 +109,12 @@ void shouldReturnRoulettePage() throws Exception { } @Test - @DisplayName("GET /api/roulette는 두 룰렛의 참여 여부를 독립적으로 반환한다") + @DisplayName("GET /api/items/roulette는 두 룰렛의 참여 여부를 독립적으로 반환한다") void shouldReturnEachRouletteParticipationStatus() throws Exception { given(rouletteService.roulettePage(any(MemberInfo.class))) .willReturn(new RoulettePageResponse(false, true, 3499L)); - mockMvc.perform(get("/api/roulette") + mockMvc.perform(get("/api/items/roulette") .header("X-Member-Id", "1") .header("X-Member-Email", "member@example.com") .header("X-Member-Role", "USER")) @@ -133,12 +133,12 @@ void shouldReturnEachRouletteParticipationStatus() throws Exception { } @Test - @DisplayName("GET /api/roulette는 rouletteType 쿼리 파라미터를 요구하지 않는다") + @DisplayName("GET /api/items/roulette는 rouletteType 쿼리 파라미터를 요구하지 않는다") void shouldNotRequireRouletteTypeOnRoulettePage() throws Exception { given(rouletteService.roulettePage(any(MemberInfo.class))) .willReturn(new RoulettePageResponse(false, false, 0L)); - mockMvc.perform(get("/api/roulette") + mockMvc.perform(get("/api/items/roulette") .header("X-Member-Id", "1") .header("X-Member-Email", "member@example.com") .header("X-Member-Role", "USER")) From 5bd2c290daabf8f4a8d3cf97b7250080fcbc9d18 Mon Sep 17 00:00:00 2001 From: choi-jin-wook Date: Sat, 12 Sep 2026 19:17:52 +0900 Subject: [PATCH 20/22] =?UTF-8?q?fix(item):=20=ED=9A=8C=EC=9B=90=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B4=EA=B0=80=20=EC=97=86=EB=8A=94=20=EC=83=81?= =?UTF-8?q?=ED=92=88=EA=B6=8C=20=EB=8B=B9=EC=B2=A8=EC=9E=90=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EC=8B=A4=ED=8C=A8=20=EB=B0=A9=EC=A7=80=20=20=20=20?= =?UTF-8?q?=20-=20=EA=B4=80=EB=A6=AC=EC=9E=90=20=EB=A3=B0=EB=A0=9B=20?= =?UTF-8?q?=EC=84=9C=EB=B9=84=EC=8A=A4=EB=A5=BC=20admin=20=EB=8F=84?= =?UTF-8?q?=EB=A9=94=EC=9D=B8=20=ED=8C=A8=ED=82=A4=EC=A7=80=EB=A1=9C=20?= =?UTF-8?q?=EC=9D=B4=EB=8F=99=20=20=20=20=20-=20=ED=9A=8C=EC=9B=90=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B4=EA=B0=80=20=EB=88=84=EB=9D=BD=EB=90=9C=20?= =?UTF-8?q?=EB=8B=B9=EC=B2=A8=20=EC=9D=B4=EB=A0=A5=EC=9D=80=20=EC=9D=91?= =?UTF-8?q?=EB=8B=B5=20=EB=AA=A9=EB=A1=9D=EC=97=90=EC=84=9C=20=EC=A0=9C?= =?UTF-8?q?=EC=99=B8=20=20=20=20=20-=20=EC=84=9C=EB=B9=84=EC=8A=A4=20?= =?UTF-8?q?=ED=8C=A8=ED=82=A4=EC=A7=80=20=EC=9D=B4=EB=8F=99=EC=97=90=20?= =?UTF-8?q?=EB=A7=9E=EC=B6=B0=20=EC=BB=A8=ED=8A=B8=EB=A1=A4=EB=9F=AC?= =?UTF-8?q?=EC=99=80=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20import=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/AdminRouletteService.java | 2 +- .../service/AdminRouletteServiceImpl.java | 18 +++--------------- .../controller/AdminRouletteController.java | 2 +- .../service/AdminRouletteServiceImplTest.java | 1 + .../AdminRouletteControllerTest.java | 2 +- 5 files changed, 7 insertions(+), 18 deletions(-) rename item-service/src/main/java/com/comatching/item/domain/{roulette => admin}/service/AdminRouletteService.java (90%) rename item-service/src/main/java/com/comatching/item/domain/{roulette => admin}/service/AdminRouletteServiceImpl.java (84%) diff --git a/item-service/src/main/java/com/comatching/item/domain/roulette/service/AdminRouletteService.java b/item-service/src/main/java/com/comatching/item/domain/admin/service/AdminRouletteService.java similarity index 90% rename from item-service/src/main/java/com/comatching/item/domain/roulette/service/AdminRouletteService.java rename to item-service/src/main/java/com/comatching/item/domain/admin/service/AdminRouletteService.java index f4bd9d2..24069d1 100644 --- a/item-service/src/main/java/com/comatching/item/domain/roulette/service/AdminRouletteService.java +++ b/item-service/src/main/java/com/comatching/item/domain/admin/service/AdminRouletteService.java @@ -1,4 +1,4 @@ -package com.comatching.item.domain.roulette.service; +package com.comatching.item.domain.admin.service; import java.util.List; diff --git a/item-service/src/main/java/com/comatching/item/domain/roulette/service/AdminRouletteServiceImpl.java b/item-service/src/main/java/com/comatching/item/domain/admin/service/AdminRouletteServiceImpl.java similarity index 84% rename from item-service/src/main/java/com/comatching/item/domain/roulette/service/AdminRouletteServiceImpl.java rename to item-service/src/main/java/com/comatching/item/domain/admin/service/AdminRouletteServiceImpl.java index e7f2f07..c562edb 100644 --- a/item-service/src/main/java/com/comatching/item/domain/roulette/service/AdminRouletteServiceImpl.java +++ b/item-service/src/main/java/com/comatching/item/domain/admin/service/AdminRouletteServiceImpl.java @@ -1,4 +1,4 @@ -package com.comatching.item.domain.roulette.service; +package com.comatching.item.domain.admin.service; import java.util.List; import java.util.Map; @@ -44,9 +44,10 @@ public List getUnpaidGiftCardWinners() { // 가져온 유저 정보 당첨 정보와 매핑 return histories.stream() + .filter(history -> usersById.containsKey(history.getMemberId())) .map(history -> AdminGiftCardWinnerResponse.from( history, - getUserOrThrow(usersById, history.getMemberId()) + usersById.get(history.getMemberId()) )) .toList(); } @@ -88,17 +89,4 @@ private Map getUsersByMemberId(List usersById, - Long memberId - ) { - AdminGiftCardUserProfileDto user = usersById.get(memberId); - if (user == null) { - throw new BusinessException(ItemErrorCode.TARGET_USER_NOT_FOUND); - } - return user; - } - } diff --git a/item-service/src/main/java/com/comatching/item/infra/controller/AdminRouletteController.java b/item-service/src/main/java/com/comatching/item/infra/controller/AdminRouletteController.java index 4965ff4..c39d907 100644 --- a/item-service/src/main/java/com/comatching/item/infra/controller/AdminRouletteController.java +++ b/item-service/src/main/java/com/comatching/item/infra/controller/AdminRouletteController.java @@ -15,7 +15,7 @@ import com.comatching.common.dto.member.MemberInfo; import com.comatching.common.dto.response.ApiResponse; import com.comatching.item.domain.roulette.dto.response.AdminGiftCardWinnerResponse; -import com.comatching.item.domain.roulette.service.AdminRouletteService; +import com.comatching.item.domain.admin.service.AdminRouletteService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; diff --git a/item-service/src/test/java/com/comatching/item/domain/roulette/service/AdminRouletteServiceImplTest.java b/item-service/src/test/java/com/comatching/item/domain/roulette/service/AdminRouletteServiceImplTest.java index 255a27a..a76d93f 100644 --- a/item-service/src/test/java/com/comatching/item/domain/roulette/service/AdminRouletteServiceImplTest.java +++ b/item-service/src/test/java/com/comatching/item/domain/roulette/service/AdminRouletteServiceImplTest.java @@ -9,6 +9,7 @@ import java.util.List; import java.util.Optional; +import com.comatching.item.domain.admin.service.AdminRouletteServiceImpl; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; diff --git a/item-service/src/test/java/com/comatching/item/infra/controller/AdminRouletteControllerTest.java b/item-service/src/test/java/com/comatching/item/infra/controller/AdminRouletteControllerTest.java index 574ee18..58d6212 100644 --- a/item-service/src/test/java/com/comatching/item/infra/controller/AdminRouletteControllerTest.java +++ b/item-service/src/test/java/com/comatching/item/infra/controller/AdminRouletteControllerTest.java @@ -25,7 +25,7 @@ import com.comatching.common.resolver.MemberInfoArgumentResolver; import com.comatching.item.domain.roulette.dto.response.AdminGiftCardWinnerResponse; import com.comatching.item.domain.roulette.enums.RouletteType; -import com.comatching.item.domain.roulette.service.AdminRouletteService; +import com.comatching.item.domain.admin.service.AdminRouletteService; import com.fasterxml.jackson.databind.ObjectMapper; @ExtendWith(MockitoExtension.class) From 48f30ffbb820ee7b7387e3145fe239b4c4d6ebfd Mon Sep 17 00:00:00 2001 From: choi-jin-wook Date: Sat, 12 Sep 2026 19:25:33 +0900 Subject: [PATCH 21/22] =?UTF-8?q?fix(gateway):=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=EC=9E=90=20=EC=82=AC=EC=9A=A9=EC=9E=90=20=EB=B0=8F=20=EA=B3=B5?= =?UTF-8?q?=EC=A7=80=20=EB=9D=BC=EC=9A=B0=ED=8C=85=20=EB=B3=B5=EA=B5=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 운영 환경의 관리자 사용자·공지 요청을 user-service로 라우팅\n- 활성 공지 경로를 운영 user-service 보호 라우트에 추가\n- 관리자 목록 기본 경로와 하위 경로를 모두 명시 --- .../src/main/resources/application-aws.yml | 11 +++++++++-- gateway-service/src/main/resources/application.yml | 4 ++-- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/gateway-service/src/main/resources/application-aws.yml b/gateway-service/src/main/resources/application-aws.yml index 808f97d..08e4ca3 100644 --- a/gateway-service/src/main/resources/application-aws.yml +++ b/gateway-service/src/main/resources/application-aws.yml @@ -48,7 +48,14 @@ spring: - id: user-service-protected uri: http://user-service:9000 predicates: - - Path=/api/auth/signup/profile, /api/auth/logout, /api/auth/password/change, /api/auth/withdraw, /api/members/** + - Path=/api/auth/signup/profile, /api/auth/logout, /api/auth/password/change, /api/auth/withdraw, /api/members/**, /api/notices/** + filters: + - AuthorizationHeaderFilter + + - id: admin-user-notice + uri: http://user-service:9000 + predicates: + - Path=/api/v1/admin/users, /api/v1/admin/users/**, /api/v1/admin/notices, /api/v1/admin/notices/** filters: - AuthorizationHeaderFilter @@ -145,4 +152,4 @@ management: health: # 게이트웨이의 8080 은 외부에 열려 있다. always 로 두면 인증 없이 # DB/Redis 구성요소 상태와 접속 정보 일부가 그대로 드러난다. - show-details: never \ No newline at end of file + show-details: never diff --git a/gateway-service/src/main/resources/application.yml b/gateway-service/src/main/resources/application.yml index 05c2473..c5a61a8 100644 --- a/gateway-service/src/main/resources/application.yml +++ b/gateway-service/src/main/resources/application.yml @@ -105,7 +105,7 @@ spring: - id: admin-user-notice uri: http://localhost:9000 predicates: - - Path=/api/v1/admin/users/**, /api/v1/admin/notices/** + - Path=/api/v1/admin/users, /api/v1/admin/users/**, /api/v1/admin/notices, /api/v1/admin/notices/** filters: - AuthorizationHeaderFilter @@ -149,4 +149,4 @@ management: http.server.requests: true spring.kafka.listener: true slo: - http.server.requests: 50ms,100ms,200ms,500ms,1s,2s \ No newline at end of file + http.server.requests: 50ms,100ms,200ms,500ms,1s,2s From 8f8868d3c98076002a8d117397c00d779fe6d21f Mon Sep 17 00:00:00 2001 From: choi-jin-wook Date: Sat, 12 Sep 2026 20:20:10 +0900 Subject: [PATCH 22/22] =?UTF-8?q?fix(item):=20=EB=A3=B0=EB=A0=9B=20?= =?UTF-8?q?=EC=B0=B8=EC=97=AC=20=EB=82=A0=EC=A7=9C=20=EA=B8=B0=EC=A4=80=20?= =?UTF-8?q?=EC=9D=BC=EA=B4=80=EC=84=B1=20=EB=B3=B4=EC=9E=A5=20=20=20-=20?= =?UTF-8?q?=EC=9A=94=EC=B2=AD=20=EC=8B=9C=EC=9E=91=20=EC=8B=9C=EA=B0=81?= =?UTF-8?q?=EC=9D=84=20=EA=B8=B0=EC=A4=80=EC=9C=BC=EB=A1=9C=20=EC=B0=B8?= =?UTF-8?q?=EC=97=AC=20=EB=82=A0=EC=A7=9C=EC=99=80=20=EA=B2=B0=EC=A0=9C=20?= =?UTF-8?q?=EC=A1=B0=ED=9A=8C=20=EA=B8=B0=EA=B0=84=EC=9D=84=20=EA=B3=A0?= =?UTF-8?q?=EC=A0=95=20=20=20-=20=EC=B0=B8=EC=97=AC=20=EC=97=AC=EB=B6=80?= =?UTF-8?q?=20=EC=A1=B0=ED=9A=8C=EB=A5=BC=20participationDate=20=EA=B8=B0?= =?UTF-8?q?=EC=A4=80=EC=9C=BC=EB=A1=9C=20=EB=B3=80=EA=B2=BD=20=20=20-=20?= =?UTF-8?q?=EC=9E=90=EC=A0=95=20=EA=B2=BD=EA=B3=84=EC=97=90=EC=84=9C=20?= =?UTF-8?q?=EB=8B=A4=EC=9D=8C=20=EB=82=A0=20=EC=B0=B8=EC=97=AC=20=EA=B8=B0?= =?UTF-8?q?=EB=A1=9D=EC=9C=BC=EB=A1=9C=20=EC=A0=80=EC=9E=A5=EB=90=98?= =?UTF-8?q?=EB=8A=94=20=EB=AC=B8=EC=A0=9C=20=EB=B0=A9=EC=A7=80=20=20=20-?= =?UTF-8?q?=20=EB=B3=80=EA=B2=BD=EB=90=9C=20=EB=82=A0=EC=A7=9C=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EA=B8=B0=EC=A4=80=EC=97=90=20=EB=A7=9E=EC=B6=B0=20?= =?UTF-8?q?=EB=A3=B0=EB=A0=9B=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/resources/application.yml | 2 +- .../roulette/entity/RouletteHistory.java | 8 +-- .../repository/RouletteHistoryRepository.java | 7 ++- .../roulette/service/RouletteServiceImpl.java | 20 ++++--- .../service/AdminRouletteServiceImplTest.java | 11 ++-- .../service/RouletteRewardRepositoryTest.java | 11 ++-- .../service/RouletteServiceImplTest.java | 53 ++++++++++--------- .../user/service/AdminMemberServiceImpl.java | 13 +++-- .../controller/AdminMemberControllerTest.java | 24 ++++----- 9 files changed, 79 insertions(+), 70 deletions(-) diff --git a/gateway-service/src/main/resources/application.yml b/gateway-service/src/main/resources/application.yml index c5a61a8..0038458 100644 --- a/gateway-service/src/main/resources/application.yml +++ b/gateway-service/src/main/resources/application.yml @@ -112,7 +112,7 @@ spring: - id: admin-payment-product-items uri: http://localhost:9006 predicates: - - Path=/api/v1/admin/shop/**, /api/v1/admin/payment/** + - Path=/api/v1/admin/shop/**, /api/v1/admin/payment/**, /api/v1/admin/roulette/** filters: - AuthorizationHeaderFilter diff --git a/item-service/src/main/java/com/comatching/item/domain/roulette/entity/RouletteHistory.java b/item-service/src/main/java/com/comatching/item/domain/roulette/entity/RouletteHistory.java index 5283173..231b883 100644 --- a/item-service/src/main/java/com/comatching/item/domain/roulette/entity/RouletteHistory.java +++ b/item-service/src/main/java/com/comatching/item/domain/roulette/entity/RouletteHistory.java @@ -50,7 +50,7 @@ public class RouletteHistory { private RouletteType rouletteType; @Column(name = "participated_at",nullable = false) - private final LocalDateTime participatedAt = LocalDateTime.now(); + private LocalDateTime participatedAt; @Column(name = "participation_date", nullable = false) private LocalDate participationDate; @@ -65,12 +65,14 @@ public RouletteHistory( Long memberId, RouletteReward reward, RouletteType rouletteType, - boolean rewardGranted + boolean rewardGranted, + LocalDateTime participatedAt ) { this.memberId = memberId; this.reward = reward; this.rouletteType = rouletteType; - this.participationDate = participatedAt.toLocalDate(); + this.participatedAt = participatedAt != null ? participatedAt : LocalDateTime.now(); + this.participationDate = this.participatedAt.toLocalDate(); this.rewardGranted = rewardGranted; } diff --git a/item-service/src/main/java/com/comatching/item/domain/roulette/repository/RouletteHistoryRepository.java b/item-service/src/main/java/com/comatching/item/domain/roulette/repository/RouletteHistoryRepository.java index c8d52f3..4d33760 100644 --- a/item-service/src/main/java/com/comatching/item/domain/roulette/repository/RouletteHistoryRepository.java +++ b/item-service/src/main/java/com/comatching/item/domain/roulette/repository/RouletteHistoryRepository.java @@ -1,6 +1,6 @@ package com.comatching.item.domain.roulette.repository; -import java.time.LocalDateTime; +import java.time.LocalDate; import java.util.List; import java.util.Optional; @@ -16,11 +16,10 @@ import jakarta.persistence.LockModeType; public interface RouletteHistoryRepository extends JpaRepository { - boolean existsByMemberIdAndRouletteTypeAndParticipatedAtGreaterThanEqualAndParticipatedAtLessThan( + boolean existsByMemberIdAndRouletteTypeAndParticipationDate( Long memberId, RouletteType rouletteType, - LocalDateTime startAt, - LocalDateTime endAt + LocalDate participationDate ); @EntityGraph(attributePaths = "reward") diff --git a/item-service/src/main/java/com/comatching/item/domain/roulette/service/RouletteServiceImpl.java b/item-service/src/main/java/com/comatching/item/domain/roulette/service/RouletteServiceImpl.java index aa10937..58eeb85 100644 --- a/item-service/src/main/java/com/comatching/item/domain/roulette/service/RouletteServiceImpl.java +++ b/item-service/src/main/java/com/comatching/item/domain/roulette/service/RouletteServiceImpl.java @@ -43,13 +43,15 @@ public class RouletteServiceImpl implements RouletteService { @Override @Transactional public RouletteSpinResponse spinRoulette(MemberInfo memberInfo, RouletteType rouletteType) { - LocalDateTime todayStart = LocalDate.now().atStartOfDay(); + LocalDateTime participatedAt = LocalDateTime.now(); + LocalDate participationDate = participatedAt.toLocalDate(); + LocalDateTime todayStart = participationDate.atStartOfDay(); LocalDateTime tomorrowStart = todayStart.plusDays(1); // 오늘 참여한 결과 더이상 불가능 boolean isParticipatedToday = rouletteHistoryRepository - .existsByMemberIdAndRouletteTypeAndParticipatedAtGreaterThanEqualAndParticipatedAtLessThan( - memberInfo.memberId(), rouletteType, todayStart, tomorrowStart); + .existsByMemberIdAndRouletteTypeAndParticipationDate( + memberInfo.memberId(), rouletteType, participationDate); if (isParticipatedToday) { throw new BusinessException(ItemErrorCode.ALREADY_PARTICIPATED_ROULETTE); } @@ -77,6 +79,7 @@ public RouletteSpinResponse spinRoulette(MemberInfo memberInfo, RouletteType rou .reward(rouletteReward) .rouletteType(rouletteType) .rewardGranted(rewardGranted) + .participatedAt(participatedAt) .build()); } catch (DataIntegrityViolationException exception) { throw new BusinessException(ItemErrorCode.ALREADY_PARTICIPATED_ROULETTE); @@ -88,18 +91,19 @@ public RouletteSpinResponse spinRoulette(MemberInfo memberInfo, RouletteType rou @Override public RoulettePageResponse roulettePage(MemberInfo memberInfo) { // 오늘 날짜 - LocalDateTime todayStart = LocalDate.now().atStartOfDay(); + LocalDate participationDate = LocalDate.now(); + LocalDateTime todayStart = participationDate.atStartOfDay(); LocalDateTime tomorrowStart = todayStart.plusDays(1); // 오늘 무료 룰렛 참여 여부 boolean isFreeParticipated = rouletteHistoryRepository - .existsByMemberIdAndRouletteTypeAndParticipatedAtGreaterThanEqualAndParticipatedAtLessThan( - memberInfo.memberId(), RouletteType.FREE, todayStart, tomorrowStart); + .existsByMemberIdAndRouletteTypeAndParticipationDate( + memberInfo.memberId(), RouletteType.FREE, participationDate); // 오늘 유료 룰렛 참여 여부 boolean isSpecialParticipated = rouletteHistoryRepository - .existsByMemberIdAndRouletteTypeAndParticipatedAtGreaterThanEqualAndParticipatedAtLessThan( - memberInfo.memberId(), RouletteType.SPECIAL, todayStart, tomorrowStart); + .existsByMemberIdAndRouletteTypeAndParticipationDate( + memberInfo.memberId(), RouletteType.SPECIAL, participationDate); // 오늘 결제액 long totalPay = orderRepository.sumApprovedPriceByMemberIdAndDecidedAtBetween( diff --git a/item-service/src/test/java/com/comatching/item/domain/roulette/service/AdminRouletteServiceImplTest.java b/item-service/src/test/java/com/comatching/item/domain/roulette/service/AdminRouletteServiceImplTest.java index a76d93f..b3fbb93 100644 --- a/item-service/src/test/java/com/comatching/item/domain/roulette/service/AdminRouletteServiceImplTest.java +++ b/item-service/src/test/java/com/comatching/item/domain/roulette/service/AdminRouletteServiceImplTest.java @@ -90,18 +90,17 @@ void shouldRequestDistinctUsersAndReturnEveryWinningHistory() { } @Test - @DisplayName("당첨 회원 정보가 응답에서 누락되면 대상 사용자 없음 예외가 발생한다") - void shouldThrowWhenWinnerProfileIsMissing() { + @DisplayName("당첨 회원 정보가 응답에서 누락되면 해당 당첨 이력을 제외한다") + void shouldSkipWinnerWhenProfileIsMissing() { RouletteHistory history = history(101L, 1L, "1만원권 상품권", false); given(rouletteHistoryRepository .findAllByReward_RewardTypeAndRewardGrantedFalseOrderByParticipatedAtDesc(RewardType.GIFT_CARD)) .willReturn(List.of(history)); given(userAdminClient.getUsersByIds(List.of(1L))).willReturn(List.of()); - assertThatThrownBy(adminRouletteService::getUnpaidGiftCardWinners) - .isInstanceOf(BusinessException.class) - .satisfies(exception -> assertThat(((BusinessException)exception).getErrorCode()) - .isEqualTo(ItemErrorCode.TARGET_USER_NOT_FOUND)); + List responses = adminRouletteService.getUnpaidGiftCardWinners(); + + assertThat(responses).isEmpty(); } @Test diff --git a/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteRewardRepositoryTest.java b/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteRewardRepositoryTest.java index c7209e3..8d047e4 100644 --- a/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteRewardRepositoryTest.java +++ b/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteRewardRepositoryTest.java @@ -198,9 +198,11 @@ void shouldFindTodayFreeHistoryForSameMember() { void shouldNotFindPastFreeHistory() { RouletteHistory history = persistHistory(1L, RouletteType.FREE); entityManager.flush(); + LocalDate yesterday = LocalDate.now().minusDays(1); jdbcTemplate.update( - "UPDATE roulette_history SET participated_at = ? WHERE id = ?", - Timestamp.valueOf(LocalDate.now().minusDays(1).atTime(12, 0)), + "UPDATE roulette_history SET participated_at = ?, participation_date = ? WHERE id = ?", + Timestamp.valueOf(yesterday.atTime(12, 0)), + yesterday, history.getId()); entityManager.clear(); @@ -341,10 +343,9 @@ private RouletteHistory persistHistory(Long memberId, RouletteType rouletteType) } private boolean existsHistoryToday(Long memberId, RouletteType rouletteType) { - LocalDateTime todayStart = LocalDate.now().atStartOfDay(); return rouletteHistoryRepository - .existsByMemberIdAndRouletteTypeAndParticipatedAtGreaterThanEqualAndParticipatedAtLessThan( - memberId, rouletteType, todayStart, todayStart.plusDays(1)); + .existsByMemberIdAndRouletteTypeAndParticipationDate( + memberId, rouletteType, LocalDate.now()); } private RouletteReward reward( diff --git a/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteServiceImplTest.java b/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteServiceImplTest.java index 6fca4d0..1318c35 100644 --- a/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteServiceImplTest.java +++ b/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteServiceImplTest.java @@ -10,6 +10,7 @@ import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; +import java.time.LocalDate; import java.time.LocalDateTime; import java.util.Optional; @@ -65,9 +66,9 @@ class RouletteServiceImplTest { @DisplayName("오늘 무료 룰렛에 참여하지 않았으면 미참여 상태를 반환한다") void shouldReturnFreeRouletteAsNotParticipated() { given(rouletteHistoryRepository - .existsByMemberIdAndRouletteTypeAndParticipatedAtGreaterThanEqualAndParticipatedAtLessThan( + .existsByMemberIdAndRouletteTypeAndParticipationDate( eq(MEMBER.memberId()), eq(RouletteType.FREE), - any(LocalDateTime.class), any(LocalDateTime.class))) + any(LocalDate.class))) .willReturn(false); RoulettePageResponse response = rouletteService.roulettePage(MEMBER); @@ -81,9 +82,9 @@ void shouldReturnFreeRouletteAsNotParticipated() { @DisplayName("오늘 무료 룰렛에 이미 참여했으면 참여 상태를 반환한다") void shouldReturnFreeRouletteAsParticipated() { given(rouletteHistoryRepository - .existsByMemberIdAndRouletteTypeAndParticipatedAtGreaterThanEqualAndParticipatedAtLessThan( + .existsByMemberIdAndRouletteTypeAndParticipationDate( eq(MEMBER.memberId()), eq(RouletteType.FREE), - any(LocalDateTime.class), any(LocalDateTime.class))) + any(LocalDate.class))) .willReturn(true); RoulettePageResponse response = rouletteService.roulettePage(MEMBER); @@ -92,9 +93,9 @@ void shouldReturnFreeRouletteAsParticipated() { assertThat(response.isSpecialParticipated()).isFalse(); assertThat(response.totalPay()).isZero(); then(rouletteHistoryRepository).should() - .existsByMemberIdAndRouletteTypeAndParticipatedAtGreaterThanEqualAndParticipatedAtLessThan( + .existsByMemberIdAndRouletteTypeAndParticipationDate( eq(MEMBER.memberId()), eq(RouletteType.FREE), - any(LocalDateTime.class), any(LocalDateTime.class)); + any(LocalDate.class)); } @Test @@ -104,14 +105,14 @@ void shouldReturnPaymentAndSpecialRouletteAsNotParticipated() { eq(MEMBER.memberId()), any(LocalDateTime.class), any(LocalDateTime.class))) .willReturn(3500L); given(rouletteHistoryRepository - .existsByMemberIdAndRouletteTypeAndParticipatedAtGreaterThanEqualAndParticipatedAtLessThan( + .existsByMemberIdAndRouletteTypeAndParticipationDate( eq(MEMBER.memberId()), eq(RouletteType.FREE), - any(LocalDateTime.class), any(LocalDateTime.class))) + any(LocalDate.class))) .willReturn(false); given(rouletteHistoryRepository - .existsByMemberIdAndRouletteTypeAndParticipatedAtGreaterThanEqualAndParticipatedAtLessThan( + .existsByMemberIdAndRouletteTypeAndParticipationDate( eq(MEMBER.memberId()), eq(RouletteType.SPECIAL), - any(LocalDateTime.class), any(LocalDateTime.class))) + any(LocalDate.class))) .willReturn(false); RoulettePageResponse response = rouletteService.roulettePage(MEMBER); @@ -128,14 +129,14 @@ void shouldReturnSpecialRouletteAsParticipated() { eq(MEMBER.memberId()), any(LocalDateTime.class), any(LocalDateTime.class))) .willReturn(10000L); given(rouletteHistoryRepository - .existsByMemberIdAndRouletteTypeAndParticipatedAtGreaterThanEqualAndParticipatedAtLessThan( + .existsByMemberIdAndRouletteTypeAndParticipationDate( eq(MEMBER.memberId()), eq(RouletteType.FREE), - any(LocalDateTime.class), any(LocalDateTime.class))) + any(LocalDate.class))) .willReturn(false); given(rouletteHistoryRepository - .existsByMemberIdAndRouletteTypeAndParticipatedAtGreaterThanEqualAndParticipatedAtLessThan( + .existsByMemberIdAndRouletteTypeAndParticipationDate( eq(MEMBER.memberId()), eq(RouletteType.SPECIAL), - any(LocalDateTime.class), any(LocalDateTime.class))) + any(LocalDate.class))) .willReturn(true); RoulettePageResponse response = rouletteService.roulettePage(MEMBER); @@ -152,14 +153,14 @@ void shouldReturnPaymentBelowSpecialRouletteMinimum() { eq(MEMBER.memberId()), any(LocalDateTime.class), any(LocalDateTime.class))) .willReturn(3499L); given(rouletteHistoryRepository - .existsByMemberIdAndRouletteTypeAndParticipatedAtGreaterThanEqualAndParticipatedAtLessThan( + .existsByMemberIdAndRouletteTypeAndParticipationDate( eq(MEMBER.memberId()), eq(RouletteType.FREE), - any(LocalDateTime.class), any(LocalDateTime.class))) + any(LocalDate.class))) .willReturn(false); given(rouletteHistoryRepository - .existsByMemberIdAndRouletteTypeAndParticipatedAtGreaterThanEqualAndParticipatedAtLessThan( + .existsByMemberIdAndRouletteTypeAndParticipationDate( eq(MEMBER.memberId()), eq(RouletteType.SPECIAL), - any(LocalDateTime.class), any(LocalDateTime.class))) + any(LocalDate.class))) .willReturn(false); RoulettePageResponse response = rouletteService.roulettePage(MEMBER); @@ -260,9 +261,9 @@ void shouldRecordNoPrizeWithoutGrantingItem() { @DisplayName("이미 무료 룰렛에 참여한 회원은 예외가 발생하고 보상을 처리하지 않는다") void shouldRejectDuplicatedFreeRouletteParticipation() { given(rouletteHistoryRepository - .existsByMemberIdAndRouletteTypeAndParticipatedAtGreaterThanEqualAndParticipatedAtLessThan( + .existsByMemberIdAndRouletteTypeAndParticipationDate( eq(MEMBER.memberId()), eq(RouletteType.FREE), - any(LocalDateTime.class), any(LocalDateTime.class))).willReturn(true); + any(LocalDate.class))).willReturn(true); assertThatThrownBy(() -> rouletteService.spinRoulette(MEMBER, RouletteType.FREE)) .isInstanceOf(BusinessException.class) @@ -279,9 +280,9 @@ void shouldRejectDuplicatedFreeRouletteParticipation() { @DisplayName("이미 스페셜 룰렛에 참여한 회원은 예외가 발생하고 결제액과 보상을 조회하지 않는다") void shouldRejectDuplicatedSpecialRouletteParticipation() { given(rouletteHistoryRepository - .existsByMemberIdAndRouletteTypeAndParticipatedAtGreaterThanEqualAndParticipatedAtLessThan( + .existsByMemberIdAndRouletteTypeAndParticipationDate( eq(MEMBER.memberId()), eq(RouletteType.SPECIAL), - any(LocalDateTime.class), any(LocalDateTime.class))).willReturn(true); + any(LocalDate.class))).willReturn(true); assertThatThrownBy(() -> rouletteService.spinRoulette(MEMBER, RouletteType.SPECIAL)) .isInstanceOf(BusinessException.class) @@ -299,9 +300,9 @@ void shouldRejectDuplicatedSpecialRouletteParticipation() { @DisplayName("오늘 누적 결제액이 3500원 미만이면 예외가 발생하고 추첨하지 않는다") void shouldRejectSpecialRouletteWhenPaymentIsBelowMinimum() { given(rouletteHistoryRepository - .existsByMemberIdAndRouletteTypeAndParticipatedAtGreaterThanEqualAndParticipatedAtLessThan( + .existsByMemberIdAndRouletteTypeAndParticipationDate( eq(MEMBER.memberId()), eq(RouletteType.SPECIAL), - any(LocalDateTime.class), any(LocalDateTime.class))).willReturn(false); + any(LocalDate.class))).willReturn(false); given(orderRepository.sumApprovedPriceByMemberIdAndDecidedAtBetween( eq(MEMBER.memberId()), any(LocalDateTime.class), any(LocalDateTime.class))) .willReturn(3499L); @@ -411,9 +412,9 @@ void shouldNotDecreaseRemainingCountBelowZero() { private void givenReward(RouletteType rouletteType, RouletteReward reward) { given(rouletteHistoryRepository - .existsByMemberIdAndRouletteTypeAndParticipatedAtGreaterThanEqualAndParticipatedAtLessThan( + .existsByMemberIdAndRouletteTypeAndParticipationDate( eq(MEMBER.memberId()), eq(rouletteType), - any(LocalDateTime.class), any(LocalDateTime.class))).willReturn(false); + any(LocalDate.class))).willReturn(false); if (rouletteType == RouletteType.SPECIAL) { given(orderRepository.sumApprovedPriceByMemberIdAndDecidedAtBetween( eq(MEMBER.memberId()), any(LocalDateTime.class), any(LocalDateTime.class))) diff --git a/user-service/src/main/java/com/comatching/user/domain/admin/user/service/AdminMemberServiceImpl.java b/user-service/src/main/java/com/comatching/user/domain/admin/user/service/AdminMemberServiceImpl.java index d55f9bb..e9587de 100644 --- a/user-service/src/main/java/com/comatching/user/domain/admin/user/service/AdminMemberServiceImpl.java +++ b/user-service/src/main/java/com/comatching/user/domain/admin/user/service/AdminMemberServiceImpl.java @@ -51,11 +51,14 @@ public PagingResponse getUsers(String keyword, Pageabl .map(this::toAdminUserProfileDto); List users = userPage.getContent(); - Map inventoryCountsByMemberId = itemAdminClient.getInventoryCounts( - users.stream() - .map(AdminUserProfileDto::id) - .toList() - ); + Map inventoryCountsByMemberId = + users.isEmpty() + ? Map.of() + : itemAdminClient.getInventoryCounts( + users.stream() + .map(AdminUserProfileDto::id) + .toList() + ); List summaries = users.stream() .map(user -> AdminUserSummaryResponse.from( diff --git a/user-service/src/test/java/com/comatching/user/infra/controller/AdminMemberControllerTest.java b/user-service/src/test/java/com/comatching/user/infra/controller/AdminMemberControllerTest.java index 58f117a..01c5a56 100644 --- a/user-service/src/test/java/com/comatching/user/infra/controller/AdminMemberControllerTest.java +++ b/user-service/src/test/java/com/comatching/user/infra/controller/AdminMemberControllerTest.java @@ -64,7 +64,7 @@ void setUp() { } @Test - @DisplayName("GET /api/admin/users - 사용자 목록을 조회한다") + @DisplayName("GET /api/v1/admin/users - 사용자 목록을 조회한다") void getUsers_success() throws Exception { // given AdminUserSummaryResponse summary = new AdminUserSummaryResponse( @@ -76,7 +76,7 @@ void getUsers_success() throws Exception { given(adminMemberService.getUsers(eq(null), any(Pageable.class))).willReturn(response); // when & then - mockMvc.perform(get("/api/admin/users") + mockMvc.perform(get("/api/v1/admin/users") .header("X-Member-Id", ADMIN_ID) .header("X-Member-Role", "ROLE_ADMIN")) .andExpect(status().isOk()) @@ -92,7 +92,7 @@ void getUsers_success() throws Exception { } @Test - @DisplayName("GET /api/admin/users?keyword= - 키워드를 서비스로 그대로 전달한다") + @DisplayName("GET /api/v1/admin/users?keyword= - 키워드를 서비스로 그대로 전달한다") void getUsers_withKeyword() throws Exception { // given PagingResponse response = @@ -101,7 +101,7 @@ void getUsers_withKeyword() throws Exception { given(adminMemberService.getUsers(eq("nickname"), any(Pageable.class))).willReturn(response); // when & then - mockMvc.perform(get("/api/admin/users") + mockMvc.perform(get("/api/v1/admin/users") .param("keyword", "nickname") .header("X-Member-Id", ADMIN_ID) .header("X-Member-Role", "ROLE_ADMIN")) @@ -113,16 +113,16 @@ void getUsers_withKeyword() throws Exception { } @Test - @DisplayName("GET /api/admin/users - 인증 헤더가 없으면 예외가 발생한다") + @DisplayName("GET /api/v1/admin/users - 인증 헤더가 없으면 예외가 발생한다") void getUsers_missingMemberIdHeader() throws Exception { - mockMvc.perform(get("/api/admin/users")) + mockMvc.perform(get("/api/v1/admin/users")) .andExpect(status().isInternalServerError()); then(adminMemberService).shouldHaveNoInteractions(); } @Test - @DisplayName("GET /api/admin/users/{memberId} - 사용자 상세와 인벤토리를 조회한다") + @DisplayName("GET /api/v1/admin/users/{memberId} - 사용자 상세와 인벤토리를 조회한다") void getUserDetail_success() throws Exception { // given AdminUserDetailResponse detail = new AdminUserDetailResponse( @@ -131,7 +131,7 @@ void getUserDetail_success() throws Exception { given(adminMemberService.getUserDetail(1L)).willReturn(detail); // when & then - mockMvc.perform(get("/api/admin/users/{memberId}", 1L) + mockMvc.perform(get("/api/v1/admin/users/{memberId}", 1L) .header("X-Member-Id", ADMIN_ID) .header("X-Member-Role", "ROLE_ADMIN")) .andExpect(status().isOk()) @@ -142,7 +142,7 @@ void getUserDetail_success() throws Exception { } @Test - @DisplayName("PATCH /api/admin/users/{memberId}/items - 인벤토리 조정에 성공하면 200을 반환한다") + @DisplayName("PATCH /api/v1/admin/users/{memberId}/items - 인벤토리 조정에 성공하면 200을 반환한다") void updateUserInventory_success() throws Exception { // given AdminInventoryUpdateRequest request = new AdminInventoryUpdateRequest( @@ -150,7 +150,7 @@ void updateUserInventory_success() throws Exception { ); // when & then - mockMvc.perform(patch("/api/admin/users/{memberId}/items", 1L) + mockMvc.perform(patch("/api/v1/admin/users/{memberId}/items", 1L) .header("X-Member-Id", ADMIN_ID) .header("X-Member-Role", "ROLE_ADMIN") .contentType(MediaType.APPLICATION_JSON) @@ -162,7 +162,7 @@ void updateUserInventory_success() throws Exception { } @Test - @DisplayName("PATCH /api/admin/users/{memberId}/items - 대상 사용자가 없으면 ITEM-004를 반환한다") + @DisplayName("PATCH /api/v1/admin/users/{memberId}/items - 대상 사용자가 없으면 ITEM-004를 반환한다") void updateUserInventory_targetUserNotFound() throws Exception { // given AdminInventoryUpdateRequest request = new AdminInventoryUpdateRequest( @@ -172,7 +172,7 @@ void updateUserInventory_targetUserNotFound() throws Exception { .given(adminMemberService).updateUserInventory(ADMIN_ID, 1L, request); // when & then - mockMvc.perform(patch("/api/admin/users/{memberId}/items", 1L) + mockMvc.perform(patch("/api/v1/admin/users/{memberId}/items", 1L) .header("X-Member-Id", ADMIN_ID) .header("X-Member-Role", "ROLE_ADMIN") .contentType(MediaType.APPLICATION_JSON)