Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ public record TicketType(
String ticketTypeName,
String ticketTypeDate,
String ticketTypeTime,
String ticketTypePrice
String ticketTypePrice,
boolean isTicketSoldOut
) { }
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;

Expand All @@ -39,6 +47,7 @@
import java.util.function.Function;
import java.util.stream.Collectors;

@Slf4j
@RequiredArgsConstructor
@Service
public class TicketService {
Expand All @@ -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) {
Expand All @@ -71,9 +81,12 @@ public EventTicketInfoResponse getEventTicketInfo(final long eventId, final Loca
// 라운드별로 티켓타입 최소 하나씩은 있는지 검증
verifyEveryRoundHasTicketType(ticketTypesByTicketRoundIdMap, ticketRoundIdList);

// ticketType soldOut 여부를 Redis에서 계산
final Map<Long, Boolean> soldOutByTicketTypeId = buildSoldOutMapByTicketTypeId(ticketTypeList);
final List<EventTicketInfoResponse.Round> 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) {
Expand Down Expand Up @@ -149,6 +162,84 @@ public DoorValidateUserTicket validateUserTicket(final String ticketCode) {
}
}

//Redis에 있는 티켓 개수로 isTicketSoldOut 판별
// 1) Redis 장애(연결 실패/타임아웃 등)일 때만 DB remainTicketCount fallback
// 2) Redis 값이 null(키 없음) 이면 예외 throw (fallback 금지)
private Map<Long, Boolean> buildSoldOutMapByTicketTypeId(final List<TicketType> ticketTypeList) {
if (ticketTypeList == null || ticketTypeList.isEmpty()) {
return Map.of();
}

//장애 fallback 시 사용할 DB remain 맵
final Map<Long, Integer> dbRemainMap = ticketTypeList.stream()
.collect(Collectors.toMap(
TicketType::getTicketTypeId,
TicketType::getRemainTicketCount,
(a, b) -> a // 중복이면 앞 값 유지
));

final List<Long> ticketTypeIds = ticketTypeList.stream()
.map(TicketType::getTicketTypeId)
.distinct()
.toList();

final List<String> ticketTypeKeys = ticketTypeIds.stream()
.map(this::buildRedisRemainKey)
.toList();

final List<String> 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<Long, Boolean> 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);
}
Comment on lines +206 to +210
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Exception 타입과 ErrorCode가 일치하지 않습니다.

PaymentBadRequestException을 사용하고 있지만, INTERNAL_TICKET_TYPE_REDIS_ERROR는 500 에러 코드입니다. 또한, Payment 관련 예외를 Ticket 서비스에서 사용하는 것은 의미적으로 맞지 않습니다.

🔎 개선 방안

Ticket 도메인에 적합한 내부 서버 에러 예외 클래스를 사용하거나, 새로운 예외 타입을 정의하는 것이 좋습니다:

-            throw new PaymentBadRequestException(ErrorCode.INTERNAL_TICKET_TYPE_REDIS_ERROR);
+            throw new InternalServerErrorException(ErrorCode.INTERNAL_TICKET_TYPE_REDIS_ERROR);

또는 TicketService 전용 예외 클래스가 있다면 해당 클래스를 사용해 주세요.

Committable suggestion skipped: line range outside the PR's diff.

🤖 Prompt for AI Agents
In
src/main/java/com/permitseoul/permitserver/domain/ticket/api/service/TicketService.java
around lines 206 to 210, the code throws PaymentBadRequestException while using
ErrorCode.INTERNAL_TICKET_TYPE_REDIS_ERROR (a 500-level internal error) and
using a Payment-specific exception in the Ticket domain; replace the thrown
exception with a ticket-appropriate internal server error exception (e.g., a
TicketInternalServerException or a generic InternalServerException) that maps to
ErrorCode.INTERNAL_TICKET_TYPE_REDIS_ERROR, or create/use an existing
TicketService-specific exception class so the exception type semantically
matches the 500 error code and domain.


