Skip to content
Original file line number Diff line number Diff line change
@@ -1,32 +1,42 @@
package com.permitseoul.permitserver.domain.admin.event.api.controller;

import com.permitseoul.permitserver.domain.admin.event.api.dto.req.AdminEventWithTicketCreateRequest;
import com.permitseoul.permitserver.domain.admin.event.api.service.AdminEventService;
import com.permitseoul.permitserver.global.response.ApiResponseUtil;
import com.permitseoul.permitserver.global.response.BaseResponse;
import com.permitseoul.permitserver.global.response.code.SuccessCode;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/admin/events")
@RequiredArgsConstructor
public class AdminEventController {
private final AdminEventService adminEventService;

//어드민 행사 리스트 조회 API
@GetMapping
public ResponseEntity<BaseResponse<?>> getEvents(
) {
return ApiResponseUtil.success(SuccessCode.OK, adminEventService.getEvents());
}

//어드민 행사 리스트 조회 API
@GetMapping("/{eventId}/details")
public ResponseEntity<BaseResponse<?>> getEventDetail(
@PathVariable(value = "eventId") long eventId
) {
return ApiResponseUtil.success(SuccessCode.OK, adminEventService.getEventDetail(eventId));
}

//어드민 행사+티켓 등록 API
@PostMapping
public ResponseEntity<BaseResponse<?>> createEvent(
@RequestBody @Valid final AdminEventWithTicketCreateRequest adminEventWithTicketCreateRequest
) {
adminEventService.createEventWithTickets(adminEventWithTicketCreateRequest);
return ApiResponseUtil.success(SuccessCode.OK);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
package com.permitseoul.permitserver.domain.admin.event.api.dto.req;

import com.fasterxml.jackson.annotation.JsonFormat;
import com.permitseoul.permitserver.domain.event.core.domain.EventType;
import jakarta.validation.Valid;
import jakarta.validation.constraints.*;

import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalTime;
import java.util.List;

public record AdminEventWithTicketCreateRequest(

@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,

Comment on lines +15 to +30
Copy link

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\\(" -C2

Length 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.

@NotBlank(message = "검증 코드는 필수입니다.")
@Size(max = 30, message = "검증 코드는 30자를 초과할 수 없습니다.")
String verificationCode,

@NotBlank(message = "행사명은 필수입니다.")
String name,

@NotNull(message = "행사 타입은 필수입니다.")
EventType eventType,

@NotNull(message = "행사 시작일은 필수입니다.")
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd")
LocalDate startDate,

@NotNull(message = "행사 시작 시간은 필수입니다.")
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "HH:mm")
LocalTime startTime,

@NotNull(message = "행사 종료일은 필수입니다.")
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd")
LocalDate endDate,

@NotNull(message = "행사 종료 시간은 필수입니다.")
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "HH:mm")
LocalTime endTime,

@NotBlank(message = "행사 장소는 필수입니다.")
String venue,

String lineup,
String details,

@Valid
@NotEmpty(message = "행사 이미지는 최소 1개 이상이어야 합니다.")
List<AdminEventImageInfo> images,

@Min(value = 0, message = "최소 나이는 0 이상이어야 합니다.")
int minAge,

@NotBlank(message = "티켓 차수 이름은 필수입니다.")
String ticketRoundName,

@NotNull(message = "티켓 차수 시작일은 필수입니다.")
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd")
LocalDate roundSalesStartDate,

@NotNull(message = "티켓 차수 시작 시간은 필수입니다.")
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "HH:mm")
LocalTime roundSalesStartTime,

@NotNull(message = "티켓 차수 종료일은 필수입니다.")
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd")
LocalDate roundSalesEndDate,

@NotNull(message = "티켓 차수 종료 시간은 필수입니다.")
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "HH:mm")
LocalTime roundSalesEndTime,

@NotEmpty(message = "티켓 정보는 최소 1개 이상이어야 합니다.")
@Valid
List<TicketTypeRequest> ticketTypes
) {
public record TicketTypeRequest(
@NotBlank(message = "티켓 이름은 필수입니다.")
String ticketName,

@Min(value = 1, message = "가격은 0원보다 커야합니다.")
@NotNull(message = "가격은 필수입니다.")
BigDecimal price,

@Min(value = 1, message = "티켓 개수는 1 이상이어야 합니다.")
int ticketCount,

Comment on lines +98 to +103
Copy link

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.

Suggested change
@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.

@NotNull(message = "티켓 시작일은 필수입니다.")
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd")
LocalDate ticketStartDate,

@NotNull(message = "티켓 시작 시간은 필수입니다.")
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "HH:mm")
LocalTime ticketStartTime,

@NotNull(message = "티켓 종료일은 필수입니다.")
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd")
LocalDate ticketEndDate,

@NotNull(message = "티켓 종료 시간은 필수입니다.")
@JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "HH:mm")
LocalTime ticketEndTime
) { }

