Skip to content
Merged

Dev #97

Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
3589f46
feat: user-service 서버에 AdminMemberController 및 AdminMemberService 뼈대 생성
choi-jin-wook Aug 12, 2026
d5d9c3e
feat(user/item): 관리자 사용자 목록 조회를 user-service로 이관
choi-jin-wook Aug 14, 2026
b97da7c
feat(user): 관리자 사용자 상세 조회를 user-service로 이관
choi-jin-wook Aug 16, 2026
8de989a
feat(user/item): 관리자 인벤토리 조정을 user-service로 이관
choi-jin-wook Aug 17, 2026
af9227b
feat(user): 공지사항 관리 API를 item-service에서 이관
choi-jin-wook Aug 19, 2026
dfb7316
Merge branch 'dev' into refactor/admin-notice
popeye0618 Aug 26, 2026
22d0eba
fix(gateway/user/item): 관리자 API 이관 게이트웨이 라우팅 수정
choi-jin-wook Aug 26, 2026
7f239cc
feat(item): 룰렛 도메인 엔티티 및 타입 추가
choi-jin-wook Aug 27, 2026
e237117
feat(item): 룰렛 추첨 및 보상 지급 API 구현
choi-jin-wook Aug 27, 2026
8ab8b3c
test(item): 룰렛 보상 처리 및 API 검증 보강
choi-jin-wook Aug 27, 2026
360285e
fix(item): 룰렛 동시 요청의 참여 및 재고 정합성 보장
choi-jin-wook Aug 28, 2026
357db62
refactor(item): 룰렛 보상 및 참여 이력 처리 단순화
choi-jin-wook Aug 30, 2026
45b4dc9
feat(item): 결제 기반 스페셜 룰렛과 참여 상태 조회 추가
choi-jin-wook Sep 2, 2026
b3572cf
fix(item): 룰렛 동시 참여의 보상 중복 지급 방지
choi-jin-wook Sep 3, 2026
3cb0a2e
test(item): 스페셜 룰렛 당일 승인 결제액 조회 검증
choi-jin-wook Sep 8, 2026
48b239d
refactor(item): 룰렛 조회 및 보상 처리 구조 개선
choi-jin-wook Sep 8, 2026
bfe6bb9
• refactor(item): 룰렛 보상 구분을 RewardType으로 통합
choi-jin-wook Sep 10, 2026
22f6b79
feat(item): 상품권 당첨자 조회 및 지급 완료 API 추가
choi-jin-wook Sep 10, 2026
9ad60d0
refactor(item): 룰렛 API 경로를 item-service prefix로 통일
choi-jin-wook Sep 10, 2026
0945372
merge: 관리자 공지 리팩토링과 룰렛 관리자 기능 통합
choi-jin-wook Sep 11, 2026
83c0889
merge: main 최신 변경사항을 룰렛 통합 브랜치에 반영
choi-jin-wook Sep 11, 2026
d10b4d1
test(item): 룰렛 API 경로 변경을 테스트에 반영
choi-jin-wook Sep 11, 2026
adbbd67
merge: feat/roulette 최신 변경사항을 룰렛 통합 브랜치에 반영
choi-jin-wook Sep 11, 2026
5bd2c29
fix(item): 회원 정보가 없는 상품권 당첨자 조회 실패 방지
choi-jin-wook Sep 12, 2026
48f30ff
fix(gateway): 관리자 사용자 및 공지 라우팅 복구
choi-jin-wook Sep 12, 2026
3639db7
merge: 관리자 공지 라우팅 변경을 룰렛 통합 브랜치에 반영
choi-jin-wook Sep 12, 2026
8f8868d
fix(item): 룰렛 참여 날짜 기준 일관성 보장
choi-jin-wook Sep 12, 2026
a5dad64
Merge pull request #96 from COMAtching/integration/roulette-admin
choi-jin-wook Sep 12, 2026
1dcf5d9
Merge pull request #98 from COMAtching/integration/roulette-admin
choi-jin-wook Sep 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -96,3 +96,5 @@ _workspace
id_rsa
id_ed25519
comatching-ec2
/AGENTS.md

2 changes: 1 addition & 1 deletion chat-service/build.gradle
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
plugins {
plugins {
id 'java'
id 'org.springframework.boot'
id 'io.spring.dependency-management'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package com.comatching.common.dto.member;

public record AdminGiftCardUserProfileDto(
Long id,
String email,
String realName,
String nickname
) {
}
9 changes: 8 additions & 1 deletion gateway-service/src/main/resources/application-aws.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
22 changes: 19 additions & 3 deletions gateway-service/src/main/resources/application.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -91,7 +91,7 @@ spring:
- id: item-service
uri: http://localhost:9006
predicates:
- Path=/api/items/**, /api/v1/**
- Path=/api/items/**
filters:
- AuthorizationHeaderFilter

Expand All @@ -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

Expand Down Expand Up @@ -133,4 +149,4 @@ management:
http.server.requests: true
spring.kafka.listener: true
slo:
http.server.requests: 50ms,100ms,200ms,500ms,1s,2s
http.server.requests: 50ms,100ms,200ms,500ms,1s,2s
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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());
Expand Down Expand Up @@ -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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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,
Expand All @@ -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);
}
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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<Long, AdminInventoryCounts> getInventoryCounts(List<Long> memberIds) {
if (memberIds == null || memberIds.isEmpty()) {
return Map.of();
}

Map<Long, long[]> 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<Long, AdminInventoryCounts> result = new HashMap<>();
countsByMemberId.forEach((memberId, counts) ->
result.put(memberId, new AdminInventoryCounts(counts[0], counts[1]))
);
return result;
}
}
Original file line number Diff line number Diff line change
@@ -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<AdminGiftCardWinnerResponse> getUnpaidGiftCardWinners();

/** 관리자가 실제 상품권 지급을 끝낸 당첨 이력을 지급 완료 상태로 변경한다. */
void markGiftCardAsGranted(Long historyId);
}
Original file line number Diff line number Diff line change
@@ -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<AdminGiftCardWinnerResponse> getUnpaidGiftCardWinners() {
// rewardType이 GIFT_CARD이고 rewardGranted가 false인 이력만 최신순으로 가져온다.
List<RouletteHistory> histories = rouletteHistoryRepository
.findAllByReward_RewardTypeAndRewardGrantedFalseOrderByParticipatedAtDesc(RewardType.GIFT_CARD);

// 미지급 당첨자가 없으면 user-service를 호출하지 않고 빈 명단을 반환한다.
if (histories.isEmpty()) { return List.of(); }

// user-service에서 받은 회원 목록을 memberId로 찾을 수 있게 만들어 각 당첨 이력에 회원 정보를 붙인다.
Map<Long, AdminGiftCardUserProfileDto> 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<Long, AdminGiftCardUserProfileDto> getUsersByMemberId(List<RouletteHistory> histories) {
// 같은 회원이 여러 상품권에 당첨됐어도 user-service에는 회원 ID를 한 번만 전달한다.
List<Long> 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);
}
}

}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -15,3 +19,4 @@ public interface AdminUserItemService {

void updateUserInventory(Long adminId, Long memberId, AdminInventoryUpdateRequest request);
}
*/
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -159,3 +163,4 @@ private List<String> toSortParams(Sort sort) {
.toList();
}
}
*/
Loading
Loading