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/chat-service/build.gradle b/chat-service/build.gradle index 001a77b..1aed37d 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/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/gateway-service/src/main/resources/application-aws.yml b/gateway-service/src/main/resources/application-aws.yml index edfa430..664007d 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 diff --git a/gateway-service/src/main/resources/application.yml b/gateway-service/src/main/resources/application.yml index c5b2555..0038458 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/users/**, /api/v1/admin/notices, /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/**, /api/v1/admin/roulette/** + filters: + - AuthorizationHeaderFilter + + default-filters: - DedupeResponseHeader=Access-Control-Allow-Origin Access-Control-Allow-Credentials, RETAIN_FIRST @@ -133,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 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/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/domain/admin/service/AdminRouletteService.java b/item-service/src/main/java/com/comatching/item/domain/admin/service/AdminRouletteService.java new file mode 100644 index 0000000..24069d1 --- /dev/null +++ b/item-service/src/main/java/com/comatching/item/domain/admin/service/AdminRouletteService.java @@ -0,0 +1,14 @@ +package com.comatching.item.domain.admin.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/admin/service/AdminRouletteServiceImpl.java b/item-service/src/main/java/com/comatching/item/domain/admin/service/AdminRouletteServiceImpl.java new file mode 100644 index 0000000..c562edb --- /dev/null +++ b/item-service/src/main/java/com/comatching/item/domain/admin/service/AdminRouletteServiceImpl.java @@ -0,0 +1,92 @@ +package com.comatching.item.domain.admin.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() + .filter(history -> usersById.containsKey(history.getMemberId())) + .map(history -> AdminGiftCardWinnerResponse.from( + history, + usersById.get(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); + } + } + +} 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/item/repository/ItemRepository.java b/item-service/src/main/java/com/comatching/item/domain/item/repository/ItemRepository.java index 8055318..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 @@ -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,13 @@ List findAllUsableItems( @Param("memberId") Long memberId, @Param("itemType") ItemType itemType); + @Lock(LockModeType.PESSIMISTIC_WRITE) + 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/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/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/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/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..e3ff3f3 --- /dev/null +++ b/item-service/src/main/java/com/comatching/item/domain/roulette/dto/response/RoulettePageResponse.java @@ -0,0 +1,8 @@ +package com.comatching.item.domain.roulette.dto.response; + +public record RoulettePageResponse( + 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/dto/response/RouletteSpinResponse.java b/item-service/src/main/java/com/comatching/item/domain/roulette/dto/response/RouletteSpinResponse.java new file mode 100644 index 0000000..bbfc093 --- /dev/null +++ b/item-service/src/main/java/com/comatching/item/domain/roulette/dto/response/RouletteSpinResponse.java @@ -0,0 +1,6 @@ +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/entity/RouletteHistory.java b/item-service/src/main/java/com/comatching/item/domain/roulette/entity/RouletteHistory.java new file mode 100644 index 0000000..231b883 --- /dev/null +++ b/item-service/src/main/java/com/comatching/item/domain/roulette/entity/RouletteHistory.java @@ -0,0 +1,83 @@ +package com.comatching.item.domain.roulette.entity; + +import java.time.LocalDate; +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 jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@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 + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(name = "member_id", nullable = false) + private Long memberId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "reward_id", nullable = false) + private RouletteReward reward; + + @Enumerated(EnumType.STRING) + @Column(name = "roulette_type", nullable = false) + private RouletteType rouletteType; + + @Column(name = "participated_at",nullable = false) + private LocalDateTime participatedAt; + + @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, + boolean rewardGranted, + LocalDateTime participatedAt + ) { + this.memberId = memberId; + this.reward = reward; + this.rouletteType = rouletteType; + this.participatedAt = participatedAt != null ? participatedAt : LocalDateTime.now(); + this.participationDate = this.participatedAt.toLocalDate(); + this.rewardGranted = rewardGranted; + } + + public void markRewardAsGranted() { + // 관리자가 상품권을 실제 지급한 뒤 지급 완료 상태로 변경한다. + this.rewardGranted = true; + } +} 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..ad73b0f --- /dev/null +++ b/item-service/src/main/java/com/comatching/item/domain/roulette/entity/RouletteReward.java @@ -0,0 +1,73 @@ +package com.comatching.item.domain.roulette.entity; + +import com.comatching.item.domain.roulette.enums.RewardType; +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) + @Column(nullable = false) + private RewardType rewardType; + + @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, + RewardType rewardType, + int quantity, + int rangeStart, + int rangeEnd, + Integer remainingCount + ) { + this.rouletteType = rouletteType; + this.rewardName = rewardName; + this.rewardType = rewardType; + this.quantity = quantity; + this.rangeStart = rangeStart; + this.rangeEnd = rangeEnd; + this.remainingCount = remainingCount; + } + + public void decreaseRemainingCount() { + if (remainingCount != null && remainingCount > 0) { + remainingCount--; + } + } +} 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/enums/RouletteType.java b/item-service/src/main/java/com/comatching/item/domain/roulette/enums/RouletteType.java new file mode 100644 index 0000000..e58d33e --- /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, + SPECIAL +} \ No newline at end of file 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..4d33760 --- /dev/null +++ b/item-service/src/main/java/com/comatching/item/domain/roulette/repository/RouletteHistoryRepository.java @@ -0,0 +1,40 @@ +package com.comatching.item.domain.roulette.repository; + +import java.time.LocalDate; +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 existsByMemberIdAndRouletteTypeAndParticipationDate( + Long memberId, + RouletteType rouletteType, + LocalDate participationDate + ); + + @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 new file mode 100644 index 0000000..0b370ca --- /dev/null +++ b/item-service/src/main/java/com/comatching/item/domain/roulette/repository/RouletteRewardRepository.java @@ -0,0 +1,28 @@ +package com.comatching.item.domain.roulette.repository; + +import java.util.Optional; + +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 { + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @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 + """) + Optional 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..382eb34 --- /dev/null +++ b/item-service/src/main/java/com/comatching/item/domain/roulette/service/RouletteService.java @@ -0,0 +1,12 @@ +package com.comatching.item.domain.roulette.service; + +import com.comatching.common.dto.member.MemberInfo; +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); +} 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..58eeb85 --- /dev/null +++ b/item-service/src/main/java/com/comatching/item/domain/roulette/service/RouletteServiceImpl.java @@ -0,0 +1,183 @@ +package com.comatching.item.domain.roulette.service; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.Optional; +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.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.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; +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 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) { + LocalDateTime participatedAt = LocalDateTime.now(); + LocalDate participationDate = participatedAt.toLocalDate(); + LocalDateTime todayStart = participationDate.atStartOfDay(); + LocalDateTime tomorrowStart = todayStart.plusDays(1); + + // 오늘 참여한 결과 더이상 불가능 + boolean isParticipatedToday = rouletteHistoryRepository + .existsByMemberIdAndRouletteTypeAndParticipationDate( + memberInfo.memberId(), rouletteType, participationDate); + if (isParticipatedToday) { + throw new BusinessException(ItemErrorCode.ALREADY_PARTICIPATED_ROULETTE); + } + + // 결제액이 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); + } + + // 난수로 추첨 + RouletteReward rouletteReward = drawAvailableReward(rouletteType); + + // 보상에 따른 아이템 및 아이템 기록 룰렛 기록 추가 + boolean rewardGranted = grantReward(memberInfo.memberId(), rouletteReward); + + // 제한 재고만 감소시키며, remainingCount가 null인 무제한 보상은 유지한다. + rouletteReward.decreaseRemainingCount(); + + // 기록 남기기 + try { + rouletteHistoryRepository.saveAndFlush(RouletteHistory.builder() + .memberId(memberInfo.memberId()) + .reward(rouletteReward) + .rouletteType(rouletteType) + .rewardGranted(rewardGranted) + .participatedAt(participatedAt) + .build()); + } catch (DataIntegrityViolationException exception) { + throw new BusinessException(ItemErrorCode.ALREADY_PARTICIPATED_ROULETTE); + } + return new RouletteSpinResponse(rouletteReward.getRewardName()); + } + + + @Override + public RoulettePageResponse roulettePage(MemberInfo memberInfo) { + // 오늘 날짜 + LocalDate participationDate = LocalDate.now(); + LocalDateTime todayStart = participationDate.atStartOfDay(); + LocalDateTime tomorrowStart = todayStart.plusDays(1); + + // 오늘 무료 룰렛 참여 여부 + boolean isFreeParticipated = rouletteHistoryRepository + .existsByMemberIdAndRouletteTypeAndParticipationDate( + memberInfo.memberId(), RouletteType.FREE, participationDate); + + // 오늘 유료 룰렛 참여 여부 + boolean isSpecialParticipated = rouletteHistoryRepository + .existsByMemberIdAndRouletteTypeAndParticipationDate( + memberInfo.memberId(), RouletteType.SPECIAL, participationDate); + + // 오늘 결제액 + 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()); + + // 상품권 외 보상은 무제한 재고로 운영하며, 전체 보상 소진 시 처리 정책은 확정되지 않아 예외 처리를 비활성화한다. +// if (rouletteReward.isEmpty()) { +// throw new BusinessException(ItemErrorCode.NO_AVAILABLE_ROULETTE_REWARD); +// } + + return rouletteReward.get(); + } + + 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; + } + + + + // 보상을 저장하고 기록을 남겨주는 메서드 + 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 1123123..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 @@ -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_ROULETTE("ITEM-007", HttpStatus.BAD_REQUEST, "이미 룰렛에 참여하셨습니다"), + NOT_ENOUGH_PAYMENT_FOR_SPECIAL_ROULETTE("ITEM-008", 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..1d94c56 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,26 +1,30 @@ package com.comatching.item.infra.client; -import java.util.List; +/* + * 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; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.RequestParam; - -import com.comatching.common.dto.member.AdminUserProfileDto; -import com.comatching.common.dto.response.PagingResponse; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import com.comatching.common.dto.member.AdminGiftCardUserProfileDto; @FeignClient(name = "user-service-admin", url = "${user-service.url}", path = "/api/internal/admin/users") public interface UserAdminClient { - @GetMapping - PagingResponse getUsers( - @RequestParam(value = "keyword", required = false) String keyword, - @RequestParam("page") int page, - @RequestParam("size") int size, - @RequestParam(value = "sort", required = false) List sort - ); +// @GetMapping +// PagingResponse getUsers( +// @RequestParam(value = "keyword", required = false) String keyword, +// @RequestParam("page") int page, +// @RequestParam("size") int size, +// @RequestParam(value = "sort", required = false) List sort +// ); +// +// @GetMapping("/{memberId}") +// AdminUserProfileDto getUserDetail(@PathVariable Long memberId); - @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..c39d907 --- /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.admin.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 new file mode 100644 index 0000000..44d3a8f --- /dev/null +++ b/item-service/src/main/java/com/comatching/item/infra/controller/RouletteController.java @@ -0,0 +1,36 @@ +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.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 lombok.AllArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +@RestController +@AllArgsConstructor +@RequestMapping("/api/items/roulette") +public class RouletteController { + private final RouletteService rouletteService; + + @PostMapping("/{rouletteType}/spins") + @Operation(summary = "룰렛 돌리는 api", description = "무료 룰렛과 유료 룰렛 (1일 1회 제한)") + 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 + ) { + return ResponseEntity.ok(ApiResponse.ok(rouletteService.roulettePage(memberInfo))); + } +} 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 92% 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 5ef736d..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; @@ -37,7 +42,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, @@ -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/internal/InternalAdminItemController.java b/item-service/src/main/java/com/comatching/item/infra/controller/internal/InternalAdminItemController.java new file mode 100644 index 0000000..582c7b1 --- /dev/null +++ b/item-service/src/main/java/com/comatching/item/infra/controller/internal/InternalAdminItemController.java @@ -0,0 +1,46 @@ +package com.comatching.item.infra.controller.internal; + +import java.util.List; +import java.util.Map; + +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 com.comatching.item.domain.admin.service.AdminItemCommandService; +import com.comatching.item.domain.admin.service.AdminItemQueryService; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/api/internal/admin/items") +@RequiredArgsConstructor +public class InternalAdminItemController { + private final AdminItemQueryService adminItemQueryService; + private final AdminItemCommandService adminItemCommandService; + + @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 + ) { + 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/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/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..b3fbb93 --- /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 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; +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 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()); + + List responses = adminRouletteService.getUnpaidGiftCardWinners(); + + assertThat(responses).isEmpty(); + } + + @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 new file mode 100644 index 0000000..b87df75 --- /dev/null +++ b/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteConcurrencyTest.java @@ -0,0 +1,266 @@ +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; +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.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.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.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; +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; + + @Autowired + private RouletteService rouletteService; + @Autowired + private RouletteRewardRepository rouletteRewardRepository; + @Autowired + private ItemRepository itemRepository; + @Autowired + private ItemHistoryRepository itemHistoryRepository; + @Autowired + private JdbcTemplate jdbcTemplate; + @Autowired + private PlatformTransactionManager transactionManager; + @MockitoBean + private OrderRepository orderRepository; + + @Test + @Transactional(propagation = Propagation.NOT_SUPPORTED) + @DisplayName("단일 행 상품권은 동시 추첨 횟수만큼 재고가 차감된다") + void giftCardStockIsDecreasedForEveryConcurrentSpin() throws Exception { + int stock = REQUEST_COUNT; + Long limitedRewardId = inTransaction(() -> rouletteRewardRepository.save( + 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.SPECIAL)); + + assertThat(failures).isEmpty(); + RouletteReward savedReward = rouletteRewardRepository.findById(limitedRewardId).orElseThrow(); + 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); + assertThat(count("SELECT COUNT(*) FROM roulette_history")) + .isEqualTo(REQUEST_COUNT); + } + + @Test + @Transactional(propagation = Propagation.NOT_SUPPORTED) + @DisplayName("동일 회원의 무료 룰렛 동시 요청은 하루에 한 번만 성공한다") + void freeRouletteSucceedsOnlyOnceForSameMember() throws Exception { + Long memberId = 50L; + Long rewardId = inTransaction(() -> rouletteRewardRepository.save( + itemReward(RouletteType.FREE, "옵션권 1장")).getId()); + + 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_ROULETTE); + }); + assertThat(count("SELECT COUNT(*) FROM roulette_history WHERE member_id = ?", memberId)) + .isOne(); + assertThat(rouletteRewardRepository.findById(rewardId).orElseThrow().getRemainingCount()) + .isNull(); + 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장")).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()) + .isNull(); + assertThat(itemRepository.count()).isOne(); + assertThat(itemHistoryRepository.count()).isOne(); + } + + 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, + RewardType rewardType, + int rangeStart, + int rangeEnd, + Integer remainingCount + ) { + return RouletteReward.builder() + .rouletteType(rouletteType) + .rewardName(rewardName) + .rewardType(rewardType) + .quantity(rewardType == RewardType.GIFT_CARD ? 1 : 0) + .rangeStart(rangeStart) + .rangeEnd(rangeEnd) + .remainingCount(remainingCount) + .build(); + } + + private RouletteReward itemReward( + RouletteType rouletteType, + String rewardName + ) { + return RouletteReward.builder() + .rouletteType(rouletteType) + .rewardName(rewardName) + .rewardType(RewardType.OPTION_TICKET) + .quantity(1) + .rangeStart(1) + .rangeEnd(10000) + .remainingCount(null) + .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 { + } +} 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..8d047e4 --- /dev/null +++ b/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteRewardRepositoryTest.java @@ -0,0 +1,396 @@ +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.time.LocalDateTime; +import java.util.List; +import java.util.Optional; + +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; +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.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.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; + +@DataJpaTest +@ContextConfiguration(classes = RouletteRewardRepositoryTest.JpaTestConfig.class) +@Import(RouletteServiceImpl.class) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_EACH_TEST_METHOD) +@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; + @MockitoBean + private OrderRepository orderRepository; + + @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(); + + Optional numberOne = rouletteRewardRepository + .findAvailableByRouletteTypeAndRouletteNumber(RouletteType.FREE, 1); + Optional numberTenThousand = rouletteRewardRepository + .findAvailableByRouletteTypeAndRouletteNumber(RouletteType.FREE, 10000); + + assertThat(numberOne.orElseThrow().getId()).isEqualTo(first.getId()); + assertThat(numberTenThousand.orElseThrow().getId()).isEqualTo(last.getId()); + } + + @Test + @DisplayName("풀세트 단일 행을 조회한다") + void shouldFindSingleFullSetReward() { + RouletteReward fullSet = persist(reward( + RouletteType.SPECIAL, "풀세트", RewardType.FULL_SET, 8201, 9200, null)); + flushAndClear(); + + Optional reward = rouletteRewardRepository + .findAvailableByRouletteTypeAndRouletteNumber(RouletteType.SPECIAL, 8500); + + assertThat(reward.orElseThrow().getId()).isEqualTo(fullSet.getId()); + } + + @Test + @DisplayName("재고가 소진된 제한 보상은 조회하지 않는다") + void shouldExcludeOutOfStockReward() { + persist(reward( + RouletteType.SPECIAL, "상품권", RewardType.GIFT_CARD, 9701, 9900, 0)); + flushAndClear(); + + Optional reward = rouletteRewardRepository + .findAvailableByRouletteTypeAndRouletteNumber(RouletteType.SPECIAL, 9800); + + assertThat(reward).isEmpty(); + } + + @Test + @DisplayName("같은 난수 범위라도 요청한 룰렛 타입의 보상만 조회한다") + void shouldSeparateRewardsByRouletteType() { + RouletteReward freeReward = persist(reward(RouletteType.FREE, "무료 보상", 1, 10000, null)); + persist(reward(RouletteType.SPECIAL, "스페셜 보상", 1, 10000, null)); + flushAndClear(); + + Optional reward = rouletteRewardRepository + .findAvailableByRouletteTypeAndRouletteNumber(RouletteType.FREE, 5000); + + 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() { + persistHistory(1L, RouletteType.FREE); + flushAndClear(); + + boolean exists = existsHistoryToday(1L, RouletteType.FREE); + + assertThat(exists).isTrue(); + } + + @Test + @DisplayName("과거 FREE 참여 이력만 있으면 false를 반환한다") + void shouldNotFindPastFreeHistory() { + RouletteHistory history = persistHistory(1L, RouletteType.FREE); + entityManager.flush(); + LocalDate yesterday = LocalDate.now().minusDays(1); + jdbcTemplate.update( + "UPDATE roulette_history SET participated_at = ?, participation_date = ? WHERE id = ?", + Timestamp.valueOf(yesterday.atTime(12, 0)), + yesterday, + history.getId()); + entityManager.clear(); + + boolean exists = existsHistoryToday(1L, RouletteType.FREE); + + assertThat(exists).isFalse(); + } + + @Test + @DisplayName("다른 회원의 오늘 FREE 참여 이력은 조회하지 않는다") + void shouldNotFindAnotherMembersFreeHistory() { + persistHistory(2L, RouletteType.FREE); + flushAndClear(); + + boolean exists = existsHistoryToday(1L, RouletteType.FREE); + + assertThat(exists).isFalse(); + } + + @Test + @DisplayName("같은 회원의 오늘 SPECIAL 참여 이력은 FREE 참여로 조회하지 않는다") + void shouldNotFindSpecialHistoryAsFreeHistory() { + persistHistory(1L, RouletteType.SPECIAL); + flushAndClear(); + + boolean exists = existsHistoryToday(1L, RouletteType.FREE); + + 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) + @DisplayName("아이템 이력 저장 실패 시 아이템과 룰렛 이력을 실제로 롤백한다") + void shouldRollbackSavedItemWhenItemHistorySaveFails() { + TransactionTemplate transactionTemplate = new TransactionTemplate(transactionManager); + transactionTemplate.executeWithoutResult(status -> rouletteRewardRepository.saveAndFlush( + reward(RouletteType.FREE, "옵션권 1장", RewardType.OPTION_TICKET, 1, 10000, null))); + 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((Integer)null); + } + + 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(); + } + + private RouletteHistory persistHistory(Long memberId, RouletteType rouletteType) { + RouletteReward historyReward = persist(reward( + RouletteType.FREE, "꽝", RewardType.NONE, 1, 10000, null)); + return entityManager.persist(RouletteHistory.builder() + .memberId(memberId) + .reward(historyReward) + .rouletteType(rouletteType) + .build()); + } + + private boolean existsHistoryToday(Long memberId, RouletteType rouletteType) { + return rouletteHistoryRepository + .existsByMemberIdAndRouletteTypeAndParticipationDate( + memberId, rouletteType, LocalDate.now()); + } + + private RouletteReward reward( + RouletteType rouletteType, + String rewardName, + int rangeStart, + int rangeEnd, + Integer remainingCount + ) { + return reward(rouletteType, rewardName, RewardType.NONE, rangeStart, rangeEnd, remainingCount); + } + + private RouletteReward reward( + RouletteType rouletteType, + String rewardName, + RewardType rewardType, + int rangeStart, + int rangeEnd, + Integer remainingCount + ) { + return RouletteReward.builder() + .rouletteType(rouletteType) + .rewardName(rewardName) + .rewardType(rewardType) + .quantity(rewardType.getItemType() == 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..1318c35 --- /dev/null +++ b/item-service/src/test/java/com/comatching/item/domain/roulette/service/RouletteServiceImplTest.java @@ -0,0 +1,459 @@ +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.LocalDate; +import java.time.LocalDateTime; +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.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.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; +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; + @Mock + private OrderRepository orderRepository; + + @InjectMocks + private RouletteServiceImpl rouletteService; + + @Test + @DisplayName("오늘 무료 룰렛에 참여하지 않았으면 미참여 상태를 반환한다") + void shouldReturnFreeRouletteAsNotParticipated() { + given(rouletteHistoryRepository + .existsByMemberIdAndRouletteTypeAndParticipationDate( + eq(MEMBER.memberId()), eq(RouletteType.FREE), + any(LocalDate.class))) + .willReturn(false); + + RoulettePageResponse response = rouletteService.roulettePage(MEMBER); + + assertThat(response.isFreeParticipated()).isFalse(); + assertThat(response.isSpecialParticipated()).isFalse(); + assertThat(response.totalPay()).isZero(); + } + + @Test + @DisplayName("오늘 무료 룰렛에 이미 참여했으면 참여 상태를 반환한다") + void shouldReturnFreeRouletteAsParticipated() { + given(rouletteHistoryRepository + .existsByMemberIdAndRouletteTypeAndParticipationDate( + eq(MEMBER.memberId()), eq(RouletteType.FREE), + any(LocalDate.class))) + .willReturn(true); + + RoulettePageResponse response = rouletteService.roulettePage(MEMBER); + + assertThat(response.isFreeParticipated()).isTrue(); + assertThat(response.isSpecialParticipated()).isFalse(); + assertThat(response.totalPay()).isZero(); + then(rouletteHistoryRepository).should() + .existsByMemberIdAndRouletteTypeAndParticipationDate( + eq(MEMBER.memberId()), eq(RouletteType.FREE), + any(LocalDate.class)); + } + + @Test + @DisplayName("오늘 결제액과 스페셜 룰렛 미참여 상태를 반환한다") + void shouldReturnPaymentAndSpecialRouletteAsNotParticipated() { + given(orderRepository.sumApprovedPriceByMemberIdAndDecidedAtBetween( + eq(MEMBER.memberId()), any(LocalDateTime.class), any(LocalDateTime.class))) + .willReturn(3500L); + given(rouletteHistoryRepository + .existsByMemberIdAndRouletteTypeAndParticipationDate( + eq(MEMBER.memberId()), eq(RouletteType.FREE), + any(LocalDate.class))) + .willReturn(false); + given(rouletteHistoryRepository + .existsByMemberIdAndRouletteTypeAndParticipationDate( + eq(MEMBER.memberId()), eq(RouletteType.SPECIAL), + any(LocalDate.class))) + .willReturn(false); + + RoulettePageResponse response = rouletteService.roulettePage(MEMBER); + + assertThat(response.isFreeParticipated()).isFalse(); + assertThat(response.isSpecialParticipated()).isFalse(); + assertThat(response.totalPay()).isEqualTo(3500L); + } + + @Test + @DisplayName("오늘 스페셜 룰렛에 이미 참여했으면 참여 상태와 결제액을 반환한다") + void shouldReturnSpecialRouletteAsParticipated() { + given(orderRepository.sumApprovedPriceByMemberIdAndDecidedAtBetween( + eq(MEMBER.memberId()), any(LocalDateTime.class), any(LocalDateTime.class))) + .willReturn(10000L); + given(rouletteHistoryRepository + .existsByMemberIdAndRouletteTypeAndParticipationDate( + eq(MEMBER.memberId()), eq(RouletteType.FREE), + any(LocalDate.class))) + .willReturn(false); + given(rouletteHistoryRepository + .existsByMemberIdAndRouletteTypeAndParticipationDate( + eq(MEMBER.memberId()), eq(RouletteType.SPECIAL), + any(LocalDate.class))) + .willReturn(true); + + RoulettePageResponse response = rouletteService.roulettePage(MEMBER); + + assertThat(response.isFreeParticipated()).isFalse(); + assertThat(response.isSpecialParticipated()).isTrue(); + assertThat(response.totalPay()).isEqualTo(10000L); + } + + @Test + @DisplayName("오늘 결제액이 3500원 미만이어도 결제액을 그대로 반환한다") + void shouldReturnPaymentBelowSpecialRouletteMinimum() { + given(orderRepository.sumApprovedPriceByMemberIdAndDecidedAtBetween( + eq(MEMBER.memberId()), any(LocalDateTime.class), any(LocalDateTime.class))) + .willReturn(3499L); + given(rouletteHistoryRepository + .existsByMemberIdAndRouletteTypeAndParticipationDate( + eq(MEMBER.memberId()), eq(RouletteType.FREE), + any(LocalDate.class))) + .willReturn(false); + given(rouletteHistoryRepository + .existsByMemberIdAndRouletteTypeAndParticipationDate( + eq(MEMBER.memberId()), eq(RouletteType.SPECIAL), + any(LocalDate.class))) + .willReturn(false); + + RoulettePageResponse response = rouletteService.roulettePage(MEMBER); + + assertThat(response.isFreeParticipated()).isFalse(); + assertThat(response.isSpecialParticipated()).isFalse(); + assertThat(response.totalPay()).isEqualTo(3499L); + } + + @Test + @DisplayName("일반 아이템 보상은 아이템과 아이템 이력을 저장하고 보상명을 반환한다") + void shouldGrantItemRewardAndReturnRewardName() { + RouletteReward reward = reward(RouletteType.FREE, "옵션권 2장", RewardType.OPTION_TICKET, 2, null); + givenReward(RouletteType.FREE, 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()); + + 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, null); + givenReward(RouletteType.SPECIAL, fullSet); + + RouletteSpinResponse response = rouletteService.spinRoulette(MEMBER, RouletteType.SPECIAL); + + assertThat(fullSet.getRemainingCount()).isNull(); + 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.OPTION_TICKET, 3), + org.assertj.core.groups.Tuple.tuple(ItemType.MATCHING_TICKET, 1) + ); + ArgumentCaptor itemHistoryCaptor = ArgumentCaptor.forClass(ItemHistory.class); + 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.OPTION_TICKET, ItemHistoryType.EVENT, 3), + org.assertj.core.groups.Tuple.tuple(ItemType.MATCHING_TICKET, ItemHistoryType.EVENT, 1) + ); + assertHistorySavedWith(fullSet, RouletteType.SPECIAL, true); + assertThat(response.rewardName()).isEqualTo("풀세트"); + } + + @Test + @DisplayName("상품권은 재고만 차감하고 룰렛 이력과 보상명을 남긴다") + void shouldRecordGiftCardWithoutGrantingItem() { + RouletteReward reward = reward(RouletteType.SPECIAL, "1만원권 상품권", RewardType.GIFT_CARD, 1, 3); + givenReward(RouletteType.SPECIAL, reward); + + RouletteSpinResponse response = rouletteService.spinRoulette(MEMBER, RouletteType.SPECIAL); + + assertThat(reward.getRemainingCount()).isEqualTo(2); + then(itemRepository).should(never()).save(any(Item.class)); + then(itemHistoryRepository).shouldHaveNoInteractions(); + assertHistorySavedWith(reward, RouletteType.SPECIAL, false); + assertThat(response.rewardName()).isEqualTo("1만원권 상품권"); + } + + @Test + @DisplayName("꽝은 아이템을 지급하지 않고 룰렛 이력과 보상명을 남긴다") + void shouldRecordNoPrizeWithoutGrantingItem() { + RouletteReward reward = reward(RouletteType.FREE, "꽝", RewardType.NONE, 0, null); + givenReward(RouletteType.FREE, reward); + + RouletteSpinResponse response = rouletteService.spinRoulette(MEMBER, RouletteType.FREE); + + then(itemRepository).should(never()).save(any(Item.class)); + then(itemHistoryRepository).shouldHaveNoInteractions(); + assertThat(reward.getRemainingCount()).isNull(); + assertHistorySavedWith(reward, RouletteType.FREE, false); + assertThat(response.rewardName()).isEqualTo("꽝"); + } + + @Test + @DisplayName("이미 무료 룰렛에 참여한 회원은 예외가 발생하고 보상을 처리하지 않는다") + void shouldRejectDuplicatedFreeRouletteParticipation() { + given(rouletteHistoryRepository + .existsByMemberIdAndRouletteTypeAndParticipationDate( + eq(MEMBER.memberId()), eq(RouletteType.FREE), + any(LocalDate.class))).willReturn(true); + + assertThatThrownBy(() -> rouletteService.spinRoulette(MEMBER, RouletteType.FREE)) + .isInstanceOf(BusinessException.class) + .satisfies(exception -> assertThat(((BusinessException)exception).getErrorCode()) + .isEqualTo(ItemErrorCode.ALREADY_PARTICIPATED_ROULETTE)); + + then(itemRepository).shouldHaveNoInteractions(); + then(rouletteRewardRepository).shouldHaveNoInteractions(); + then(itemHistoryRepository).shouldHaveNoInteractions(); + then(rouletteHistoryRepository).should(never()).saveAndFlush(any(RouletteHistory.class)); + } + + @Test + @DisplayName("이미 스페셜 룰렛에 참여한 회원은 예외가 발생하고 결제액과 보상을 조회하지 않는다") + void shouldRejectDuplicatedSpecialRouletteParticipation() { + given(rouletteHistoryRepository + .existsByMemberIdAndRouletteTypeAndParticipationDate( + eq(MEMBER.memberId()), eq(RouletteType.SPECIAL), + any(LocalDate.class))).willReturn(true); + + assertThatThrownBy(() -> rouletteService.spinRoulette(MEMBER, RouletteType.SPECIAL)) + .isInstanceOf(BusinessException.class) + .satisfies(exception -> assertThat(((BusinessException)exception).getErrorCode()) + .isEqualTo(ItemErrorCode.ALREADY_PARTICIPATED_ROULETTE)); + + then(orderRepository).shouldHaveNoInteractions(); + then(rouletteRewardRepository).shouldHaveNoInteractions(); + then(itemRepository).shouldHaveNoInteractions(); + then(itemHistoryRepository).shouldHaveNoInteractions(); + then(rouletteHistoryRepository).should(never()).saveAndFlush(any(RouletteHistory.class)); + } + + @Test + @DisplayName("오늘 누적 결제액이 3500원 미만이면 예외가 발생하고 추첨하지 않는다") + void shouldRejectSpecialRouletteWhenPaymentIsBelowMinimum() { + given(rouletteHistoryRepository + .existsByMemberIdAndRouletteTypeAndParticipationDate( + eq(MEMBER.memberId()), eq(RouletteType.SPECIAL), + any(LocalDate.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()).saveAndFlush(any(RouletteHistory.class)); + } + + @Test + @DisplayName("선택된 범위의 보상이 비어 있으면 다시 추첨한다") + void shouldRetryWhenSelectedRewardIsEmpty() { + RouletteReward reward = reward(RouletteType.FREE, "꽝", RewardType.NONE, 0, null); + given(rouletteRewardRepository + .findAvailableByRouletteTypeAndRouletteNumber(eq(RouletteType.FREE), anyInt())) + .willReturn(Optional.empty(), Optional.of(reward)); + + RouletteSpinResponse response = rouletteService.spinRoulette(MEMBER, RouletteType.FREE); + + then(rouletteRewardRepository).should(times(2)) + .findAvailableByRouletteTypeAndRouletteNumber(eq(RouletteType.FREE), anyInt()); + assertHistorySavedWith(reward, RouletteType.FREE, false); + assertThat(response.rewardName()).isEqualTo("꽝"); + } + + @Test + @DisplayName("아이템 저장이 실패하면 아이템 이력과 룰렛 이력을 저장하지 않는다") + void shouldStopWhenItemSaveFails() { + RouletteReward reward = reward( + RouletteType.FREE, "옵션권 1장", RewardType.OPTION_TICKET, 1, null); + givenReward(RouletteType.FREE, 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()).saveAndFlush(any(RouletteHistory.class)); + } + + @Test + @DisplayName("아이템 이력 저장이 실패하면 룰렛 이력을 저장하지 않는다") + void shouldStopWhenItemHistorySaveFails() { + RouletteReward reward = reward( + 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")); + + 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()).saveAndFlush(any(RouletteHistory.class)); + } + + @Test + @DisplayName("룰렛 이력 저장이 실패하면 예외를 그대로 전달한다") + void shouldPropagateRouletteHistorySaveFailure() { + RouletteReward reward = reward(RouletteType.FREE, "꽝", RewardType.NONE, 0, null); + givenReward(RouletteType.FREE, reward); + given(rouletteHistoryRepository.saveAndFlush(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.SPECIAL, "상품권", RewardType.GIFT_CARD, 1, 2); + + reward.decreaseRemainingCount(); + + assertThat(reward.getRemainingCount()).isEqualTo(1); + } + + @Test + @DisplayName("무제한 보상은 재고를 변경하지 않는다") + void shouldNotChangeUnlimitedRemainingCount() { + RouletteReward reward = reward(RouletteType.FREE, "꽝", RewardType.NONE, 0, null); + + reward.decreaseRemainingCount(); + + assertThat(reward.getRemainingCount()).isNull(); + } + + @Test + @DisplayName("소진된 재고는 음수가 되지 않는다") + void shouldNotDecreaseRemainingCountBelowZero() { + RouletteReward reward = reward(RouletteType.SPECIAL, "상품권", RewardType.GIFT_CARD, 1, 0); + + reward.decreaseRemainingCount(); + + assertThat(reward.getRemainingCount()).isZero(); + } + + private void givenReward(RouletteType rouletteType, RouletteReward reward) { + given(rouletteHistoryRepository + .existsByMemberIdAndRouletteTypeAndParticipationDate( + eq(MEMBER.memberId()), eq(rouletteType), + any(LocalDate.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)); + } + + private void assertHistorySavedWith( + RouletteReward reward, + RouletteType rouletteType, + boolean rewardGranted + ) { + ArgumentCaptor historyCaptor = ArgumentCaptor.forClass(RouletteHistory.class); + 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( + RouletteType rouletteType, + String rewardName, + RewardType rewardType, + int quantity, + Integer remainingCount + ) { + return RouletteReward.builder() + .rouletteType(rouletteType) + .rewardName(rewardName) + .rewardType(rewardType) + .quantity(quantity) + .rangeStart(1) + .rangeEnd(1) + .remainingCount(remainingCount) + .build(); + } + +} 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 { + } +} 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..58d6212 --- /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.admin.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/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..80af38d --- /dev/null +++ b/item-service/src/test/java/com/comatching/item/infra/controller/RouletteControllerTest.java @@ -0,0 +1,150 @@ +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.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; + +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.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; + +@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/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/items/roulette/SPECIAL/spins") + .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.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 shouldRejectUnsupportedRouletteType() throws Exception { + mockMvc.perform(post("/api/items/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/items/roulette는 무료·스페셜 룰렛 참여 여부와 오늘 결제액을 반환한다") + void shouldReturnRoulettePage() throws Exception { + given(rouletteService.roulettePage(any(MemberInfo.class))) + .willReturn(new RoulettePageResponse(true, false, 3500L)); + + mockMvc.perform(get("/api/items/roulette") + .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.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()); + assertThat(memberCaptor.getValue().memberId()).isEqualTo(1L); + assertThat(memberCaptor.getValue().email()).isEqualTo("member@example.com"); + assertThat(memberCaptor.getValue().role()).isEqualTo("USER"); + } + + @Test + @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/items/roulette") + .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.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()); + assertThat(memberCaptor.getValue().memberId()).isEqualTo(1L); + assertThat(memberCaptor.getValue().email()).isEqualTo("member@example.com"); + assertThat(memberCaptor.getValue().role()).isEqualTo("USER"); + } + + @Test + @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/items/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)); + } +} diff --git a/user-service/build.gradle b/user-service/build.gradle index eddaab3..a96f1c8 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' + // Cache (참여자 수 등 읽기 빈도가 높고 실시간성이 낮은 조회의 로컬 캐시) implementation 'org.springframework.boot:spring-boot-starter-cache' implementation 'com.github.ben-manes.caffeine:caffeine' 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/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/user/dto/AdminInventoryAction.java b/user-service/src/main/java/com/comatching/user/domain/admin/user/dto/AdminInventoryAction.java new file mode 100644 index 0000000..f0fbc2f --- /dev/null +++ b/user-service/src/main/java/com/comatching/user/domain/admin/user/dto/AdminInventoryAction.java @@ -0,0 +1,6 @@ +package com.comatching.user.domain.admin.user.dto; + +public enum AdminInventoryAction { + ADD, + REMOVE +} diff --git a/user-service/src/main/java/com/comatching/user/domain/admin/user/dto/AdminInventoryCounts.java b/user-service/src/main/java/com/comatching/user/domain/admin/user/dto/AdminInventoryCounts.java new file mode 100644 index 0000000..7f74d28 --- /dev/null +++ b/user-service/src/main/java/com/comatching/user/domain/admin/user/dto/AdminInventoryCounts.java @@ -0,0 +1,10 @@ +package com.comatching.user.domain.admin.user.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/user/dto/AdminInventoryUpdateRequest.java b/user-service/src/main/java/com/comatching/user/domain/admin/user/dto/AdminInventoryUpdateRequest.java new file mode 100644 index 0000000..d12d6ef --- /dev/null +++ b/user-service/src/main/java/com/comatching/user/domain/admin/user/dto/AdminInventoryUpdateRequest.java @@ -0,0 +1,28 @@ +package com.comatching.user.domain.admin.user.dto; + +import com.comatching.common.domain.enums.ItemType; +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/user/dto/AdminUserDetailResponse.java b/user-service/src/main/java/com/comatching/user/domain/admin/user/dto/AdminUserDetailResponse.java new file mode 100644 index 0000000..160d266 --- /dev/null +++ b/user-service/src/main/java/com/comatching/user/domain/admin/user/dto/AdminUserDetailResponse.java @@ -0,0 +1,28 @@ +package com.comatching.user.domain.admin.user.dto; + +import com.comatching.common.domain.enums.Gender; +import com.comatching.common.dto.member.AdminUserProfileDto; + +public record AdminUserDetailResponse( + Long id, + String email, + String realName, + String nickname, + Gender gender, + String profileImageUrl, + 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/user/dto/AdminUserSummaryResponse.java b/user-service/src/main/java/com/comatching/user/domain/admin/user/dto/AdminUserSummaryResponse.java new file mode 100644 index 0000000..d638414 --- /dev/null +++ b/user-service/src/main/java/com/comatching/user/domain/admin/user/dto/AdminUserSummaryResponse.java @@ -0,0 +1,28 @@ +package com.comatching.user.domain.admin.user.dto; + +import com.comatching.common.domain.enums.Gender; +import com.comatching.common.dto.member.AdminUserProfileDto; + +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/admin/user/service/AdminMemberService.java b/user-service/src/main/java/com/comatching/user/domain/admin/user/service/AdminMemberService.java new file mode 100644 index 0000000..ff3b585 --- /dev/null +++ b/user-service/src/main/java/com/comatching/user/domain/admin/user/service/AdminMemberService.java @@ -0,0 +1,17 @@ +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.user.dto.AdminInventoryUpdateRequest; +import com.comatching.user.domain.admin.user.dto.AdminUserDetailResponse; +import com.comatching.user.domain.admin.user.dto.AdminUserSummaryResponse; + +public interface AdminMemberService { + + PagingResponse getUsers(String keyword, Pageable pageable); + + AdminUserDetailResponse getUserDetail(Long memberId); + + void updateUserInventory(Long adminId, Long memberId, AdminInventoryUpdateRequest request); +} 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 new file mode 100644 index 0000000..e9587de --- /dev/null +++ b/user-service/src/main/java/com/comatching/user/domain/admin/user/service/AdminMemberServiceImpl.java @@ -0,0 +1,140 @@ +package com.comatching.user.domain.admin.user.service; + +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; +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.common.exception.BusinessException; +import com.comatching.common.exception.code.GeneralErrorCode; +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; +import com.comatching.user.infra.client.ItemAdminClient; + +import feign.FeignException; +import feign.codec.DecodeException; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@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 = + users.isEmpty() + ? Map.of() + : 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 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.TARGET_USER_NOT_FOUND)); + AdminUserProfileDto user = toAdminUserProfileDto(member); + + Map inventoryCountsByMemberId = itemAdminClient.getInventoryCounts(List.of(memberId)); + AdminInventoryCounts inventoryCounts = inventoryCountsByMemberId.getOrDefault(memberId, AdminInventoryCounts.empty()); + + return AdminUserDetailResponse.from(user, inventoryCounts); + } + + @Override + @Transactional + 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) { + 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/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/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/client/ItemAdminClient.java b/user-service/src/main/java/com/comatching/user/infra/client/ItemAdminClient.java new file mode 100644 index 0000000..c0d203a --- /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.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 { + + @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 new file mode 100644 index 0000000..43b6e0f --- /dev/null +++ b/user-service/src/main/java/com/comatching/user/infra/controller/AdminMemberController.java @@ -0,0 +1,71 @@ +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.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 com.comatching.common.dto.response.PagingResponse; +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; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@Tag(name = "Admin User API", description = "관리자 전용 사용자 조회 및 인벤토리 관리") +@RestController +@RequestMapping("/api/v1/admin/users") +@RequiredArgsConstructor +public class AdminMemberController { + + private final AdminMemberService adminMemberService; + + @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.ok(ApiResponse.ok(adminMemberService.getUsers(keyword, pageable))); + } + + @RequireRole(MemberRole.ROLE_ADMIN) + @Operation(summary = "사용자 상세 조회", description = "관리자가 사용자 상세 정보와 보유 아이템 인벤토리를 조회합니다.") + @GetMapping("/{memberId}") + public ResponseEntity> getUserDetail( + @CurrentMember MemberInfo memberInfo, + @PathVariable Long memberId + ) { + return ResponseEntity.ok(ApiResponse.ok(adminMemberService.getUserDetail(memberId))); + } + + @RequireRole(MemberRole.ROLE_ADMIN) + @Operation(summary = "사용자 아이템 인벤토리 수정", description = "관리자가 특정 사용자 인벤토리에 아이템을 추가하거나 차감합니다.") + @PatchMapping("/{memberId}/items") + public ResponseEntity> updateUserInventory( + @CurrentMember MemberInfo memberInfo, + @PathVariable Long memberId, + @Valid @RequestBody AdminInventoryUpdateRequest request + ) { + adminMemberService.updateUserInventory(memberInfo.memberId(), memberId, request); + return ResponseEntity.ok(ApiResponse.ok()); + } +} 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..8eb3ad9 --- /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("v1/admin/notices") + public ResponseEntity>> getAdminNotices( + @CurrentMember MemberInfo memberInfo + ) { + return ResponseEntity.ok(ApiResponse.ok(adminNoticeService.getAdminNotices())); + } + + @RequireRole(MemberRole.ROLE_ADMIN) + @Operation(summary = "공지사항 등록", description = "관리자가 제목, 내용, 시작시간, 종료시간으로 공지사항을 등록합니다.") + @PostMapping("v1/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("v1/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("v1/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/main/java/com/comatching/user/infra/controller/InternalAdminUserController.java b/user-service/src/main/java/com/comatching/user/infra/controller/InternalAdminUserController.java index 73cb058..74b7152 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,18 @@ 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; @@ -30,8 +34,14 @@ public PagingResponse getUsers( return adminMemberQueryService.getUsers(keyword, pageable); } - @GetMapping("/{memberId}") - public AdminUserProfileDto getUserDetail(@PathVariable Long memberId) { - return adminMemberQueryService.getUserDetail(memberId); +// @GetMapping("/{memberId}") +// 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/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/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 new file mode 100644 index 0000000..5410fc8 --- /dev/null +++ b/user-service/src/test/java/com/comatching/user/domain/admin/service/AdminMemberServiceTest.java @@ -0,0 +1,324 @@ +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 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; +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.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.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; +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 { + + @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); + } + + @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, + 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/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/AdminMemberControllerTest.java b/user-service/src/test/java/com/comatching/user/infra/controller/AdminMemberControllerTest.java new file mode 100644 index 0000000..01c5a56 --- /dev/null +++ b/user-service/src/test/java/com/comatching/user/infra/controller/AdminMemberControllerTest.java @@ -0,0 +1,183 @@ +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.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; + +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.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.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; + +@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"; + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @BeforeEach + void setUp() { + mockMvc = MockMvcBuilders.standaloneSetup(adminMemberController) + .setCustomArgumentResolvers(new MemberInfoArgumentResolver(), new PageableHandlerMethodArgumentResolver()) + .setControllerAdvice(new GlobalExceptionHandler(new ObjectMapper())) + .build(); + } + + @Test + @DisplayName("GET /api/v1/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/v1/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/v1/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/v1/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/v1/admin/users - 인증 헤더가 없으면 예외가 발생한다") + void getUsers_missingMemberIdHeader() throws Exception { + mockMvc.perform(get("/api/v1/admin/users")) + .andExpect(status().isInternalServerError()); + + then(adminMemberService).shouldHaveNoInteractions(); + } + + @Test + @DisplayName("GET /api/v1/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/v1/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/v1/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/v1/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/v1/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/v1/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")); + } +} 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(); + } +}