public record AdminEventImageInfo(
@NotBlank(message = "이미지 url은 필수입니다.")
String imageUrl
) { }
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ public record AdminEventDetailResponse(
String lineup,
String details,
List<AdminEventImageInfo> images,
Integer minAge
int minAge
) {

public static AdminEventDetailResponse of(final long eventId,
Expand All @@ -36,7 +36,7 @@ public static AdminEventDetailResponse of(final long eventId,
final String lineup,
final String details,
final List<AdminEventImageInfo> images,
final Integer minAge) {
final int minAge) {
return new AdminEventDetailResponse(
eventId,
eventExposureStartDate,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,26 +1,35 @@
package com.permitseoul.permitserver.domain.admin.event.api.service;

import com.permitseoul.permitserver.domain.admin.base.AdminBaseException;
import com.permitseoul.permitserver.domain.admin.base.api.exception.AdminApiException;
import com.permitseoul.permitserver.domain.admin.event.api.dto.req.AdminEventWithTicketCreateRequest;
import com.permitseoul.permitserver.domain.admin.event.api.dto.res.AdminEventDetailResponse;
import com.permitseoul.permitserver.domain.admin.event.api.dto.res.AdminEventListResponse;
import com.permitseoul.permitserver.domain.admin.event.core.component.AdminEventRetriever;
import com.permitseoul.permitserver.domain.admin.event.core.component.AdminEventSaver;
import com.permitseoul.permitserver.domain.admin.event.core.exception.AdminEventNotFoundException;
import com.permitseoul.permitserver.domain.admin.eventimage.core.component.AdminEventImageRetriever;
import com.permitseoul.permitserver.domain.admin.eventimage.core.component.AdminEventImageSaver;
import com.permitseoul.permitserver.domain.admin.ticketround.core.AdminTicketRoundRetriever;
import com.permitseoul.permitserver.domain.admin.ticketround.core.AdminTicketRoundSaver;
import com.permitseoul.permitserver.domain.admin.tickettype.core.component.AdminTicketTypeRetriever;
import com.permitseoul.permitserver.domain.admin.tickettype.core.component.AdminTicketTypeSaver;
import com.permitseoul.permitserver.domain.event.core.domain.Event;
import com.permitseoul.permitserver.domain.eventimage.core.domain.EventImage;
import com.permitseoul.permitserver.domain.eventimage.core.domain.entity.EventImageEntity;
import com.permitseoul.permitserver.domain.ticketround.core.domain.TicketRound;
import com.permitseoul.permitserver.domain.tickettype.core.domain.TicketType;
import com.permitseoul.permitserver.domain.tickettype.core.domain.entity.TicketTypeEntity;
import com.permitseoul.permitserver.global.response.code.ErrorCode;
import com.permitseoul.permitserver.global.util.DateFormatterUtil;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.util.*;
import java.util.stream.IntStream;


@Service
Expand All @@ -31,6 +40,10 @@ public class AdminEventService {
private final AdminTicketRoundRetriever adminTicketRoundRetriever;
private final AdminTicketTypeRetriever adminTicketTypeRetriever;
private final AdminEventImageRetriever adminEventImageRetriever;
private final AdminEventSaver adminEventSaver;
private final AdminEventImageSaver adminEventImageSaver;
private final AdminTicketRoundSaver adminTicketRoundSaver;
private final AdminTicketTypeSaver adminTicketTypeSaver;

@Transactional(readOnly = true)
public List<AdminEventListResponse> getEvents() {
Expand Down Expand Up @@ -59,7 +72,7 @@ public AdminEventDetailResponse getEventDetail(final long eventId) {
final List<EventImage> eventImages = adminEventImageRetriever.findAllEventImagesByEventId(event.getEventId());

final List<AdminEventDetailResponse.AdminEventImageInfo> adminEventImageInfos = eventImages.stream()
.sorted(Comparator.comparingInt(EventImage::getSequence))
.sorted(Comparator.comparingInt(EventImage::getSequence))
.map(eventImage -> AdminEventDetailResponse.AdminEventImageInfo.of(eventImage.getImageUrl()))
.toList();

Expand Down Expand Up @@ -87,6 +100,90 @@ public AdminEventDetailResponse getEventDetail(final long eventId) {
}
}

@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());
}
Comment on lines +103 to +124
Copy link

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.

Suggested change
@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.

Comment on lines +115 to +124
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

images/ticketTypes null 처리 및 빈 컬렉션 정책 확정

req.images()/ticketTypes()가 null이면 NPE. 또한 티켓 타입이 없을 때 라운드 생성 여부 정책이 필요합니다.

-        saveEventImages(savedEvent.getEventId(), req.images());
+        final var images = java.util.Optional.ofNullable(req.images()).orElseGet(java.util.List::of);
+        saveEventImages(savedEvent.getEventId(), images);
@@
-        final TicketRound savedTicketRound = saveTicketRound(
-                savedEvent.getEventId(),
-                req.ticketRoundName(),
-                ticketRoundSalesStartDateTime,
-                ticketRoundSalesEndDateTime
-        );
-        saveTicketTypes(req.ticketTypes(), savedTicketRound.getTicketRoundId());
+        final var ticketTypes = java.util.Optional.ofNullable(req.ticketTypes()).orElseGet(java.util.List::of);
+        if (!ticketTypes.isEmpty()) {
+            final TicketRound savedTicketRound = saveTicketRound(
+                    savedEvent.getEventId(),
+                    req.ticketRoundName(),
+                    ticketRoundSalesStartDateTime,
+                    ticketRoundSalesEndDateTime
+            );
+            saveTicketTypes(ticketTypes, savedTicketRound.getTicketRoundId());
+        }
📝 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
saveEventImages(savedEvent.getEventId(), req.images());
final TicketRound savedTicketRound = saveTicketRound(
savedEvent.getEventId(),
req.ticketRoundName(),
ticketRoundSalesStartDateTime,
ticketRoundSalesEndDateTime
);
saveTicketTypes(req.ticketTypes(), savedTicketRound.getTicketRoundId());
}
// Handle possible null images list by treating it as an empty list
final var images = java.util.Optional.ofNullable(req.images()).orElseGet(java.util.List::of);
saveEventImages(savedEvent.getEventId(), images);
// Handle possible null ticketTypes and skip round creation when empty
final var ticketTypes = java.util.Optional.ofNullable(req.ticketTypes()).orElseGet(java.util.List::of);
if (!ticketTypes.isEmpty()) {
final TicketRound savedTicketRound = saveTicketRound(
savedEvent.getEventId(),
req.ticketRoundName(),
ticketRoundSalesStartDateTime,
ticketRoundSalesEndDateTime
);
saveTicketTypes(ticketTypes, savedTicketRound.getTicketRoundId());
}
}
🤖 Prompt for AI Agents
In
src/main/java/com/permitseoul/permitserver/domain/admin/event/api/service/AdminEventService.java
around lines 115-124, req.images() and req.ticketTypes() can be null causing
NPEs and the code lacks a policy for creating ticket rounds when there are no
ticket types; fix by defensively normalizing nulls to empty collections (e.g.,
List.of() or Collections.emptyList()) before use, always call saveEventImages
with the normalized images list (which should accept an empty list), and only
create/save a TicketRound and call saveTicketTypes when the normalized
ticketTypes list is non-empty (if ticketTypes is empty, skip round creation and
saveTicketTypes). Ensure downstream methods accept empty lists and update method
contracts or null checks accordingly.


private void saveEventImages(final long eventId,
List<AdminEventWithTicketCreateRequest.AdminEventImageInfo> eventImages) {
final List<EventImageEntity> eventImageEntityList = IntStream.range(0, eventImages.size())
.mapToObj(i -> EventImageEntity.create(
eventId,
eventImages.get(i).imageUrl().trim(),
i+1
)).toList();
adminEventImageSaver.saveEventImages(eventImageEntityList);
}

private void saveTicketTypes(final List<AdminEventWithTicketCreateRequest.TicketTypeRequest> ticketTypes,
final long ticketRoundId) {
final List<TicketTypeEntity> ticketTypeEntityList = ticketTypes.stream()
.map(ticketType -> TicketTypeEntity.create(
ticketRoundId,
ticketType.ticketName(),
ticketType.price(),
ticketType.ticketCount(),
LocalDateTime.of(ticketType.ticketStartDate(), ticketType.ticketStartTime()),
LocalDateTime.of(ticketType.ticketEndDate(), ticketType.ticketEndTime())
)).toList();
adminTicketTypeSaver.saveAllTicketTypes(ticketTypeEntityList);
}
Comment on lines +137 to +149
Copy link

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion

티켓 타입 입력값 검증(가격/수량/기간) 권장

  • price, ticketCount ≥ 0
  • ticketStart ≤ ticketEnd

간단 예시:

-        final List<TicketTypeEntity> ticketTypeEntityList = ticketTypes.stream()
+        final List<TicketTypeEntity> ticketTypeEntityList = ticketTypes.stream()
                 .map(ticketType -> TicketTypeEntity.create(
                                 ticketRoundId,
                                 ticketType.ticketName(),
                                 ticketType.price(),
                                 ticketType.ticketCount(),
                                 LocalDateTime.of(ticketType.ticketStartDate(), ticketType.ticketStartTime()),
                                 LocalDateTime.of(ticketType.ticketEndDate(), ticketType.ticketEndTime())
                 )).toList();

위 맵핑 전, validate 함수로 조건 위반 시 AdminApiException을 던지도록 하는 방식을 권장합니다.

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

🤖 Prompt for AI Agents
In
src/main/java/com/permitseoul/permitserver/domain/admin/event/api/service/AdminEventService.java
around lines 137 to 149, the ticket-type mapping does not validate input fields;
add a validation step before mapping that iterates ticketTypes and for each
ensures price >= 0, ticketCount >= 0, and that LocalDateTime.of(ticketStartDate,
ticketStartTime) is <= LocalDateTime.of(ticketEndDate, ticketEndTime); on any
violation throw an AdminApiException with a clear message identifying the
offending ticket (e.g., index or name) so invalid requests are rejected before
creating TicketTypeEntity instances and persisting.


private TicketRound saveTicketRound(final long eventId,
final String ticketRoundName,
final LocalDateTime salesStartDateTime,
final LocalDateTime salesEndDateTime) {
return adminTicketRoundSaver.saveTicketRound(
eventId,
ticketRoundName,
salesStartDateTime,
salesEndDateTime
);
}

private Event saveEvent(final AdminEventWithTicketCreateRequest req,
final LocalDateTime eventStartDateTime,
final LocalDateTime eventEndDateTime,
final LocalDateTime eventExposureStartDateTime,
final LocalDateTime eventExposureEndDateTime) {
return adminEventSaver.saveEvent(
req.name(),
req.eventType(),
eventStartDateTime,
eventEndDateTime,
req.venue(),
req.lineup(),
req.details(),
req.minAge(),
eventExposureStartDateTime,
eventExposureEndDateTime,
req.verificationCode()
);
}

private LocalDateTime combineDateTime(final LocalDate date, final LocalTime time) {
return LocalDateTime.of(date, time);
}

private Map<Long, Integer> initSoldTicketCountZero(final List<Event> events) {
final Map<Long, Integer> map = new HashMap<>();
for (Event e : events) {
Expand Down Expand Up @@ -185,7 +282,7 @@ private Map<String, List<AdminEventListResponse.AdminEventInfo>> groupEventsByYe
return grouped;
}

private boolean isEmpty(final Collection<?> c) {
return c == null || c.isEmpty();
private boolean isEmpty(final List<?> list) {
return list == null || list.isEmpty();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package com.permitseoul.permitserver.domain.admin.event.core.component;

import com.permitseoul.permitserver.domain.event.core.domain.Event;
import com.permitseoul.permitserver.domain.event.core.domain.EventType;
import com.permitseoul.permitserver.domain.event.core.domain.entity.EventEntity;
import com.permitseoul.permitserver.domain.event.core.repository.EventRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;

import java.time.LocalDateTime;

@Component
@RequiredArgsConstructor
public class AdminEventSaver {
private final EventRepository eventRepository;

public Event saveEvent(final String name,
final EventType eventType,
final LocalDateTime startDate,
final LocalDateTime endDate,
final String venue,
final String lineUp,
final String details,
final int minAge,
final LocalDateTime visibleStartDate,
final LocalDateTime visibleEndDate,
final String ticketCheckCode) {
return Event.fromEntity(
eventRepository.save(EventEntity.create(name, eventType, startDate, endDate, venue, lineUp, details, minAge, visibleStartDate, visibleEndDate, ticketCheckCode))
);
}
}
Loading