-
Notifications
You must be signed in to change notification settings - Fork 2
feat: admin 행사+티켓 등록 API - #117 #122
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Conversation
|
Warning Rate limit exceeded@sjk4618 has exceeded the limit for the number of commits or files that can be reviewed per hour. Please wait 2 minutes and 46 seconds before requesting another review. ⌛ How to resolve this issue?After the wait time has elapsed, a review can be triggered using the We recommend that you space out your commits to avoid hitting the rate limit. 🚦 How do rate limits work?CodeRabbit enforces hourly rate limits for each developer per organization. Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout. Please see our FAQ for further information. 📒 Files selected for processing (2)
📝 WalkthroughSummary by CodeRabbit
Walkthrough관리자 행사 생성(+티켓) API가 추가되어 단일 트랜잭션으로 행사, 이미지, 티켓 라운드, 여러 티켓 타입을 생성하는 플로우가 도입됨. 이벤트/티켓 도메인 일부 필드가 primitive로 변경되었고, EventType에서 FESTIVAL이 제거되고 OLYMPAN이 추가됨. 인증 필터·리졸버 에러 코드 처리와 유틸이 보강됨. Changes
Sequence Diagram(s)sequenceDiagram
autonumber
actor Admin as Admin Client
participant C as AdminEventController
participant S as AdminEventService
participant ES as AdminEventSaver
participant IS as AdminEventImageSaver
participant RS as AdminTicketRoundSaver
participant TS as AdminTicketTypeSaver
Admin->>C: POST /api/admin/events\n(AdminEventWithTicketCreateRequest)
C->>S: createEventWithTickets(req)
rect rgba(200,230,255,0.3)
note right of S: 날짜/시간 결합 및 유효 구간 구성
S->>ES: saveEvent(name, type, start/end,\nvenue, lineup, details, minAge,\nvisibleStart/end, verificationCode)
ES-->>S: Event(id)
end
rect rgba(220,255,220,0.3)
S->>IS: saveEventImages([EventImageEntity(eventId, url, seq)])
IS-->>S: void
end
rect rgba(255,240,200,0.3)
S->>RS: saveTicketRound(eventId, roundTitle,\nsalesStart, salesEnd)
RS-->>S: TicketRound(id)
S->>TS: saveAllTicketTypes([TicketTypeEntity(roundId,\nname, price, count, start, end)])
TS-->>S: void
end
S-->>C: success
C-->>Admin: 200 OK
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60–90 minutes Possibly related PRs
Pre-merge checks (3 passed, 2 warnings)❌ Failed checks (2 warnings)
✅ Passed checks (3 passed)
✨ Finishing Touches
🧪 Generate unit tests
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Actionable comments posted: 16
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (4)
src/main/java/com/permitseoul/permitserver/domain/ticket/api/service/TicketService.java (1)
112-147: 정렬 기준이 'yyyy-MM-dd' 문자열이라 동일 일자 내 불안정 정렬시간 정보를 잃어버려 같은 날짜의 주문이 불안정하게 섞일 수 있습니다.
createdAt기준으로 맵 엔트리를 정렬한 뒤 DTO로 매핑하는 편이 안전합니다.- return ticketsGroupedByOrderId.entrySet().stream() - .map(entry -> { + return ticketsGroupedByOrderId.entrySet().stream() + .sorted(Comparator.comparing((Map.Entry<String, List<Ticket>> e) -> + e.getValue().get(0).getCreatedAt()).reversed()) + .map(entry -> { final String orderId = entry.getKey(); final List<Ticket> ticketsInOrder = entry.getValue(); - final String orderDate = ticketsInOrder.get(0).getCreatedAt() - .format(DateTimeFormatter.ofPattern(ORDER_DATE_FORMAT)); + final LocalDateTime createdAt = ticketsInOrder.get(0).getCreatedAt(); + final String orderDate = createdAt.format(DateTimeFormatter.ofPattern(ORDER_DATE_FORMAT)); ... - }) - .sorted(Comparator.comparing(UserBuyTicketInfo.Order::orderDate).reversed()) - .toList(); + }) + .toList();src/main/java/com/permitseoul/permitserver/domain/ticket/core/domain/Ticket.java (1)
23-33: fromEntity에서 NPE 가능성 제거wrapper(Long)일 가능성이 있는 필드를 바로 primitive 매개변수로 넘기면 NPE가 납니다. null 방어를 추가해 주세요.
적용 예시(게터가 Long일 때만 적용; 이미 long이면 생략):
@@ - return new Ticket( - entity.getTicketId(), - entity.getUserId(), - entity.getOrderId(), - entity.getTicketTypeId(), - entity.getEventId(), - entity.getTicketCode(), - entity.getStatus(), - entity.getCreatedAt() - ); + return new Ticket( + java.util.Objects.requireNonNull(entity.getTicketId(), "ticketId is null"), + java.util.Objects.requireNonNull(entity.getUserId(), "userId is null"), + entity.getOrderId(), + java.util.Objects.requireNonNull(entity.getTicketTypeId(), "ticketTypeId is null"), + java.util.Objects.requireNonNull(entity.getEventId(), "eventId is null"), + entity.getTicketCode(), + entity.getStatus(), + entity.getCreatedAt() + );src/main/java/com/permitseoul/permitserver/domain/ticketround/core/domain/TicketRound.java (1)
20-28: fromEntity의 autounboxing NPE 방지엔티티 ID가 Long이면 null일 때 NPE. 방어 로직을 넣어 주세요(또는 엔티티 게터를 primitive로 통일).
- return new TicketRound( - ticketRoundEntity.getTicketRoundId(), + return new TicketRound( + java.util.Objects.requireNonNull(ticketRoundEntity.getTicketRoundId(), "ticketRoundId is null"), ticketRoundEntity.getEventId(), ticketRoundEntity.getTicketRoundTitle(), ticketRoundEntity.getSalesStartDate(), ticketRoundEntity.getSalesEndDate() );src/main/java/com/permitseoul/permitserver/domain/admin/event/api/service/AdminEventService.java (1)
247-253: roundToEvent.get(...) autounboxing NPE 버그매핑에 없는 roundId가 들어오면 get(...)이 null을 반환하고 long에 대입 시 NPE가 납니다.
- for (TicketType ticketType : ticketTypes) { - final long eventId = roundToEvent.get(ticketType.getTicketRoundId()); + for (TicketType ticketType : ticketTypes) { + final Long eventId = roundToEvent.get(ticketType.getTicketRoundId()); + if (eventId == null) { + // 데이터 불일치 방어: 스킵하거나 로깅/예외 처리 + continue; + } final int total = Math.max(0, ticketType.getTotalTicketCount()); final int remain = Math.max(0, ticketType.getRemainTicketCount()); final int sold = Math.max(0, total - remain); - soldByEventId.merge(eventId, sold, Integer::sum); + soldByEventId.merge(eventId, sold, Integer::sum); }
🧹 Nitpick comments (18)
src/main/java/com/permitseoul/permitserver/global/util/DateFormatterUtil.java (1)
14-14: 와일드카드 import 지양 권장 (충돌/가독성).java.util.* 대신 명시적 import로 회귀해주세요.
적용 예시:
-import java.util.*; +import java.util.Comparator; +import java.util.List; +import java.util.Locale; +import java.util.Objects; +import java.util.Optional;src/main/java/com/permitseoul/permitserver/global/filter/JwtAuthenticationFilter.java (1)
66-66: 쿠키 기반 단일 추출만 허용 — 헤더 Fallback 고려(Optional)필요 시 Authorization: Bearer 헤더를 Fallback으로 지원하면 게이트웨이/서버 간 호출 유연성이 올라갑니다. 우선순위: Cookie → Bearer 헤더.
src/main/java/com/permitseoul/permitserver/domain/admin/event/api/dto/req/AdminEventWithTicketCreateRequest.java (2)
31-34: 검증 코드 형식 제약 추가 제안허용 문자 집합을 제한하면 운영/보안 리스크를 낮출 수 있습니다.
- @NotBlank(message = "검증 코드는 필수입니다.") - @Size(max = 30, message = "검증 코드는 30자를 초과할 수 없습니다.") - String verificationCode, + @NotBlank(message = "검증 코드는 필수입니다.") + @Size(max = 30, message = "검증 코드는 30자를 초과할 수 없습니다.") + @Pattern(regexp = "^[A-Za-z0-9_-]+$", message = "검증 코드는 영문/숫자, '-', '_'만 허용합니다.") + String verificationCode,
66-68: 최소 연령 상한값 부재비정상 큰 값 방지용 상한을 두는 것이 안전합니다(예: 150).
- @Min(value = 0, message = "최소 나이는 0 이상이어야 합니다.") - int minAge, + @Min(value = 0, message = "최소 나이는 0 이상이어야 합니다.") + @Max(value = 150, message = "최소 나이는 150을 초과할 수 없습니다.") + int minAge,src/main/java/com/permitseoul/permitserver/domain/eventimage/core/domain/entity/EventImageEntity.java (1)
31-35: 정적 팩토리 방식 도입 👍불필요한 생성자 노출을 막으면서 생성 경로를 표준화했습니다. 일관성 있는 도메인 생성 패턴에 부합합니다.
추가 제안:
- 무결성:
sequence는 0 이상 제약을 두는 편이 안전합니다(DB CHECK 제약 또는 저장 컴포넌트에서 검증).- 조회 성능/정렬 안정성:
(event_id, sequence)복합 인덱스를 권장합니다.- 동치성: 컬렉션 사용 시 정렬/중복 제어가 필요하다면
equals/hashCode를 비즈니스 키(eventId,sequence) 기반으로 재정의하는 것도 고려해 주세요(엔티티 라이프사이클에 맞춰 신중히).src/main/java/com/permitseoul/permitserver/domain/ticket/api/service/TicketService.java (4)
80-106: 현재시간 사용의 테스트 비결정성 — now를 캡처해 전달
isTicketDateExpired가LocalDateTime.now()를 직접 호출해 테스트가 비결정적입니다. 메서드 시작 시now를 캡처해 하위 로직에 전달하세요.@@ - public UserBuyTicketInfo getUserBuyTicketInfo(final Long userId) { + public UserBuyTicketInfo getUserBuyTicketInfo(final Long userId) { if (userId == null) { return new UserBuyTicketInfo(List.of()); } try { + final LocalDateTime now = LocalDateTime.now(); final List<Ticket> ticketList = ticketRetriever.findAllTicketsByUserId(userId); @@ - final List<UserBuyTicketInfo.TicketInfo> ticketInfos = ticketsInOrder.stream() + final List<UserBuyTicketInfo.TicketInfo> ticketInfos = ticketsInOrder.stream() .map(ticket -> { final TicketType ticketType = ticketTypeMap.get(ticket.getTicketTypeId()); - final boolean expired = isTicketDateExpired(ticketType.getTicketEndDate()); + final boolean expired = isTicketDateExpired(ticketType.getTicketEndDate(), now); @@ - private boolean isTicketDateExpired(final LocalDateTime endDate) { - return LocalDateTime.now().isAfter(endDate); + private boolean isTicketDateExpired(final LocalDateTime endDate, final LocalDateTime now) { + return now.isAfter(endDate); }Also applies to: 123-136, 150-152
94-99: 로컬 변수 네이밍 컨벤션
TicketListGroupedByOrderIdMap는 파스칼케이스로 시작합니다. 카멜케이스 사용을 권장합니다.- final Map<String, List<Ticket>> TicketListGroupedByOrderIdMap = ticketList.stream() + final Map<String, List<Ticket>> ticketsGroupedByOrderIdMap = ticketList.stream() .collect(Collectors.groupingBy(Ticket::getOrderId)); - final Map<String, BigDecimal> refundAmountByOrderId = findRefundAmountsFromPaymentIfAllTicketsCanceled(TicketListGroupedByOrderIdMap); - final List<UserBuyTicketInfo.Order> orders = convertToOrderList(TicketListGroupedByOrderIdMap, eventMap, ticketTypeMap, refundAmountByOrderId); + final Map<String, BigDecimal> refundAmountByOrderId = findRefundAmountsFromPaymentIfAllTicketsCanceled(ticketsGroupedByOrderIdMap); + final List<UserBuyTicketInfo.Order> orders = convertToOrderList(ticketsGroupedByOrderIdMap, eventMap, ticketTypeMap, refundAmountByOrderId);
141-145: 포맷팅 사소한 수정
,뒤 공백이 빠졌습니다. 가독성을 위해 공백을 추가하세요.- return new UserBuyTicketInfo.Order(orderDate, orderId, eventName, eventVenue, formattedRefund,canCancel, ticketInfos); + return new UserBuyTicketInfo.Order(orderDate, orderId, eventName, eventVenue, formattedRefund, canCancel, ticketInfos);
81-85: UserIdHeaderResolver가 인증 실패 시 401 처리 — service 진입 시 userId는 null이 될 수 없습니다
null 체크 분기는 실행되지 않으므로getUserBuyTicketInfo내부의 첫 분기 삭제를 검토하세요.src/main/java/com/permitseoul/permitserver/domain/admin/event/api/controller/AdminEventController.java (3)
34-41: POST 생성 응답은 201 + 리소스 식별자 반환 권장현재 200 OK, 바디 없음. 생성된 eventId를 반환하고 201로 응답하면 API 사용성이 좋아집니다.
예시(서비스가 eventId를 리턴하도록 변경 전제):
- @PostMapping - public ResponseEntity<BaseResponse<?>> createEvent( + @PostMapping + public ResponseEntity<BaseResponse<?>> createEvent( @RequestBody @Valid final AdminEventWithTicketCreateRequest adminEventWithTicketCreateRequest ) { - adminEventService.createEventWithTickets(adminEventWithTicketCreateRequest); - return ApiResponseUtil.success(SuccessCode.OK); + final long eventId = adminEventService.createEventWithTickets(adminEventWithTicketCreateRequest); + return ApiResponseUtil.success(SuccessCode.CREATED, java.util.Map.of("eventId", eventId)); }
26-31: 주석 문구 오탈자"리스트 조회"가 아니라 "상세 조회"로 보입니다.
- //어드민 행사 리스트 조회 API + //어드민 행사 상세 조회 API
19-24: 목록/그룹화 응답은 OK페이징 요구사항만 없다면 현재 형태로 충분합니다. 이후 데이터 규모 커지면 페이징 고려해 주세요.
src/main/java/com/permitseoul/permitserver/domain/admin/tickettype/core/component/AdminTicketTypeSaver.java (1)
15-17: null/빈 컬렉션 방어 로직 추가 권장호출 측에서 null이 올 경우 NPE 위험. 빈 리스트면 no-op이므로 조기 반환이 깔끔합니다.
- public void saveAllTicketTypes(final List<TicketTypeEntity> ticketTypeEntityList) { - ticketTypeRepository.saveAll(ticketTypeEntityList); - } + public void saveAllTicketTypes(final List<TicketTypeEntity> ticketTypeEntityList) { + if (ticketTypeEntityList == null || ticketTypeEntityList.isEmpty()) return; + ticketTypeRepository.saveAll(ticketTypeEntityList); + }src/main/java/com/permitseoul/permitserver/domain/admin/eventimage/core/component/AdminEventImageSaver.java (1)
15-17: null/빈 컬렉션 방어 로직 추가 권장이미지 없으면 no-op 처리해도 됩니다.
- public void saveEventImages(final List<EventImageEntity> eventImages) { - eventImageRepository.saveAll(eventImages); - } + public void saveEventImages(final List<EventImageEntity> eventImages) { + if (eventImages == null || eventImages.isEmpty()) return; + eventImageRepository.saveAll(eventImages); + }src/main/java/com/permitseoul/permitserver/domain/admin/ticketround/core/AdminTicketRoundSaver.java (1)
16-23: 판매기간 유효성 사전검증 권장salesStartDate ≤ salesEndDate 보장 필요. 서비스 계층에서라도 체크해 주세요.
예시(서비스에서 호출 전 검증):
if (salesStartDateTime.isAfter(salesEndDateTime)) { throw new AdminApiException(ErrorCode.INVALID_DATE_RANGE); }src/main/java/com/permitseoul/permitserver/domain/admin/event/core/component/AdminEventSaver.java (1)
17-31: 트랜잭션 경계 안에서의 단순 저장 OK도메인-엔티티 변환 흐름 명확합니다. 후속으로 비즈니스 규칙 검증은 서비스에서 수행하면 좋겠습니다.
src/main/java/com/permitseoul/permitserver/domain/admin/event/api/service/AdminEventService.java (1)
126-135: 이미지 URL 정합성 필터링(공백/빈 값) 권장trim 후 빈 문자열은 제외하는 것이 안전합니다.
- final List<EventImageEntity> eventImageEntityList = IntStream.range(0, eventImages.size()) - .mapToObj(i -> EventImageEntity.create( - eventId, - eventImages.get(i).imageUrl().trim(), - i+1 - )).toList(); + final List<EventImageEntity> eventImageEntityList = + IntStream.range(0, eventImages.size()) + .mapToObj(i -> eventImages.get(i).imageUrl()) + .filter(Objects::nonNull) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .map((url) -> url) // 유지 + .map((url) -> url) // 유지 + .map((url) -> url) // 유지 + .map((url) -> url) // 유지 + .map((url) -> url) // 유지 + .map((url) -> url) // 유지 + .map((url) -> url) // 유지 + .toList(); + // i+1 시퀀스는 생성 시점에 다시 부여 + final List<EventImageEntity> sequenced = + IntStream.range(0, eventImageEntityList.size()) + .mapToObj(i -> EventImageEntity.create(eventId, eventImages.get(i).imageUrl().trim(), i + 1)) + .toList(); + adminEventImageSaver.saveEventImages(sequenced);참고: 위 코드는 아이디어 전달용입니다. 간단히 stream 한 번으로 필터링+시퀀싱을 처리해도 됩니다.
src/main/java/com/permitseoul/permitserver/domain/event/core/domain/entity/EventEntity.java (1)
56-79: create 메서드에 기본 입력 검증 추가 권장
- Objects.requireNonNull/blank 검사, startDate < endDate, visibleStartDate ≤ visibleEndDate, minAge ≥ 0 등 도메인 불변성 검증 추가
- (선택) JPA 매핑에서 visible_start_date 컬럼에 nullable=false 지정 고려
📜 Review details
Configuration used: CodeRabbit UI
Review profile: CHILL
Plan: Pro
📒 Files selected for processing (23)
src/main/java/com/permitseoul/permitserver/domain/admin/event/api/controller/AdminEventController.java(1 hunks)src/main/java/com/permitseoul/permitserver/domain/admin/event/api/dto/req/AdminEventWithTicketCreateRequest.java(1 hunks)src/main/java/com/permitseoul/permitserver/domain/admin/event/api/dto/res/AdminEventDetailResponse.java(2 hunks)src/main/java/com/permitseoul/permitserver/domain/admin/event/api/service/AdminEventService.java(5 hunks)src/main/java/com/permitseoul/permitserver/domain/admin/event/core/component/AdminEventSaver.java(1 hunks)src/main/java/com/permitseoul/permitserver/domain/admin/eventimage/core/component/AdminEventImageSaver.java(1 hunks)src/main/java/com/permitseoul/permitserver/domain/admin/ticketround/core/AdminTicketRoundSaver.java(1 hunks)src/main/java/com/permitseoul/permitserver/domain/admin/tickettype/core/component/AdminTicketTypeSaver.java(1 hunks)src/main/java/com/permitseoul/permitserver/domain/event/api/service/EventService.java(1 hunks)src/main/java/com/permitseoul/permitserver/domain/event/core/domain/Event.java(1 hunks)src/main/java/com/permitseoul/permitserver/domain/event/core/domain/EventType.java(1 hunks)src/main/java/com/permitseoul/permitserver/domain/event/core/domain/entity/EventEntity.java(3 hunks)src/main/java/com/permitseoul/permitserver/domain/eventimage/core/domain/entity/EventImageEntity.java(1 hunks)src/main/java/com/permitseoul/permitserver/domain/ticket/api/service/TicketService.java(1 hunks)src/main/java/com/permitseoul/permitserver/domain/ticket/core/domain/Ticket.java(1 hunks)src/main/java/com/permitseoul/permitserver/domain/ticketround/core/domain/TicketRound.java(1 hunks)src/main/java/com/permitseoul/permitserver/domain/ticketround/core/domain/entity/TicketRoundEntity.java(1 hunks)src/main/java/com/permitseoul/permitserver/domain/tickettype/core/domain/TicketType.java(1 hunks)src/main/java/com/permitseoul/permitserver/domain/tickettype/core/domain/entity/TicketTypeEntity.java(1 hunks)src/main/java/com/permitseoul/permitserver/global/filter/JwtAuthenticationFilter.java(2 hunks)src/main/java/com/permitseoul/permitserver/global/resolver/user/UserIdHeaderResolver.java(1 hunks)src/main/java/com/permitseoul/permitserver/global/response/code/ErrorCode.java(2 hunks)src/main/java/com/permitseoul/permitserver/global/util/DateFormatterUtil.java(2 hunks)
🧰 Additional context used
🧬 Code graph analysis (7)
src/main/java/com/permitseoul/permitserver/domain/admin/tickettype/core/component/AdminTicketTypeSaver.java (2)
src/main/java/com/permitseoul/permitserver/domain/admin/eventimage/core/component/AdminEventImageSaver.java (1)
Component(10-18)src/main/java/com/permitseoul/permitserver/domain/admin/ticketround/core/AdminTicketRoundSaver.java (1)
Component(11-24)
src/main/java/com/permitseoul/permitserver/domain/admin/ticketround/core/AdminTicketRoundSaver.java (2)
src/main/java/com/permitseoul/permitserver/domain/admin/eventimage/core/component/AdminEventImageSaver.java (1)
Component(10-18)src/main/java/com/permitseoul/permitserver/domain/admin/tickettype/core/component/AdminTicketTypeSaver.java (1)
Component(10-18)
src/main/java/com/permitseoul/permitserver/global/filter/JwtAuthenticationFilter.java (2)
src/main/java/com/permitseoul/permitserver/domain/auth/core/jwt/CookieExtractor.java (1)
CookieExtractor(9-24)src/main/java/com/permitseoul/permitserver/global/Constants.java (1)
Constants(3-24)
src/main/java/com/permitseoul/permitserver/domain/admin/event/api/controller/AdminEventController.java (1)
src/main/java/com/permitseoul/permitserver/global/response/ApiResponseUtil.java (1)
ApiResponseUtil(7-29)
src/main/java/com/permitseoul/permitserver/domain/admin/eventimage/core/component/AdminEventImageSaver.java (2)
src/main/java/com/permitseoul/permitserver/domain/admin/ticketround/core/AdminTicketRoundSaver.java (1)
Component(11-24)src/main/java/com/permitseoul/permitserver/domain/admin/tickettype/core/component/AdminTicketTypeSaver.java (1)
Component(10-18)
src/main/java/com/permitseoul/permitserver/domain/admin/event/api/service/AdminEventService.java (1)
src/main/java/com/permitseoul/permitserver/domain/admin/event/core/exception/AdminEventNotFoundException.java (1)
AdminEventNotFoundException(3-4)
src/main/java/com/permitseoul/permitserver/domain/event/core/domain/entity/EventEntity.java (1)
src/main/java/com/permitseoul/permitserver/domain/auth/core/domain/Token.java (1)
AllArgsConstructor(7-25)
🔇 Additional comments (13)
src/main/java/com/permitseoul/permitserver/global/util/DateFormatterUtil.java (1)
72-77: LocalDate/LocalTime 결합 유틸 추가 좋습니다.null 방어까지 포함되어 사용 측 가독성과 안정성에 도움 됩니다.
src/main/java/com/permitseoul/permitserver/domain/event/api/service/EventService.java (1)
42-43: 타입 교체 반영 OK. 다만 데이터/클라이언트 정합성 확인 필요.OLYMPAN 필터링으로 변경은 EventType 변경과 일치합니다. 프론트/관리 콘솔에서 세 번째 카테고리 기대값과 용어가 일치하는지, FESTIVAL 잔존 데이터가 없다면 목록 공란이 발생하지 않는지 확인 부탁드립니다.
src/main/java/com/permitseoul/permitserver/domain/admin/event/api/dto/res/AdminEventDetailResponse.java (1)
21-22: minAge의 primitive 전환에 따른 하위 호환/데이터 마이그레이션 확인.
- int로 고정되면서 null 표현이 불가합니다. 과거 null 데이터가 있었다면 저장/조회 시 NPE 또는 강제 0으로의 의미 변화가 생길 수 있습니다.
- 외부 응답 스키마도 “항상 존재하는 숫자”로 바뀌므로 API 소비자 영향 점검이 필요합니다.
가능한 조치:
- DB에 NOT NULL + DEFAULT(0 등) 마이그레이션.
- 변경 사항을 릴리즈 노트에 명시.
Also applies to: 39-39
src/main/java/com/permitseoul/permitserver/global/response/code/ErrorCode.java (1)
43-44: 에러코드 매핑 확인 완료
UNAUTHORIZED_USERID_RESOLVER는 UserIdHeaderResolver(34행)에서, INTERNAL_FILTER_ERROR는 JwtAuthenticationFilter(59행)에서 정상적으로 참조되고 있습니다.src/main/java/com/permitseoul/permitserver/global/filter/JwtAuthenticationFilter.java (2)
58-59: 필터 내부 예외 맵핑 보강: LGTMServletException | IOException을 명시적으로 INTERNAL_FILTER_ERROR로 매핑한 부분 적절합니다.
78-80: 헬스체크 전용 매처 분리: LGTMHEALTH_CHECK_URL 매칭을 전용 메서드로 뺀 구조는 가독성 측면에서 좋습니다.
src/main/java/com/permitseoul/permitserver/domain/tickettype/core/domain/TicketType.java (1)
13-13: fromEntity는 항상 영속화된 엔티티 호출로 ticketTypeId가 null일 수 없어TicketType.fromEntity는 repository의 findById, findAllById 등으로 조회된 경우에만 호출되며, 이때 ticketTypeId는 DB에 저장된 값이므로 null이 될 수 없습니다. 따라서 기본형 long 사용으로 인한 NPE 위험은 실제로 발생하지 않으며, 필드를 Long으로 변경할 필요가 없습니다.
Likely an incorrect or invalid review comment.
src/main/java/com/permitseoul/permitserver/domain/admin/event/api/dto/req/AdminEventWithTicketCreateRequest.java (2)
63-65: 스크립트 실행 후 결과를 토대로 저장·조회 로직을 함께 검토해보겠습니다.
35-37: DTO 요청 값 검증에 DB 컬럼 길이 일치시키기
엔티티 또는 마이그레이션에서name/venue/ticket_round_name컬럼의length값을 확인하고, DTO 필드에 해당 값과 동일한@Size(max = …)어노테이션을 추가해 주세요.src/main/java/com/permitseoul/permitserver/domain/ticket/core/domain/Ticket.java (1)
14-14: 엔티티 ID 필드 타입 점검:TicketEntity의ticketId,userId등 ID 필드 선언부(private Long ...또는private long ...)를 확인하고, 도메인 모델(long)과 일치하도록 변경하거나 null-unboxing 방어 로직(예:Objects.requireNonNull)을 추가하세요.src/main/java/com/permitseoul/permitserver/domain/admin/event/api/service/AdminEventService.java (3)
75-77: 이미지 시퀀스 정렬 👍표시 순서 안정화에 유용합니다.
285-287: isEmpty 시그니처 단순화 👍List<?>로 축소하여 용도에 맞게 간결해졌습니다.
163-181: req.lineup() 호출은 DTO record 컴포넌트명과 일치하므로 변경 불필요Likely an incorrect or invalid review comment.
| @NotNull(message = "행사 노출 시작일은 필수입니다.") | ||
| @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd") | ||
| LocalDate eventExposureStartDate, | ||
|
|
||
| @NotNull(message = "행사 노출 시작 시간은 필수입니다.") | ||
| @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "HH:mm") | ||
| LocalTime eventExposureStartTime, | ||
|
|
||
| @NotNull(message = "행사 노출 종료일은 필수입니다.") | ||
| @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd") | ||
| LocalDate eventExposureEndDate, | ||
|
|
||
| @NotNull(message = "행사 노출 종료 시간은 필수입니다.") | ||
| @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "HH:mm") | ||
| LocalTime eventExposureEndTime, | ||
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
💡 Verification agent
❓ Verification inconclusive
교차 필드(기간) 검증 없음 — 데이터 무결성 위험
노출/행사/라운드/티켓 기간 간 선후관계가 보장되지 않습니다. 운영 데이터 오류로 직결됩니다.
권장: 클래스 레벨 커스텀 Validator를 추가해 다음을 보장하세요.
- 노출 시작 ≤ 노출 종료
- 행사 시작 ≤ 행사 종료
- 라운드 판매 시작 ≤ 라운드 판매 종료
- 각 티켓(시작/종료)이 해당 라운드(시작/종료) 범위 안
예시(새 파일 추가):
// annotation
@Target(TYPE)
@Retention(RUNTIME)
@Constraint(validatedBy = AdminEventWithTicketCreateRequestValidator.class)
public @interface ValidAdminEventWithTicketCreate { String message() default "..."; Class<?>[] groups() default {}; Class<? extends Payload>[] payload() default {}; }
// validator
public class AdminEventWithTicketCreateRequestValidator implements ConstraintValidator<ValidAdminEventWithTicketCreate, AdminEventWithTicketCreateRequest> {
public boolean isValid(AdminEventWithTicketCreateRequest r, ConstraintValidatorContext c) {
// null-safe 조합
var expoStart = LocalDateTime.of(r.eventExposureStartDate(), r.eventExposureStartTime());
var expoEnd = LocalDateTime.of(r.eventExposureEndDate(), r.eventExposureEndTime());
if (expoStart.isAfter(expoEnd)) return false;
var eventStart = LocalDateTime.of(r.startDate(), r.startTime());
var eventEnd = LocalDateTime.of(r.endDate(), r.endTime());
if (eventStart.isAfter(eventEnd)) return false;
var roundStart = LocalDateTime.of(r.roundSalesStartDate(), r.roundSalesStartTime());
var roundEnd = LocalDateTime.of(r.roundSalesEndDate(), r.roundSalesEndTime());
if (roundStart.isAfter(roundEnd)) return false;
for (var t : r.ticketTypes()) {
var ts = LocalDateTime.of(t.ticketStartDate(), t.ticketStartTime());
var te = LocalDateTime.of(t.ticketEndDate(), t.ticketEndTime());
if (ts.isAfter(te)) return false;
if (ts.isBefore(roundStart) || te.isAfter(roundEnd)) return false;
}
return true;
}
}DTO에 애노테이션 부착:
-public record AdminEventWithTicketCreateRequest(
+@ValidAdminEventWithTicketCreate
+public record AdminEventWithTicketCreateRequest(검증 존재 여부 확인 스크립트:
Also applies to: 41-56, 72-87, 102-117
🏁 Script executed:
#!/bin/bash
rg -nP "ValidAdminEventWithTicketCreate|Validator|validate\\(" -C2Length of output: 0
추가 검증을 위해 ConstraintValidator 구현체 및 애노테이션 존재 여부를 검색합니다.
🏁 Script executed:
#!/bin/bash
# ConstraintValidator 구현체 검색
rg -nP "implements\s+ConstraintValidator" .
# @Constraint 애노테이션 검색
rg -n "@Constraint" .
# 이름에 Validator가 포함된 클래스 검색
rg -nP "class\s+\w+Validator" .Length of output: 0
클래스 레벨 교차 필드 검증 추가 필요
현재 AdminEventWithTicketCreateRequest 및 연관 DTO에 노출·행사·라운드·티켓 기간 간 선후관계를 보장하는 검증 로직이 없어 데이터 무결성에 위험이 있습니다. 다음을 구현하세요:
@ValidAdminEventWithTicketCreate커스텀 애노테이션AdminEventWithTicketCreateRequestValidator implements ConstraintValidator<ValidAdminEventWithTicketCreate, AdminEventWithTicketCreateRequest>- 노출 시작 ≤ 종료
- 행사 시작 ≤ 종료
- 라운드 판매 시작 ≤ 종료
- 각 티켓 기간이 해당 라운드 기간 내에 있도록 검증
DTO 선언부 상단에 애노테이션을 추가합니다:
-public record AdminEventWithTicketCreateRequest(
+@ValidAdminEventWithTicketCreate
+public record AdminEventWithTicketCreateRequest(🤖 Prompt for AI Agents
In
src/main/java/com/permitseoul/permitserver/domain/admin/event/api/dto/req/AdminEventWithTicketCreateRequest.java
around lines 15-30, add a class-level custom validation to enforce cross-field
chronological constraints: create a new @ValidAdminEventWithTicketCreate
annotation and implement AdminEventWithTicketCreateRequestValidator implements
ConstraintValidator<ValidAdminEventWithTicketCreate,
AdminEventWithTicketCreateRequest>; in the validator check that exposureStart <=
exposureEnd, eventStart <= eventEnd, each round.sellStart <= round.sellEnd, and
every ticket’s sellStart/sellEnd falls within its parent round’s
sellStart/sellEnd (fail validation with a useful message when any check fails);
finally annotate the DTO class declaration with @ValidAdminEventWithTicketCreate
so the validator runs during request validation.
...rmitseoul/permitserver/domain/admin/event/api/dto/req/AdminEventWithTicketCreateRequest.java
Show resolved
Hide resolved
| @NotNull(message = "가격은 필수입니다.") | ||
| BigDecimal price, | ||
|
|
||
| @Min(value = 1, message = "티켓 개수는 1 이상이어야 합니다.") | ||
| int ticketCount, | ||
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion
가격 음수 및 스케일 제약 부재 — 최소 검증 추가 권장
가격(BigDecimal)에 음수 입력이 허용되고 소수 자릿수 제약이 없습니다. 최소한 0 이상 제약은 필수로 보입니다.
적용 예:
- @NotNull(message = "가격은 필수입니다.")
- BigDecimal price,
+ @NotNull(message = "가격은 필수입니다.")
+ @DecimalMin(value = "0", inclusive = true, message = "가격은 0 이상이어야 합니다.")
+ BigDecimal price,(정책상 원 단위만 허용한다면 @Digits(integer = 10, fraction = 0)로 더 엄격히 제한하는 것을 고려해 주세요.)
📝 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.
| @NotNull(message = "가격은 필수입니다.") | |
| BigDecimal price, | |
| @Min(value = 1, message = "티켓 개수는 1 이상이어야 합니다.") | |
| int ticketCount, | |
| @NotNull(message = "가격은 필수입니다.") | |
| @DecimalMin(value = "0", inclusive = true, message = "가격은 0 이상이어야 합니다.") | |
| BigDecimal price, | |
| @Min(value = 1, message = "티켓 개수는 1 이상이어야 합니다.") | |
| int ticketCount, |
🤖 Prompt for AI Agents
In
src/main/java/com/permitseoul/permitserver/domain/admin/event/api/dto/req/AdminEventWithTicketCreateRequest.java
around lines 96 to 101, the BigDecimal price field currently allows negative
values and unlimited scale; add validation to enforce non-negative values and
optional scale limits by annotating the price with @DecimalMin(value = "0",
inclusive = true) to prevent negatives and, if you want to restrict fractional
digits (e.g., 원 단위 only), add @Digits(integer = 10, fraction = 0); ensure the
necessary javax.validation imports are present and keep @NotNull as well.
| public record AdminEventImageInfo( | ||
| String imageUrl | ||
| ) { } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion
이미지 URL 제약 없음
imageUrl에 공백/무효 URL이 들어올 수 있습니다. 최소한 공백 금지, 길이 제한, 프로토콜 체크가 필요합니다.
- public record AdminEventImageInfo(
- String imageUrl
- ) { }
+ public record AdminEventImageInfo(
+ @NotBlank(message = "이미지 URL은 필수입니다.")
+ @Size(max = 2048, message = "이미지 URL은 2048자를 초과할 수 없습니다.")
+ @Pattern(regexp = "https?://.+", message = "이미지 URL은 http 또는 https로 시작해야 합니다.")
+ String imageUrl
+ ) { }📝 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.
| public record AdminEventImageInfo( | |
| String imageUrl | |
| ) { } | |
| public record AdminEventImageInfo( | |
| @NotBlank(message = "이미지 URL은 필수입니다.") | |
| @Size(max = 2048, message = "이미지 URL은 2048자를 초과할 수 없습니다.") | |
| @Pattern(regexp = "https?://.+", message = "이미지 URL은 http 또는 https로 시작해야 합니다.") | |
| String imageUrl | |
| ) { } |
🤖 Prompt for AI Agents
In
src/main/java/com/permitseoul/permitserver/domain/admin/event/api/dto/req/AdminEventWithTicketCreateRequest.java
around lines 119 to 121, the AdminEventImageInfo.imageUrl currently accepts
blank or invalid URLs; add validation to reject blank values, enforce a
reasonable max length (e.g. 2048), and ensure the URL uses http or https.
Implement this by applying validation annotations (e.g. @NotBlank,
@Size(max=2048), and either @Pattern restricting ^https?://… or a custom
@URL/@ValidUrl validator) to the imageUrl parameter/field or by validating in
the record constructor using java.net.URL parsing and throwing a
ConstraintViolationException/IllegalArgumentException on failure; ensure
validation messages are clear and tests updated accordingly.
| @Transactional | ||
| public void createEventWithTickets(final AdminEventWithTicketCreateRequest req) { | ||
| final LocalDateTime eventStartDateTime = combineDateTime(req.startDate(), req.startTime()); | ||
| final LocalDateTime eventEndDateTime = combineDateTime(req.endDate(), req.endTime()); | ||
|
|
||
| final LocalDateTime eventExposureStartDateTime = combineDateTime(req.eventExposureStartDate(), req.eventExposureStartTime()); | ||
| final LocalDateTime eventExposureEndDateTime = combineDateTime(req.eventExposureEndDate(), req.eventExposureEndTime()); | ||
|
|
||
| final LocalDateTime ticketRoundSalesStartDateTime = combineDateTime(req.roundSalesStartDate(), req.roundSalesStartTime()); | ||
| final LocalDateTime ticketRoundSalesEndDateTime = combineDateTime(req.roundSalesEndDate(), req.roundSalesEndTime()); | ||
|
|
||
| final Event savedEvent = saveEvent(req, eventStartDateTime, eventEndDateTime, eventExposureStartDateTime, eventExposureEndDateTime); | ||
| saveEventImages(savedEvent.getEventId(), req.images()); | ||
|
|
||
| final TicketRound savedTicketRound = saveTicketRound( | ||
| savedEvent.getEventId(), | ||
| req.ticketRoundName(), | ||
| ticketRoundSalesStartDateTime, | ||
| ticketRoundSalesEndDateTime | ||
| ); | ||
| saveTicketTypes(req.ticketTypes(), savedTicketRound.getTicketRoundId()); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion
생성 플로우의 날짜·시간 일관성 검증 추가
역전(시작>종료), 노출/판매기간 유효성 등 최소 검증을 권장합니다. 잘못된 데이터가 그대로 저장될 수 있습니다.
예시(간단 검증):
@@
- public void createEventWithTickets(final AdminEventWithTicketCreateRequest req) {
+ public void createEventWithTickets(final AdminEventWithTicketCreateRequest req) {
final LocalDateTime eventStartDateTime = combineDateTime(req.startDate(), req.startTime());
final LocalDateTime eventEndDateTime = combineDateTime(req.endDate(), req.endTime());
@@
final LocalDateTime ticketRoundSalesStartDateTime = combineDateTime(req.roundSalesStartDate(), req.roundSalesStartTime());
final LocalDateTime ticketRoundSalesEndDateTime = combineDateTime(req.roundSalesEndDate(), req.roundSalesEndTime());
+
+ if (eventStartDateTime.isAfter(eventEndDateTime)) {
+ throw new AdminApiException(ErrorCode.INVALID_DATE_RANGE);
+ }
+ if (eventExposureStartDateTime.isAfter(eventExposureEndDateTime)) {
+ throw new AdminApiException(ErrorCode.INVALID_DATE_RANGE);
+ }
+ if (ticketRoundSalesStartDateTime.isAfter(ticketRoundSalesEndDateTime)) {
+ throw new AdminApiException(ErrorCode.INVALID_DATE_RANGE);
+ }📝 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.
| @Transactional | |
| public void createEventWithTickets(final AdminEventWithTicketCreateRequest req) { | |
| final LocalDateTime eventStartDateTime = combineDateTime(req.startDate(), req.startTime()); | |
| final LocalDateTime eventEndDateTime = combineDateTime(req.endDate(), req.endTime()); | |
| final LocalDateTime eventExposureStartDateTime = combineDateTime(req.eventExposureStartDate(), req.eventExposureStartTime()); | |
| final LocalDateTime eventExposureEndDateTime = combineDateTime(req.eventExposureEndDate(), req.eventExposureEndTime()); | |
| final LocalDateTime ticketRoundSalesStartDateTime = combineDateTime(req.roundSalesStartDate(), req.roundSalesStartTime()); | |
| final LocalDateTime ticketRoundSalesEndDateTime = combineDateTime(req.roundSalesEndDate(), req.roundSalesEndTime()); | |
| final Event savedEvent = saveEvent(req, eventStartDateTime, eventEndDateTime, eventExposureStartDateTime, eventExposureEndDateTime); | |
| saveEventImages(savedEvent.getEventId(), req.images()); | |
| final TicketRound savedTicketRound = saveTicketRound( | |
| savedEvent.getEventId(), | |
| req.ticketRoundName(), | |
| ticketRoundSalesStartDateTime, | |
| ticketRoundSalesEndDateTime | |
| ); | |
| saveTicketTypes(req.ticketTypes(), savedTicketRound.getTicketRoundId()); | |
| } | |
| @Transactional | |
| public void createEventWithTickets(final AdminEventWithTicketCreateRequest req) { | |
| final LocalDateTime eventStartDateTime = combineDateTime(req.startDate(), req.startTime()); | |
| final LocalDateTime eventEndDateTime = combineDateTime(req.endDate(), req.endTime()); | |
| final LocalDateTime eventExposureStartDateTime = combineDateTime(req.eventExposureStartDate(), req.eventExposureStartTime()); | |
| final LocalDateTime eventExposureEndDateTime = combineDateTime(req.eventExposureEndDate(), req.eventExposureEndTime()); | |
| final LocalDateTime ticketRoundSalesStartDateTime = combineDateTime(req.roundSalesStartDate(), req.roundSalesStartTime()); | |
| final LocalDateTime ticketRoundSalesEndDateTime = combineDateTime(req.roundSalesEndDate(), req.roundSalesEndTime()); | |
| if (eventStartDateTime.isAfter(eventEndDateTime)) { | |
| throw new AdminApiException(ErrorCode.INVALID_DATE_RANGE); | |
| } | |
| if (eventExposureStartDateTime.isAfter(eventExposureEndDateTime)) { | |
| throw new AdminApiException(ErrorCode.INVALID_DATE_RANGE); | |
| } | |
| if (ticketRoundSalesStartDateTime.isAfter(ticketRoundSalesEndDateTime)) { | |
| throw new AdminApiException(ErrorCode.INVALID_DATE_RANGE); | |
| } | |
| final Event savedEvent = saveEvent( | |
| req, | |
| eventStartDateTime, | |
| eventEndDateTime, | |
| eventExposureStartDateTime, | |
| eventExposureEndDateTime | |
| ); | |
| saveEventImages(savedEvent.getEventId(), req.images()); | |
| final TicketRound savedTicketRound = saveTicketRound( | |
| savedEvent.getEventId(), | |
| req.ticketRoundName(), | |
| ticketRoundSalesStartDateTime, | |
| ticketRoundSalesEndDateTime | |
| ); | |
| saveTicketTypes(req.ticketTypes(), savedTicketRound.getTicketRoundId()); | |
| } |
🤖 Prompt for AI Agents
In
src/main/java/com/permitseoul/permitserver/domain/admin/event/api/service/AdminEventService.java
around lines 103 to 124, add explicit date/time consistency checks before
persisting: validate eventStartDateTime is before eventEndDateTime; validate
eventExposureStartDateTime is before eventExposureEndDateTime; validate
ticketRoundSalesStartDateTime is before ticketRoundSalesEndDateTime;
additionally ensure exposure window falls within the event window and the ticket
sales window falls within the exposure (or event) window as business requires;
if any check fails, throw a clear runtime exception (e.g.,
IllegalArgumentException or a domain-specific exception) with a descriptive
message and avoid calling saveEvent/saveTicketRound/saveTicketTypes when
invalid.
| public static TicketRoundEntity create(final long eventId, | ||
| final String ticketRoundTitle, | ||
| final LocalDateTime salesStartDate, | ||
| final LocalDateTime salesEndDate) { | ||
| return new TicketRoundEntity(eventId, ticketRoundTitle, salesStartDate, salesEndDate); | ||
| } | ||
|
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion
팩토리에서 도메인 불변식 검증 추가 필요(널/기간 역전).
현재 create(...)는 입력 검증 없이 엔티티를 생성합니다. 잘못된 상태가 영속되기 전 선제 차단을 권장합니다.
제안 패치:
public static TicketRoundEntity create(final long eventId,
final String ticketRoundTitle,
final LocalDateTime salesStartDate,
final LocalDateTime salesEndDate) {
- return new TicketRoundEntity(eventId, ticketRoundTitle, salesStartDate, salesEndDate);
+ Objects.requireNonNull(ticketRoundTitle, "ticketRoundTitle is null");
+ if (ticketRoundTitle.isBlank()) throw new IllegalArgumentException("ticketRoundTitle is blank");
+ Objects.requireNonNull(salesStartDate, "salesStartDate is null");
+ Objects.requireNonNull(salesEndDate, "salesEndDate is null");
+ if (salesStartDate.isAfter(salesEndDate)) {
+ throw new IllegalArgumentException("salesStartDate must be <= salesEndDate");
+ }
+ return new TicketRoundEntity(eventId, ticketRoundTitle, salesStartDate, salesEndDate);
}운영 측면 권고:
- DB에 CHECK 제약(sales_start_date <= sales_end_date) 추가를 검토하세요.
📝 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.
| public static TicketRoundEntity create(final long eventId, | |
| final String ticketRoundTitle, | |
| final LocalDateTime salesStartDate, | |
| final LocalDateTime salesEndDate) { | |
| return new TicketRoundEntity(eventId, ticketRoundTitle, salesStartDate, salesEndDate); | |
| } | |
| public static TicketRoundEntity create(final long eventId, | |
| final String ticketRoundTitle, | |
| final LocalDateTime salesStartDate, | |
| final LocalDateTime salesEndDate) { | |
| Objects.requireNonNull(ticketRoundTitle, "ticketRoundTitle is null"); | |
| if (ticketRoundTitle.isBlank()) { | |
| throw new IllegalArgumentException("ticketRoundTitle is blank"); | |
| } | |
| Objects.requireNonNull(salesStartDate, "salesStartDate is null"); | |
| Objects.requireNonNull(salesEndDate, "salesEndDate is null"); | |
| if (salesStartDate.isAfter(salesEndDate)) { | |
| throw new IllegalArgumentException("salesStartDate must be <= salesEndDate"); | |
| } | |
| return new TicketRoundEntity(eventId, ticketRoundTitle, salesStartDate, salesEndDate); | |
| } |
| this.ticketPrice = ticketPrice; | ||
| this.totalTicketCount = totalTicketCount; | ||
| this.remainTicketCount = remainTicketCount; | ||
| this.remainTicketCount = totalTicketCount; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion
remainTicketCount = totalTicketCount 초기화는 적절하나 상한 보호가 없습니다
환불 등으로 increaseTicketCount 호출 시 totalTicketCount를 초과할 수 있습니다. 데이터 정합성을 위해 상한 체크를 권장합니다. (선호: 초과 시 예외)
예시(메서드 밖 제안 코드):
public void increaseTicketCount(final int buyTicketCount) {
checkBuyTicketCountZero(buyTicketCount);
if (this.remainTicketCount + buyTicketCount > this.totalTicketCount) {
throw new IllegalArgumentException("remainTicketCount cannot exceed totalTicketCount");
}
this.remainTicketCount += buyTicketCount;
}🤖 Prompt for AI Agents
In
src/main/java/com/permitseoul/permitserver/domain/tickettype/core/domain/entity/TicketTypeEntity.java
around line 52, remainTicketCount is initialized to totalTicketCount but there
is no upper-bound protection when tickets are increased later; update the
increaseTicketCount method to validate that remainTicketCount + buyTicketCount
does not exceed totalTicketCount, call the existing zero-check, and throw an
IllegalArgumentException (or domain-specific exception) if the sum would exceed
totalTicketCount to preserve data consistency.
| public static TicketTypeEntity create(final long ticketRoundId, | ||
| final String ticketTypeName, | ||
| final BigDecimal ticketPrice, | ||
| final int totalTicketCount, | ||
| final LocalDateTime ticketStartDate, | ||
| final LocalDateTime ticketEndDate) { | ||
| return new TicketTypeEntity(ticketRoundId, ticketTypeName, ticketPrice, totalTicketCount, ticketStartDate, ticketEndDate); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion
정적 팩터리 입력값 검증 추가 제안
생성 시점에서 기본적인 입력 검증을 수행해 도메인 불변식을 보장하세요(수량, 가격, 판매기간).
public static TicketTypeEntity create(final long ticketRoundId,
final String ticketTypeName,
final BigDecimal ticketPrice,
final int totalTicketCount,
final LocalDateTime ticketStartDate,
final LocalDateTime ticketEndDate) {
- return new TicketTypeEntity(ticketRoundId, ticketTypeName, ticketPrice, totalTicketCount, ticketStartDate, ticketEndDate);
+ if (totalTicketCount <= 0) {
+ throw new IllegalArgumentException("totalTicketCount must be > 0");
+ }
+ if (ticketPrice == null || ticketPrice.signum() < 0) {
+ throw new IllegalArgumentException("ticketPrice must be >= 0");
+ }
+ if (ticketStartDate == null || ticketEndDate == null || ticketStartDate.isAfter(ticketEndDate)) {
+ throw new IllegalArgumentException("ticketStartDate must be <= ticketEndDate");
+ }
+ return new TicketTypeEntity(ticketRoundId, ticketTypeName, ticketPrice, totalTicketCount, ticketStartDate, ticketEndDate);
}또한 ticketTypeName의 null/blank 검증도 고려해 주세요.
📝 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.
| public static TicketTypeEntity create(final long ticketRoundId, | |
| final String ticketTypeName, | |
| final BigDecimal ticketPrice, | |
| final int totalTicketCount, | |
| final LocalDateTime ticketStartDate, | |
| final LocalDateTime ticketEndDate) { | |
| return new TicketTypeEntity(ticketRoundId, ticketTypeName, ticketPrice, totalTicketCount, ticketStartDate, ticketEndDate); | |
| } | |
| public static TicketTypeEntity create(final long ticketRoundId, | |
| final String ticketTypeName, | |
| final BigDecimal ticketPrice, | |
| final int totalTicketCount, | |
| final LocalDateTime ticketStartDate, | |
| final LocalDateTime ticketEndDate) { | |
| if (totalTicketCount <= 0) { | |
| throw new IllegalArgumentException("totalTicketCount must be > 0"); | |
| } | |
| if (ticketPrice == null || ticketPrice.signum() < 0) { | |
| throw new IllegalArgumentException("ticketPrice must be >= 0"); | |
| } | |
| if (ticketStartDate == null || ticketEndDate == null || ticketStartDate.isAfter(ticketEndDate)) { | |
| throw new IllegalArgumentException("ticketStartDate must be <= ticketEndDate"); | |
| } | |
| return new TicketTypeEntity(ticketRoundId, ticketTypeName, ticketPrice, totalTicketCount, ticketStartDate, ticketEndDate); | |
| } |
🤖 Prompt for AI Agents
In
src/main/java/com/permitseoul/permitserver/domain/tickettype/core/domain/entity/TicketTypeEntity.java
around lines 57 to 64, add input validation inside the static create method:
validate ticketTypeName is not null or blank, ticketPrice is non-null and
greater than zero, totalTicketCount is positive, ticketStartDate and
ticketEndDate are non-null and ticketStartDate is before ticketEndDate (or at
least not after), and optionally ticketRoundId is positive; if any check fails
throw an IllegalArgumentException with a clear message so the factory enforces
domain invariants before returning the new TicketTypeEntity.
| if(isHealthCheckUri(uri)) { | ||
| filterChain.doFilter(request, response); | ||
| return; | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion
화이트리스트 처리 회귀 + NPE 위험 (UsernamePasswordAuthenticationToken에 null 권한 전달)
- 화이트리스트 URL에서도 먼저 setAuthentication를 시도해 AuthCookieException을 유발시키는 흐름으로 회귀되었습니다. 화이트리스트는 인증 시도 자체를 우회하는 것이 안전/효율적입니다.
- UsernamePasswordAuthenticationToken(null, null, null)은 내부에서 권한 컬렉션 null을 허용하지 않아 IllegalArgumentException이 발생합니다. 또한 익명 인증은 AnonymousAuthenticationToken 또는 clearContext가 적합합니다.
권장 수정:
- 화이트리스트를 헬스체크와 동일하게 try 블록 초기에 단락 평가(bypass)하고, 현재 catch 블록의 익명 토큰 생성은 제거/정리하세요.
try {
+ if (isWhiteListUrl(uri)) {
+ SecurityContextHolder.clearContext();
+ filterChain.doFilter(request, response);
+ return;
+ }
if(isHealthCheckUri(uri)) {
filterChain.doFilter(request, response);
return;
}
setAuthentication(request);
filterChain.doFilter(request, response);
} catch (AuthCookieException e) {
- if(isWhiteListUrl(uri)) {
- SecurityContextHolder.getContext().setAuthentication(new UsernamePasswordAuthenticationToken(null, null, null));
- filterChain.doFilter(request, response);
- } else {
+ if (isWhiteListUrl(uri)) {
+ SecurityContextHolder.clearContext();
+ filterChain.doFilter(request, response);
+ return;
+ } else {
throw new FilterException(ErrorCode.NOT_FOUND_AT_COOKIE);
}추가로, 화이트리스트 URL에서 잘못된/만료 토큰이 오더라도 완전 우회가 의도라면 AuthExpiredJwtException/AuthWrongJwtException의 케이스도 setAuthentication 이전 단락 평가로 자연스럽게 해소됩니다.
Also applies to: 48-51
🤖 Prompt for AI Agents
In
src/main/java/com/permitseoul/permitserver/global/filter/JwtAuthenticationFilter.java
around lines 41-44 (and also apply the same fix to 48-51): the
whitelist/health-check URI must be bypassed immediately before attempting any
authentication to avoid triggering AuthCookieException and to prevent creating a
UsernamePasswordAuthenticationToken with null authorities; move the
isHealthCheckUri/isWhitelist short-circuit to the very start of the try (or
before entering auth logic) so no token validation or setAuthentication is
attempted for those URIs, and remove the catch-branch behavior that constructs
UsernamePasswordAuthenticationToken(null, null, null) — either clear the
SecurityContext or, if anonymous behavior is required, create a proper
AnonymousAuthenticationToken with non-null authorities; ensure
AuthExpiredJwtException/AuthWrongJwtException are not processed for whitelisted
URIs because of the early bypass.
| ModelAndViewContainer mavContainer, | ||
| NativeWebRequest webRequest, | ||
| WebDataBinderFactory binderFactory) { | ||
| final Authentication authentication = Optional.ofNullable(SecurityContextHolder.getContext()) | ||
| .map(SecurityContext::getAuthentication) | ||
| .filter(Authentication::isAuthenticated) | ||
| .orElseThrow(() -> new ResolverException(ErrorCode.UNAUTHORIZED_SECURITY_ENTRY)); | ||
| .orElseThrow(() -> new ResolverException(ErrorCode.UNAUTHORIZED_USERID_RESOLVER)); | ||
| return (Long) authentication.getPrincipal(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🛠️ Refactor suggestion
principal 타입 안전성 보강(ClasCastException 방지).
현재 (Long) 캐스팅 가정이 깨지면 500으로 번집니다. 타입 분기 및 문자열 숫자 케이스 대응을 권장합니다.
제안 패치:
- final Authentication authentication = Optional.ofNullable(SecurityContextHolder.getContext())
+ final Authentication authentication = Optional.ofNullable(SecurityContextHolder.getContext())
.map(SecurityContext::getAuthentication)
.filter(Authentication::isAuthenticated)
.orElseThrow(() -> new ResolverException(ErrorCode.UNAUTHORIZED_USERID_RESOLVER));
- return (Long) authentication.getPrincipal();
+ final Object principal = authentication.getPrincipal();
+ if (principal instanceof Long l) return l;
+ if (principal instanceof String s && s.matches("\\d+")) return Long.parseLong(s);
+ throw new ResolverException(ErrorCode.UNAUTHORIZED_USERID_RESOLVER);참고: 익명 토큰을 사용 중이라면 AnonymousAuthenticationToken 제외 필터링도 고려해주세요.
📝 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.
| ModelAndViewContainer mavContainer, | |
| NativeWebRequest webRequest, | |
| WebDataBinderFactory binderFactory) { | |
| final Authentication authentication = Optional.ofNullable(SecurityContextHolder.getContext()) | |
| .map(SecurityContext::getAuthentication) | |
| .filter(Authentication::isAuthenticated) | |
| .orElseThrow(() -> new ResolverException(ErrorCode.UNAUTHORIZED_SECURITY_ENTRY)); | |
| .orElseThrow(() -> new ResolverException(ErrorCode.UNAUTHORIZED_USERID_RESOLVER)); | |
| return (Long) authentication.getPrincipal(); | |
| ModelAndViewContainer mavContainer, | |
| NativeWebRequest webRequest, | |
| WebDataBinderFactory binderFactory) { | |
| final Authentication authentication = Optional.ofNullable(SecurityContextHolder.getContext()) | |
| .map(SecurityContext::getAuthentication) | |
| .filter(Authentication::isAuthenticated) | |
| .orElseThrow(() -> new ResolverException(ErrorCode.UNAUTHORIZED_USERID_RESOLVER)); | |
| final Object principal = authentication.getPrincipal(); | |
| if (principal instanceof Long l) return l; | |
| if (principal instanceof String s && s.matches("\\d+")) return Long.parseLong(s); | |
| throw new ResolverException(ErrorCode.UNAUTHORIZED_USERID_RESOLVER); |
🤖 Prompt for AI Agents
In
src/main/java/com/permitseoul/permitserver/global/resolver/user/UserIdHeaderResolver.java
around lines 28 to 35, the code casts authentication.getPrincipal() directly to
Long which can cause ClassCastException; change it to inspect the principal's
runtime type: if it's a Long return it; if it's an Integer convert to long and
return; if it's a String attempt Long.parseLong(...) and handle
NumberFormatException by throwing ResolverException with an appropriate
ErrorCode; explicitly detect and reject AnonymousAuthenticationToken (or filter
it out earlier) by throwing ResolverException; ensure all other unexpected
principal types also result in a ResolverException rather than allowing a
ClassCastException.
🔥Pull requests
⛳️ 작업한 브랜치
👷 작업한 내용
🚨 참고 사항