final Map<Long, Boolean> 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);
}
Expand Down Expand Up @@ -272,11 +363,12 @@ private Map<Long, Event> getEventMap(final List<Ticket> tickets) {

private EventTicketInfoResponse.Round createRoundDto(final TicketRound round,
final Map<Long, List<TicketType>> ticketTypesByTicketRoundIdMap,
final Map<Long, Boolean> soldOutByTicketTypeId,
final LocalDateTime now ) {
final boolean isAvailable = isRoundAvailable(round, now);
final List<TicketType> ticketTypeListInMap = Objects.requireNonNull(ticketTypesByTicketRoundIdMap.get(round.getTicketRoundId()));
final String roundPrice = formatRoundPrice(ticketTypeListInMap);
final List<EventTicketInfoResponse.TicketType> ticketTypeDtoList = getTicketTypeIfTicketRoundAvailableOrEmptyList(isAvailable, ticketTypeListInMap);
final List<EventTicketInfoResponse.TicketType> ticketTypeDtoList = getTicketTypeIfTicketRoundAvailableOrEmptyList(isAvailable, ticketTypeListInMap, soldOutByTicketTypeId);

return new EventTicketInfoResponse.Round(
round.getTicketRoundId(),
Expand Down Expand Up @@ -305,11 +397,12 @@ private List<Long> extractTicketRoundIdList(final List<TicketRound> ticketRoundL
}

private List<EventTicketInfoResponse.TicketType> getTicketTypeIfTicketRoundAvailableOrEmptyList(final boolean isAvailable,
final List<TicketType> ticketTypeListInMap) {
final List<TicketType> ticketTypeListInMap,
final Map<Long, Boolean> soldOutByTicketTypeId) {
return isAvailable
? ticketTypeListInMap.stream()
.sorted(Comparator.comparing(TicketType::getTicketTypeId))
.map(this::makeFormattedTicketTypeDto)
.map(ticketType -> makeFormattedTicketTypeDto(ticketType, soldOutByTicketTypeId))
.toList()
: List.of();
}
Expand All @@ -322,19 +415,23 @@ private String formatRoundPrice(final List<TicketType> ticketTypes) {
}

// 포맷팅된 티켓타입dto로 변환
private EventTicketInfoResponse.TicketType makeFormattedTicketTypeDto(final TicketType ticketType) {
private EventTicketInfoResponse.TicketType makeFormattedTicketTypeDto(final TicketType ticketType,
final Map<Long, Boolean> 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
);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -76,4 +77,13 @@ public boolean mSetIfAbsent(final Map<String, String> keyValues) {
final Boolean ok = redisTemplate.opsForValue().multiSetIfAbsent(keyValues);
return Boolean.TRUE.equals(ok);
}

//여러 키를 한번에 조회
public List<String> mGet(final List<String> keys) {
if (keys == null || keys.isEmpty()) {
return List.of();
}
final List<String> values = redisTemplate.opsForValue().multiGet(keys);
return values != null ? values : List.of();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 에러입니다. "),
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

에러 메시지에 오타가 있습니다.

"tpye""type"으로 수정이 필요합니다. 또한, 메시지 끝의 불필요한 공백도 제거해 주세요.

🔎 수정 제안
-    BAD_REQUEST_REDIS_TICKET_TYPE_MISMATCH(HttpStatus.BAD_REQUEST, 40018, "redis ticket tpye mismatch 에러입니다. "),
+    BAD_REQUEST_REDIS_TICKET_TYPE_MISMATCH(HttpStatus.BAD_REQUEST, 40018, "redis ticket type mismatch 에러입니다."),
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
BAD_REQUEST_REDIS_TICKET_TYPE_MISMATCH(HttpStatus.BAD_REQUEST, 40018, "redis ticket tpye mismatch 에러입니다. "),
BAD_REQUEST_REDIS_TICKET_TYPE_MISMATCH(HttpStatus.BAD_REQUEST, 40018, "redis ticket type mismatch 에러입니다."),
🤖 Prompt for AI Agents
In
src/main/java/com/permitseoul/permitserver/global/response/code/ErrorCode.java
around line 32, fix the typo and trailing space in the enum message: change
"redis ticket tpye mismatch 에러입니다. " to "redis ticket type mismatch 에러입니다." by
correcting "tpye" to "type" and removing the extra space at the end so the
string is clean.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Search for BAD_REQUEST_REDIS_TICKET_TYPE_MISMATCH usage
echo "=== Searching for BAD_REQUEST_REDIS_TICKET_TYPE_MISMATCH usage ==="
rg -n "BAD_REQUEST_REDIS_TICKET_TYPE_MISMATCH" --type java

echo ""
echo "=== Searching for INTERNAL_TICKET_TYPE_REDIS_ERROR usage ==="
rg -n "INTERNAL_TICKET_TYPE_REDIS_ERROR" --type java

echo ""
echo "=== Searching for all references to the error code ==="
rg -n "40018" --type java

Repository: PERMIT-SEOUL/permit-server

Length of output: 1970


정의된 에러 코드가 사용되지 않습니다. 제거해 주세요.

BAD_REQUEST_REDIS_TICKET_TYPE_MISMATCH는 정의되었으나 코드베이스 어디에서도 사용되지 않습니다. 실제로는 INTERNAL_TICKET_TYPE_REDIS_ERROR(50015)가 TicketService, AdminTicketService, AdminEventService에서 사용 중입니다. 불필요한 에러 코드 정의이므로 제거하거나, 실제 용도가 있다면 구현을 완료해 주세요.

또한 에러 메시지의 "tpye"은 "type"의 오타입니다.

🤖 Prompt for AI Agents
In
src/main/java/com/permitseoul/permitserver/global/response/code/ErrorCode.java
around line 32, the enum constant BAD_REQUEST_REDIS_TICKET_TYPE_MISMATCH is
unused and contains a typo ("tpye"); either delete this constant from the enum,
or if it was intended to be used, replace the current usages of
INTERNAL_TICKET_TYPE_REDIS_ERROR (50015) in TicketService, AdminTicketService,
and AdminEventService with this constant and correct its message to "redis
ticket type mismatch 에러입니다."; ensure imports/compilation are updated after
removal or replacement.




Expand Down