diff --git a/src/main/java/com/permitseoul/permitserver/domain/ticket/api/dto/res/EventTicketInfoResponse.java b/src/main/java/com/permitseoul/permitserver/domain/ticket/api/dto/res/EventTicketInfoResponse.java index 86cf5b71..9ef6045c 100644 --- a/src/main/java/com/permitseoul/permitserver/domain/ticket/api/dto/res/EventTicketInfoResponse.java +++ b/src/main/java/com/permitseoul/permitserver/domain/ticket/api/dto/res/EventTicketInfoResponse.java @@ -18,6 +18,7 @@ public record TicketType( String ticketTypeName, String ticketTypeDate, String ticketTypeTime, - String ticketTypePrice + String ticketTypePrice, + boolean isTicketSoldOut ) { } } diff --git a/src/main/java/com/permitseoul/permitserver/domain/ticket/api/service/TicketService.java b/src/main/java/com/permitseoul/permitserver/domain/ticket/api/service/TicketService.java index 0f1eb5d5..3e0f7611 100644 --- a/src/main/java/com/permitseoul/permitserver/domain/ticket/api/service/TicketService.java +++ b/src/main/java/com/permitseoul/permitserver/domain/ticket/api/service/TicketService.java @@ -3,6 +3,7 @@ import com.permitseoul.permitserver.domain.event.core.component.EventRetriever; import com.permitseoul.permitserver.domain.event.core.domain.Event; import com.permitseoul.permitserver.domain.event.core.exception.EventNotfoundException; +import com.permitseoul.permitserver.domain.payment.api.exception.PaymentBadRequestException; import com.permitseoul.permitserver.domain.payment.core.component.PaymentRetriever; import com.permitseoul.permitserver.domain.payment.core.domain.Payment; import com.permitseoul.permitserver.domain.ticket.api.dto.res.DoorValidateUserTicket; @@ -23,12 +24,19 @@ import com.permitseoul.permitserver.domain.tickettype.core.component.TicketTypeRetriever; import com.permitseoul.permitserver.domain.tickettype.core.domain.TicketType; import com.permitseoul.permitserver.domain.tickettype.core.exception.TicketTypeNotfoundException; +import com.permitseoul.permitserver.global.Constants; import com.permitseoul.permitserver.global.exception.PriceFormatException; +import com.permitseoul.permitserver.global.exception.RedisKeyNotFoundException; +import com.permitseoul.permitserver.global.redis.RedisManager; import com.permitseoul.permitserver.global.response.code.ErrorCode; import com.permitseoul.permitserver.global.util.LocalDateTimeFormatterUtil; import com.permitseoul.permitserver.global.util.PriceFormatterUtil; import com.permitseoul.permitserver.global.util.TimeFormatterUtil; +import org.springframework.dao.QueryTimeoutException; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.RedisConnectionFailureException; +import org.springframework.data.redis.RedisSystemException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -39,6 +47,7 @@ import java.util.function.Function; import java.util.stream.Collectors; +@Slf4j @RequiredArgsConstructor @Service public class TicketService { @@ -47,9 +56,10 @@ public class TicketService { private final TicketRetriever ticketRetriever; private final EventRetriever eventRetriever; private final PaymentRetriever paymentRetriever; + private final RedisManager redisManager; + private final TicketUpdater ticketUpdater; private static final String ORDER_DATE_FORMAT = "yyyy-MM-dd"; - private final TicketUpdater ticketUpdater; @Transactional(readOnly = true) public EventTicketInfoResponse getEventTicketInfo(final long eventId, final LocalDateTime now) { @@ -71,9 +81,12 @@ public EventTicketInfoResponse getEventTicketInfo(final long eventId, final Loca // 라운드별로 티켓타입 최소 하나씩은 있는지 검증 verifyEveryRoundHasTicketType(ticketTypesByTicketRoundIdMap, ticketRoundIdList); + // ticketType soldOut 여부를 Redis에서 계산 + final Map soldOutByTicketTypeId = buildSoldOutMapByTicketTypeId(ticketTypeList); final List roundDtoList = ticketRoundList.stream() .sorted(Comparator.comparing(TicketRound::getTicketRoundId)) - .map(round -> createRoundDto(round, ticketTypesByTicketRoundIdMap, now)).toList(); + .map(round -> createRoundDto(round, ticketTypesByTicketRoundIdMap, soldOutByTicketTypeId, now)) + .toList(); return new EventTicketInfoResponse(roundDtoList); } catch (TicketTypeNotfoundException e) { @@ -149,6 +162,84 @@ public DoorValidateUserTicket validateUserTicket(final String ticketCode) { } } + //Redis에 있는 티켓 개수로 isTicketSoldOut 판별 + // 1) Redis 장애(연결 실패/타임아웃 등)일 때만 DB remainTicketCount fallback + // 2) Redis 값이 null(키 없음) 이면 예외 throw (fallback 금지) + private Map buildSoldOutMapByTicketTypeId(final List ticketTypeList) { + if (ticketTypeList == null || ticketTypeList.isEmpty()) { + return Map.of(); + } + + //장애 fallback 시 사용할 DB remain 맵 + final Map dbRemainMap = ticketTypeList.stream() + .collect(Collectors.toMap( + TicketType::getTicketTypeId, + TicketType::getRemainTicketCount, + (a, b) -> a // 중복이면 앞 값 유지 + )); + + final List ticketTypeIds = ticketTypeList.stream() + .map(TicketType::getTicketTypeId) + .distinct() + .toList(); + + final List ticketTypeKeys = ticketTypeIds.stream() + .map(this::buildRedisRemainKey) + .toList(); + + final List redisTicketTypeCountValues; + try { + redisTicketTypeCountValues = redisManager.mGet(ticketTypeKeys); + } catch (RedisConnectionFailureException | RedisSystemException | QueryTimeoutException e) { + // Redis 장애일 때만 fallback + log.error("[TicketType 개수 정보 조회] Redis 장애로 DB remainTicketCount fallback 처리. ticketType={}, err={}", + ticketTypeIds, e.getClass().getSimpleName()); + + final Map fallback = new HashMap<>(); + for (Long id : ticketTypeIds) { + final long remain = dbRemainMap.getOrDefault(id, 0); + fallback.put(id, remain <= 0); + } + return fallback; + } + + if (redisTicketTypeCountValues == null || redisTicketTypeCountValues.size() != ticketTypeKeys.size()) { + log.error("[TicketInfo] Redis mGet 결과 크기 불일치. keysSize={}, valuesSize={}", + ticketTypeKeys.size(), redisTicketTypeCountValues == null ? -1 : redisTicketTypeCountValues.size()); + throw new PaymentBadRequestException(ErrorCode.INTERNAL_TICKET_TYPE_REDIS_ERROR); + } + + final Map soldOutMap = new HashMap<>(); + + for (int i = 0; i < ticketTypeIds.size(); i++) { + final long ticketTypeId = ticketTypeIds.get(i); + final String key = ticketTypeKeys.get(i); + final String raw = redisTicketTypeCountValues.get(i); + + if (raw == null) { + log.error("[TicketInfo] Redis key 없음 key={}, ticketTypeId={}", key, ticketTypeId); + throw new RedisKeyNotFoundException(); + } + + final long remain; + try { + remain = Long.parseLong(raw); + } catch (NumberFormatException e) { + log.error("[TicketInfo] Redis ticketType value parsing 에러. key={}, raw={}, ticketTypeId={}", key, raw, ticketTypeId); + throw new PaymentBadRequestException(ErrorCode.INTERNAL_TICKET_TYPE_REDIS_ERROR); + } + + // redis ticketType remain <= 0 이면 soldOut=true + soldOutMap.put(ticketTypeId, remain <= 0); + } + + return soldOutMap; + } + + private String buildRedisRemainKey(final long ticketTypeId) { + return Constants.REDIS_TICKET_TYPE_KEY_NAME + ticketTypeId + Constants.REDIS_TICKET_TYPE_REMAIN; + } + private Event findEventById(final long eventId) { return eventRetriever.findEventById(eventId); } @@ -272,11 +363,12 @@ private Map getEventMap(final List tickets) { private EventTicketInfoResponse.Round createRoundDto(final TicketRound round, final Map> ticketTypesByTicketRoundIdMap, + final Map soldOutByTicketTypeId, final LocalDateTime now ) { final boolean isAvailable = isRoundAvailable(round, now); final List ticketTypeListInMap = Objects.requireNonNull(ticketTypesByTicketRoundIdMap.get(round.getTicketRoundId())); final String roundPrice = formatRoundPrice(ticketTypeListInMap); - final List ticketTypeDtoList = getTicketTypeIfTicketRoundAvailableOrEmptyList(isAvailable, ticketTypeListInMap); + final List ticketTypeDtoList = getTicketTypeIfTicketRoundAvailableOrEmptyList(isAvailable, ticketTypeListInMap, soldOutByTicketTypeId); return new EventTicketInfoResponse.Round( round.getTicketRoundId(), @@ -305,11 +397,12 @@ private List extractTicketRoundIdList(final List ticketRoundL } private List getTicketTypeIfTicketRoundAvailableOrEmptyList(final boolean isAvailable, - final List ticketTypeListInMap) { + final List ticketTypeListInMap, + final Map soldOutByTicketTypeId) { return isAvailable ? ticketTypeListInMap.stream() .sorted(Comparator.comparing(TicketType::getTicketTypeId)) - .map(this::makeFormattedTicketTypeDto) + .map(ticketType -> makeFormattedTicketTypeDto(ticketType, soldOutByTicketTypeId)) .toList() : List.of(); } @@ -322,19 +415,23 @@ private String formatRoundPrice(final List ticketTypes) { } // 포맷팅된 티켓타입dto로 변환 - private EventTicketInfoResponse.TicketType makeFormattedTicketTypeDto(final TicketType ticketType) { + private EventTicketInfoResponse.TicketType makeFormattedTicketTypeDto(final TicketType ticketType, + final Map soldOutByTicketTypeId) { final String formattedDate = LocalDateTimeFormatterUtil.formatEventDate( ticketType.getTicketStartAt(), ticketType.getTicketEndAt()); final String formattedTime = ticketType.getTicketStartAt() .toLocalTime() .format(java.time.format.DateTimeFormatter.ofPattern("HH:mm")); + final boolean isSoldOut = soldOutByTicketTypeId.getOrDefault(ticketType.getTicketTypeId(), false); + return new EventTicketInfoResponse.TicketType( ticketType.getTicketTypeId(), ticketType.getTicketTypeName(), formattedDate, formattedTime, - PriceFormatterUtil.formatPrice(ticketType.getTicketPrice()) + PriceFormatterUtil.formatPrice(ticketType.getTicketPrice()), + isSoldOut ); } diff --git a/src/main/java/com/permitseoul/permitserver/global/redis/RedisManager.java b/src/main/java/com/permitseoul/permitserver/global/redis/RedisManager.java index fc5248db..d3483dc1 100644 --- a/src/main/java/com/permitseoul/permitserver/global/redis/RedisManager.java +++ b/src/main/java/com/permitseoul/permitserver/global/redis/RedisManager.java @@ -7,6 +7,7 @@ import org.springframework.stereotype.Service; import java.time.Duration; +import java.util.List; import java.util.Map; import java.util.Objects; @@ -76,4 +77,13 @@ public boolean mSetIfAbsent(final Map keyValues) { final Boolean ok = redisTemplate.opsForValue().multiSetIfAbsent(keyValues); return Boolean.TRUE.equals(ok); } + + //여러 키를 한번에 조회 + public List mGet(final List keys) { + if (keys == null || keys.isEmpty()) { + return List.of(); + } + final List values = redisTemplate.opsForValue().multiGet(keys); + return values != null ? values : List.of(); + } } \ No newline at end of file diff --git a/src/main/java/com/permitseoul/permitserver/global/response/code/ErrorCode.java b/src/main/java/com/permitseoul/permitserver/global/response/code/ErrorCode.java index 0d8c2c8b..d54c1ade 100644 --- a/src/main/java/com/permitseoul/permitserver/global/response/code/ErrorCode.java +++ b/src/main/java/com/permitseoul/permitserver/global/response/code/ErrorCode.java @@ -29,6 +29,7 @@ public enum ErrorCode implements ApiCode { BAD_REQUEST_CANCELED_TICKET(HttpStatus.BAD_REQUEST, 40015, "취소된 ticket 입니다."), BAD_REQUEST_MISMATCH_TICKET_TYPE_ROUND(HttpStatus.BAD_REQUEST, 40016, "ticketType의 roundId와 다른 ticketRoundId 입니다."), BAD_REQUEST_MISMATCH_LIST_SIZE(HttpStatus.BAD_REQUEST, 40017, "list의 길이가 다릅니다."), + BAD_REQUEST_REDIS_TICKET_TYPE_MISMATCH(HttpStatus.BAD_REQUEST, 40018, "redis ticket tpye mismatch 에러입니다. "),