From a0ea993152cdac743df595d8a1ba1421763b4031 Mon Sep 17 00:00:00 2001 From: gain <163835628+leegain1@users.noreply.github.com> Date: Sun, 18 Oct 2026 00:28:31 +0900 Subject: [PATCH 01/19] =?UTF-8?q?feat:=20=ED=96=89=EC=82=AC=20=ED=95=99?= =?UTF-8?q?=EC=83=9D=ED=9A=8C=EB=B9=84=20=EB=82=A9=EB=B6=80=EC=9E=90=20?= =?UTF-8?q?=EC=A0=84=EC=9A=A9=C2=B7=EA=B8=B0=ED=83=80=20=EC=84=A0=ED=83=9D?= =?UTF-8?q?=EC=A7=80=20=EC=BB=AC=EB=9F=BC=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kookmin/stream/event/domain/event/domain/Event.java | 5 ++++- .../stream/event/domain/event/domain/EventQuestion.java | 9 +++++++-- .../domain/event/domain/EventApplicationCursorTest.java | 2 +- .../domain/event/domain/EventApplicationDetailTest.java | 6 +++--- .../event/domain/event/domain/EventCursorTest.java | 2 +- .../event/domain/event/domain/EventDetailTest.java | 2 +- .../event/domain/event/domain/EventSummaryTest.java | 2 +- .../stream/event/domain/event/domain/EventTest.java | 2 +- .../service/impl/EventApplyAnswerValidatorTest.java | 2 +- .../domain/event/service/impl/EventServiceImplTest.java | 6 +++--- .../kr/ac/kookmin/stream/db/event/EventJpaEntity.java | 8 ++++++-- .../kookmin/stream/db/event/EventQuestionJpaEntity.java | 7 ++++++- ..._add_fee_paid_only_and_has_other_option_to_events.sql | 7 +++++++ 13 files changed, 42 insertions(+), 18 deletions(-) create mode 100644 infrastructure/db/src/main/resources/db/migration/V19__add_fee_paid_only_and_has_other_option_to_events.sql diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/Event.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/Event.java index e548e6f9..e680a9cc 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/Event.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/Event.java @@ -24,6 +24,8 @@ public class Event { private RecruitType recruitType; private List imageIds; private int capacity; + /** 학생회비 납부자만 신청할 수 있는지. 신청 시 검증은 아직 없다(후속 작업) */ + private boolean feePaidOnly; private boolean published; private Long createdBy; @@ -39,12 +41,13 @@ public static Event of( RecruitType recruitType, List imageIds, int capacity, + boolean feePaidOnly, boolean published, Long createdBy ) { return new Event( id, title, description, place, eventStartAt, eventEndAt, applyStartAt, - applyEndAt, recruitType, imageIds, capacity, published, createdBy + applyEndAt, recruitType, imageIds, capacity, feePaidOnly, published, createdBy ); } diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventQuestion.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventQuestion.java index 1efc0f04..180fee1a 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventQuestion.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventQuestion.java @@ -18,6 +18,8 @@ public class EventQuestion { private boolean required; private int displayOrder; private List options; + /** 선택형 질문에 '기타'(직접 입력) 선택지를 덧붙이는지. 학생 앱 노출·답변 저장은 아직 없다(후속 작업) */ + private boolean otherOptionAllowed; public static EventQuestion of( Long id, @@ -26,8 +28,11 @@ public static EventQuestion of( QuestionType questionType, boolean required, int displayOrder, - List options + List options, + boolean otherOptionAllowed ) { - return new EventQuestion(id, eventId, questionText, questionType, required, displayOrder, options); + return new EventQuestion( + id, eventId, questionText, questionType, required, displayOrder, options, otherOptionAllowed + ); } } diff --git a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventApplicationCursorTest.java b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventApplicationCursorTest.java index e013b98f..4c1f878d 100644 --- a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventApplicationCursorTest.java +++ b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventApplicationCursorTest.java @@ -29,7 +29,7 @@ void fromSummary() { Event event = Event.of( 15L, "행사", "설명", "학생회관", APPLIED_AT.plusDays(30), APPLIED_AT.plusDays(31), APPLIED_AT.minusDays(7), APPLIED_AT.plusDays(3), - RecruitType.OPEN, List.of(), 0, true, 100L + RecruitType.OPEN, List.of(), 0, false, true, 100L ); EventApplication application = EventApplication.of( 102L, 15L, 1L, EventApplicationStatus.APPLIED, APPLIED_AT, null); diff --git a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventApplicationDetailTest.java b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventApplicationDetailTest.java index cf667f61..614aa38b 100644 --- a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventApplicationDetailTest.java +++ b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventApplicationDetailTest.java @@ -15,15 +15,15 @@ class EventApplicationDetailTest { private static final EventQuestion CHOICE_QUESTION = EventQuestion.of( 1L, 15L, "관심 분야를 선택해 주세요.", QuestionType.MULTIPLE_CHOICE, true, 0, - List.of("텍스트1", "텍스트2", "텍스트3")); + List.of("텍스트1", "텍스트2", "텍스트3"), false); private static final EventQuestion TEXT_QUESTION = EventQuestion.of( - 2L, 15L, "궁금한 점을 남겨 주세요.", QuestionType.LONG_TEXT, false, 1, List.of()); + 2L, 15L, "궁금한 점을 남겨 주세요.", QuestionType.LONG_TEXT, false, 1, List.of(), false); private static Event event() { return Event.of( 15L, "동문패널톡", "설명", "미래관 419호", APPLIED_AT.plusDays(30), APPLIED_AT.plusDays(31), APPLIED_AT.minusDays(7), APPLIED_AT.plusDays(3), - RecruitType.OPEN, List.of(7L), 0, true, 100L + RecruitType.OPEN, List.of(7L), 0, false, true, 100L ); } diff --git a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventCursorTest.java b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventCursorTest.java index 3ca8e342..7c90c1d7 100644 --- a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventCursorTest.java +++ b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventCursorTest.java @@ -29,7 +29,7 @@ void fromEvent() { Event event = Event.of( 42L, "행사", "설명", "학생회관", EVENT_START_AT, EVENT_START_AT.plusHours(2), EVENT_START_AT.minusDays(7), EVENT_START_AT.minusDays(1), - RecruitType.OPEN, List.of(), 0, true, 100L + RecruitType.OPEN, List.of(), 0, false, true, 100L ); assertEquals(new EventCursor(EVENT_START_AT, 42L), EventCursor.of(event)); diff --git a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventDetailTest.java b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventDetailTest.java index 4afdc859..fb7a7ec0 100644 --- a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventDetailTest.java +++ b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventDetailTest.java @@ -18,7 +18,7 @@ private static Event event(RecruitType recruitType, List imageIds) { return Event.of( 15L, "2026 소융대 개강 행사", "소융대 재학생을 위한 개강 행사입니다.", "101호", NOW.plusDays(10), NOW.plusDays(10).plusHours(2), NOW.minusDays(1), NOW.plusDays(3), - recruitType, imageIds, 30, true, 1L + recruitType, imageIds, 30, false, true, 1L ); } diff --git a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventSummaryTest.java b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventSummaryTest.java index ca209808..01fef67d 100644 --- a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventSummaryTest.java +++ b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventSummaryTest.java @@ -23,7 +23,7 @@ private static Event event( return Event.of( 1L, "행사", "설명", "학생회관", NOW.plusDays(30), NOW.plusDays(31), applyStartAt, applyEndAt, - recruitType, imageIds, capacity, true, 100L + recruitType, imageIds, capacity, false, true, 100L ); } diff --git a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventTest.java b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventTest.java index cf7178b6..493e1496 100644 --- a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventTest.java +++ b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventTest.java @@ -22,7 +22,7 @@ private static Event event(RecruitType recruitType) { 1L, "2026 소융대 개강 행사", "설명", "101호", LocalDateTime.of(2026, 9, 15, 18, 0), LocalDateTime.of(2026, 9, 15, 20, 0), APPLY_START_AT, APPLY_END_AT, - recruitType, List.of(), CAPACITY, true, 1L + recruitType, List.of(), CAPACITY, false, true, 1L ); } diff --git a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventApplyAnswerValidatorTest.java b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventApplyAnswerValidatorTest.java index 115de48a..2e1c6602 100644 --- a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventApplyAnswerValidatorTest.java +++ b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventApplyAnswerValidatorTest.java @@ -24,7 +24,7 @@ class EventApplyAnswerValidatorTest { private final EventApplyAnswerValidator validator = new EventApplyAnswerValidator(); private static EventQuestion question(Long id, QuestionType type, boolean required, List options) { - return EventQuestion.of(id, 1L, "질문", type, required, 0, options); + return EventQuestion.of(id, 1L, "질문", type, required, 0, options, false); } /** 단답(필수) / 장문(선택) / 객관식 3지(필수) / 체크박스 2지(선택) */ diff --git a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImplTest.java b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImplTest.java index de178814..bc272f9b 100644 --- a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImplTest.java +++ b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImplTest.java @@ -34,7 +34,7 @@ private static Event event(Long id) { return Event.of( id, "행사 " + id, "설명", "101호", NOW.plusDays(10), NOW.plusDays(10).plusHours(2), NOW.minusDays(1), NOW.plusDays(3), - RecruitType.FIRST_COME, List.of(), 100, true, 1L + RecruitType.FIRST_COME, List.of(), 100, false, true, 1L ); } @@ -74,11 +74,11 @@ void appliesCountPerEvent() { Event full = Event.of( 1L, "정원 3명", "설명", "101호", NOW.plusDays(10), null, NOW.minusDays(1), NOW.plusDays(3), - RecruitType.FIRST_COME, List.of(), 3, true, 1L); + RecruitType.FIRST_COME, List.of(), 3, false, true, 1L); Event roomy = Event.of( 2L, "정원 100명", "설명", "101호", NOW.plusDays(10), null, NOW.minusDays(1), NOW.plusDays(3), - RecruitType.FIRST_COME, List.of(), 100, true, 1L); + RecruitType.FIRST_COME, List.of(), 100, false, true, 1L); FakeEventRepository repository = new FakeEventRepository( List.of(full, roomy), diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventJpaEntity.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventJpaEntity.java index d33a051c..80faadac 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventJpaEntity.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventJpaEntity.java @@ -66,6 +66,9 @@ public class EventJpaEntity extends BaseSoftDeleteEntity { @Column(nullable = false) private int capacity; + @Column(name = "fee_paid_only", nullable = false) + private boolean feePaidOnly; + @Column(name = "is_published", nullable = false) private boolean isPublished; @@ -84,6 +87,7 @@ private EventJpaEntity(Event event) { this.recruitType = event.getRecruitType(); this.imageIds = event.getImageIds(); this.capacity = event.getCapacity(); + this.feePaidOnly = event.isFeePaidOnly(); this.isPublished = event.isPublished(); this.createdBy = event.getCreatedBy(); } @@ -94,7 +98,7 @@ public static EventJpaEntity from(Event event) { public Event toDomain() { return Event.of(id, title, description, place, eventStartAt, eventEndAt, - applyStartAt, applyEndAt, recruitType, imageIds, capacity, isPublished, - createdBy); + applyStartAt, applyEndAt, recruitType, imageIds, capacity, feePaidOnly, + isPublished, createdBy); } } diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventQuestionJpaEntity.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventQuestionJpaEntity.java index ef545f0e..4751c4dc 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventQuestionJpaEntity.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventQuestionJpaEntity.java @@ -53,6 +53,9 @@ public class EventQuestionJpaEntity extends BaseTimeEntity { @Column(columnDefinition = "json") private List options; + @Column(name = "has_other_option", nullable = false) + private boolean otherOptionAllowed; + private EventQuestionJpaEntity(EventQuestion question) { this.id = question.getId(); this.eventId = question.getEventId(); @@ -61,6 +64,7 @@ private EventQuestionJpaEntity(EventQuestion question) { this.required = question.isRequired(); this.displayOrder = question.getDisplayOrder(); this.options = question.getOptions(); + this.otherOptionAllowed = question.isOtherOptionAllowed(); } public static EventQuestionJpaEntity from(EventQuestion question) { @@ -68,6 +72,7 @@ public static EventQuestionJpaEntity from(EventQuestion question) { } public EventQuestion toDomain() { - return EventQuestion.of(id, eventId, questionText, questionType, required, displayOrder, options); + return EventQuestion.of(id, eventId, questionText, questionType, required, displayOrder, options, + otherOptionAllowed); } } diff --git a/infrastructure/db/src/main/resources/db/migration/V19__add_fee_paid_only_and_has_other_option_to_events.sql b/infrastructure/db/src/main/resources/db/migration/V19__add_fee_paid_only_and_has_other_option_to_events.sql new file mode 100644 index 00000000..4b2a1c0a --- /dev/null +++ b/infrastructure/db/src/main/resources/db/migration/V19__add_fee_paid_only_and_has_other_option_to_events.sql @@ -0,0 +1,7 @@ +-- 학생회비 납부자만 신청할 수 있는 행사인지 +ALTER TABLE events + ADD COLUMN fee_paid_only TINYINT(1) NOT NULL DEFAULT 0; + +-- 선택형 질문에 '기타'(직접 입력) 선택지를 덧붙이는지 +ALTER TABLE event_questions + ADD COLUMN has_other_option TINYINT(1) NOT NULL DEFAULT 0; From 28a38bdc9f331297ac57df98a87ab40c6da9df90 Mon Sep 17 00:00:00 2001 From: gain <163835628+leegain1@users.noreply.github.com> Date: Sun, 18 Oct 2026 00:31:09 +0900 Subject: [PATCH 02/19] =?UTF-8?q?feat:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=ED=96=89=EC=82=AC=20=EB=93=B1=EB=A1=9D=20API=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/admin-api/build.gradle.kts | 1 + .../api/admin/event/event/AdminEventApi.java | 33 +++++++ .../event/event/AdminEventController.java | 32 +++++++ .../event/request/EventCreateRequest.java | 86 +++++++++++++++++++ .../event/request/EventQuestionRequest.java | 44 ++++++++++ .../event/response/EventCreateResponse.java | 10 +++ .../event/usecase/AdminEventWriteUseCase.java | 38 ++++++++ .../event/domain/event/domain/Event.java | 26 ++++++ .../event/domain/EventCreateCommand.java | 23 +++++ .../domain/event/domain/EventErrorCode.java | 5 +- .../domain/event/domain/EventQuestion.java | 19 ++++ .../event/domain/EventQuestionCommand.java | 15 ++++ .../domain/event/domain/QuestionType.java | 5 ++ .../event/repository/EventRepository.java | 4 + .../domain/event/service/EventService.java | 9 +- .../event/service/impl/EventServiceImpl.java | 22 +++++ .../service/impl/EventWriteValidator.java | 47 ++++++++++ .../service/impl/EventServiceImplTest.java | 22 +++-- .../stream/db/event/EventRepositoryImpl.java | 15 ++++ 19 files changed, 448 insertions(+), 8 deletions(-) create mode 100644 api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/AdminEventApi.java create mode 100644 api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/AdminEventController.java create mode 100644 api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/request/EventCreateRequest.java create mode 100644 api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/request/EventQuestionRequest.java create mode 100644 api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/response/EventCreateResponse.java create mode 100644 api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/usecase/AdminEventWriteUseCase.java create mode 100644 core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventCreateCommand.java create mode 100644 core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventQuestionCommand.java create mode 100644 core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventWriteValidator.java diff --git a/api/admin-api/build.gradle.kts b/api/admin-api/build.gradle.kts index 983679fe..3d452814 100644 --- a/api/admin-api/build.gradle.kts +++ b/api/admin-api/build.gradle.kts @@ -7,6 +7,7 @@ description = "운영진 콘솔 — ADMIN, /v1/admin/**" dependencies { implementation(project(":api:common-api")) implementation(project(":core:common")) + implementation(project(":core:domain:event")) implementation(project(":core:domain:file")) implementation(project(":core:domain:internal")) implementation(project(":core:domain:member")) diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/AdminEventApi.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/AdminEventApi.java new file mode 100644 index 00000000..0535ab86 --- /dev/null +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/AdminEventApi.java @@ -0,0 +1,33 @@ +package kr.ac.kookmin.stream.api.admin.event.event; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import kr.ac.kookmin.stream.api.admin.AdminApiUser; +import kr.ac.kookmin.stream.api.admin.event.event.request.EventCreateRequest; +import kr.ac.kookmin.stream.api.admin.event.event.response.EventCreateResponse; +import kr.ac.kookmin.stream.api.common.dto.ApiResponse; +import kr.ac.kookmin.stream.api.common.openapi.ApiErrorCode; +import kr.ac.kookmin.stream.common.CommonErrorCode; +import kr.ac.kookmin.stream.event.domain.event.domain.EventErrorCode; +import kr.ac.kookmin.stream.file.domain.FileErrorCode; + +/** + * 운영진 행사 API의 문서 명세. 구현은 {@link AdminEventController}가 맡는다. + *

+ * 접근은 {@code ADMIN} role이면 된다. 부서 제한은 두지 않았다. + */ +@Tag(name = "행사 관리", description = "운영진 행사 등록·조회·수정") +public interface AdminEventApi { + + /** 행사 등록. 작성자는 토큰의 관리자로 정해진다. */ + @Operation(summary = "행사 등록", + description = "행사와 신청서 질문을 등록한다. 이미지는 업로드를 마친 파일 ID로 넘기며, 존재하지 않는 파일이 하나라도 " + + "있으면 등록하지 않는다. 새 행사는 비공개로 만들어진다.") + @ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"}) + @ApiErrorCode( + type = EventErrorCode.class, + codes = {"EVENT_INVALID_PERIOD", "EVENT_INVALID_CAPACITY", "EVENT_INVALID_QUESTION"} + ) + @ApiErrorCode(type = FileErrorCode.class, codes = {"FILE_NOT_FOUND"}) + ApiResponse createEvent(AdminApiUser apiUser, EventCreateRequest request); +} diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/AdminEventController.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/AdminEventController.java new file mode 100644 index 00000000..ddb96553 --- /dev/null +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/AdminEventController.java @@ -0,0 +1,32 @@ +package kr.ac.kookmin.stream.api.admin.event.event; + +import jakarta.validation.Valid; +import kr.ac.kookmin.stream.api.admin.AdminApiUser; +import kr.ac.kookmin.stream.api.admin.event.event.request.EventCreateRequest; +import kr.ac.kookmin.stream.api.admin.event.event.response.EventCreateResponse; +import kr.ac.kookmin.stream.api.admin.event.event.usecase.AdminEventWriteUseCase; +import kr.ac.kookmin.stream.api.common.dto.ApiResponse; +import kr.ac.kookmin.stream.event.domain.event.domain.Event; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/v1/admin/events") +@RequiredArgsConstructor +public class AdminEventController implements AdminEventApi { + + private final AdminEventWriteUseCase adminEventWriteUseCase; + + @Override + @PostMapping + public ApiResponse createEvent( + AdminApiUser apiUser, + @Valid @RequestBody EventCreateRequest request + ) { + Event event = adminEventWriteUseCase.create(apiUser.userId(), request.toCommand()); + return ApiResponse.success(EventCreateResponse.from(event)); + } +} diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/request/EventCreateRequest.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/request/EventCreateRequest.java new file mode 100644 index 00000000..d8509ffc --- /dev/null +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/request/EventCreateRequest.java @@ -0,0 +1,86 @@ +package kr.ac.kookmin.stream.api.admin.event.event.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import java.time.LocalDateTime; +import java.util.List; +import kr.ac.kookmin.stream.event.domain.event.domain.EventCreateCommand; +import kr.ac.kookmin.stream.event.domain.event.domain.RecruitType; + +/** + * 작성자는 요청 본문이 아니라 토큰의 관리자 ID로 정한다. 공개 여부는 받지 않고 비공개로 시작한다. + */ +public record EventCreateRequest( + @Schema(description = "행사 제목", example = "체육대회") + @NotBlank(message = "행사 제목을 입력해 주세요.") + @Size(max = 200, message = "행사 제목은 200자 이하로 입력해 주세요.") + String title, + + // description은 TEXT 컬럼(최대 65,535바이트)이다. 글자당 최대 4바이트로 잡아도 넘치지 않는 길이로 막는다 + @Schema(description = "행사 소개글", example = "학과 대항 체육대회입니다.") + @NotBlank(message = "행사 소개글을 입력해 주세요.") + @Size(max = 16000, message = "행사 소개글은 16000자 이하로 입력해 주세요.") + String description, + + @Schema(description = "행사 장소", example = "종합운동장") + @Size(max = 255, message = "행사 장소는 255자 이하로 입력해 주세요.") + String place, + + @Schema(description = "행사 시작 일시", example = "2026-11-01T10:00:00") + @NotNull(message = "행사 시작 일시를 입력해 주세요.") + LocalDateTime eventStartAt, + + @Schema(description = "행사 종료 일시. 시작 일시보다 늦어야 한다", example = "2026-11-01T18:00:00") + @NotNull(message = "행사 종료 일시를 입력해 주세요.") + LocalDateTime eventEndAt, + + @Schema(description = "신청 시작 일시", example = "2026-10-14T10:00:00") + @NotNull(message = "신청 시작 일시를 입력해 주세요.") + LocalDateTime applyStartAt, + + @Schema(description = "신청 마감 일시. 시작 일시보다 늦어야 한다", example = "2026-10-25T23:59:59") + @NotNull(message = "신청 마감 일시를 입력해 주세요.") + LocalDateTime applyEndAt, + + @Schema(description = "모집 방식. 선착순이면 FIRST_COME, 인원 제한이 없으면 OPEN") + @NotNull(message = "모집 방식을 입력해 주세요.") + RecruitType recruitType, + + @Schema(description = "모집 정원. FIRST_COME이면 1 이상 필요하고, OPEN이면 무시한다", example = "100") + Integer capacity, + + @Schema(description = "학생회비 납부자만 신청 가능 여부") + @NotNull(message = "학생회비 납부자 전용 여부를 입력해 주세요.") + Boolean feePaidOnly, + + @Schema(description = "행사 이미지 파일 ID 목록. 첫 번째가 대표 이미지가 된다", example = "[31, 32]") + @NotNull(message = "이미지 파일 목록을 입력해 주세요.") + @Size(max = 10, message = "이미지는 10장까지 등록할 수 있습니다.") + List<@NotNull(message = "이미지 파일 ID는 비워 둘 수 없습니다.") Long> imageIds, + + @Schema(description = "신청서 질문 목록. 목록 순서대로 신청서에 노출된다") + @NotNull(message = "신청서 질문 목록을 입력해 주세요.") + @Valid + List<@NotNull(message = "신청서 질문이 비어 있습니다.") EventQuestionRequest> questions +) { + + public EventCreateCommand toCommand() { + return new EventCreateCommand( + title, + description, + place, + eventStartAt, + eventEndAt, + applyStartAt, + applyEndAt, + recruitType, + capacity, + feePaidOnly, + imageIds.stream().distinct().toList(), + questions.stream().map(EventQuestionRequest::toCommand).toList() + ); + } +} diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/request/EventQuestionRequest.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/request/EventQuestionRequest.java new file mode 100644 index 00000000..a507a69a --- /dev/null +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/request/EventQuestionRequest.java @@ -0,0 +1,44 @@ +package kr.ac.kookmin.stream.api.admin.event.event.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import java.util.List; +import kr.ac.kookmin.stream.event.domain.event.domain.EventQuestionCommand; +import kr.ac.kookmin.stream.event.domain.event.domain.QuestionType; + +/** + * 신청서 질문 한 건. 행사 등록·수정 요청이 함께 쓴다. 노출 순서는 목록에서의 위치로 정하므로 따로 받지 않는다. + */ +public record EventQuestionRequest( + @Schema(description = "질문 유형") + @NotNull(message = "질문 유형을 입력해 주세요.") + QuestionType questionType, + + @Schema(description = "질문 내용", example = "참여 종목") + @NotBlank(message = "질문 내용을 입력해 주세요.") + @Size(max = 500, message = "질문 내용은 500자 이하로 입력해 주세요.") + String questionText, + + @Schema(description = "필수 응답 여부") + @NotNull(message = "필수 응답 여부를 입력해 주세요.") + Boolean isRequired, + + @Schema(description = "선택지 목록. 선택형이면 1개 이상 필요하고, 텍스트형이면 무시한다", example = "[\"축구\", \"농구\"]") + List<@NotBlank(message = "선택지는 비워 둘 수 없습니다.") String> options, + + @Schema(description = "'기타'(직접 입력) 선택지 추가 여부. 선택형에서만 쓰며 기본값은 false") + Boolean hasOtherOption +) { + + public EventQuestionCommand toCommand() { + return new EventQuestionCommand( + questionText, + questionType, + isRequired, + options == null ? List.of() : options, + Boolean.TRUE.equals(hasOtherOption) + ); + } +} diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/response/EventCreateResponse.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/response/EventCreateResponse.java new file mode 100644 index 00000000..2feb1408 --- /dev/null +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/response/EventCreateResponse.java @@ -0,0 +1,10 @@ +package kr.ac.kookmin.stream.api.admin.event.event.response; + +import kr.ac.kookmin.stream.event.domain.event.domain.Event; + +public record EventCreateResponse(Long eventId) { + + public static EventCreateResponse from(Event event) { + return new EventCreateResponse(event.getId()); + } +} diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/usecase/AdminEventWriteUseCase.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/usecase/AdminEventWriteUseCase.java new file mode 100644 index 00000000..0deb52b0 --- /dev/null +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/usecase/AdminEventWriteUseCase.java @@ -0,0 +1,38 @@ +package kr.ac.kookmin.stream.api.admin.event.event.usecase; + +import java.util.List; +import kr.ac.kookmin.stream.common.BusinessException; +import kr.ac.kookmin.stream.event.domain.event.domain.Event; +import kr.ac.kookmin.stream.event.domain.event.domain.EventCreateCommand; +import kr.ac.kookmin.stream.event.domain.event.service.EventService; +import kr.ac.kookmin.stream.file.domain.FileErrorCode; +import kr.ac.kookmin.stream.file.service.FileService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +/** + * 행사를 쓰기 전에 연결할 이미지 파일이 실제로 있는지 확인한다. 행사(event)와 파일(file)은 서로 다른 도메인이라 + * 도메인끼리 직접 의존하지 않고 이 UseCase가 두 Service를 조합한다. + *

+ * 파일 확인과 행사 저장은 한 트랜잭션으로 묶지 않는다. 확인 직후 파일이 지워져도 행사 조회가 삭제된 파일을 + * 목록에서 조용히 제외하므로 원자성이 필요하지 않다. + */ +@Component +@RequiredArgsConstructor +public class AdminEventWriteUseCase { + + private final EventService eventService; + private final FileService fileService; + + public Event create(Long createdBy, EventCreateCommand command) { + requireImagesExist(command.imageIds()); + return eventService.create(createdBy, command); + } + + // 요청 DTO가 중복을 걸러 넘기므로 조회 결과 개수와 바로 비교할 수 있다 + private void requireImagesExist(List imageIds) { + if (fileService.findAllByIdIn(imageIds).size() != imageIds.size()) { + throw new BusinessException(FileErrorCode.FILE_NOT_FOUND); + } + } +} diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/Event.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/Event.java index e680a9cc..f7bbef14 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/Event.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/Event.java @@ -51,6 +51,28 @@ public static Event of( ); } + /** + * 새 행사를 만든다. 학생에게 바로 보이지 않도록 비공개로 시작한다. 상시 모집은 정원이 의미 없어 0으로 둔다. + */ + public static Event create(Long createdBy, EventCreateCommand command) { + return new Event( + null, + command.title(), + command.description(), + command.place(), + command.eventStartAt(), + command.eventEndAt(), + command.applyStartAt(), + command.applyEndAt(), + command.recruitType(), + command.imageIds(), + capacityOf(command.recruitType(), command.capacity()), + command.feePaidOnly(), + false, + createdBy + ); + } + /** * 현재 시각과 신청자 수를 반영한 모집 상태를 계산한다. *

@@ -112,6 +134,10 @@ public boolean isClosedByCapacityOnly(LocalDateTime now, long appliedCount) { && isCapacityFull(appliedCount); } + private static int capacityOf(RecruitType recruitType, Integer capacity) { + return recruitType == RecruitType.FIRST_COME ? capacity : 0; + } + private boolean isBeforeApplyPeriod(LocalDateTime now) { return now.isBefore(applyStartAt); } diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventCreateCommand.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventCreateCommand.java new file mode 100644 index 00000000..2d63c3c1 --- /dev/null +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventCreateCommand.java @@ -0,0 +1,23 @@ +package kr.ac.kookmin.stream.event.domain.event.domain; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * @param capacity 선착순 모집의 정원. 상시 모집이면 쓰이지 않는다 + */ +public record EventCreateCommand( + String title, + String description, + String place, + LocalDateTime eventStartAt, + LocalDateTime eventEndAt, + LocalDateTime applyStartAt, + LocalDateTime applyEndAt, + RecruitType recruitType, + Integer capacity, + boolean feePaidOnly, + List imageIds, + List questions +) { +} diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventErrorCode.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventErrorCode.java index 65f1b728..98e1454b 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventErrorCode.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventErrorCode.java @@ -20,7 +20,10 @@ public enum EventErrorCode implements ErrorCode { CANCEL_DEADLINE_PASSED(ErrorStatus.CONFLICT, "신청이 마감되어 취소할 수 없습니다."), INVALID_ANSWER(ErrorStatus.BAD_REQUEST, "신청서 답변 형식이 올바르지 않습니다."), EVENT_INVALID_CURSOR(ErrorStatus.BAD_REQUEST, "유효하지 않은 커서입니다."), - EVENT_INVALID_RECRUIT_STATUS(ErrorStatus.BAD_REQUEST, "유효하지 않은 모집 상태입니다."); + EVENT_INVALID_RECRUIT_STATUS(ErrorStatus.BAD_REQUEST, "유효하지 않은 모집 상태입니다."), + EVENT_INVALID_PERIOD(ErrorStatus.BAD_REQUEST, "종료 일시는 시작 일시보다 늦어야 합니다."), + EVENT_INVALID_CAPACITY(ErrorStatus.BAD_REQUEST, "선착순 모집은 1명 이상의 정원을 입력해야 합니다."), + EVENT_INVALID_QUESTION(ErrorStatus.BAD_REQUEST, "선택형 질문에는 선택지를 1개 이상 입력해야 합니다."); private final int status; private final String message; diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventQuestion.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventQuestion.java index 180fee1a..34481a4e 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventQuestion.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventQuestion.java @@ -35,4 +35,23 @@ public static EventQuestion of( id, eventId, questionText, questionType, required, displayOrder, options, otherOptionAllowed ); } + + /** + * 새 질문을 만든다. 텍스트형 질문에 딸려 온 선택지·'기타' 여부는 의미가 없으므로 버린다. + * + * @param displayOrder 신청서에서 몇 번째로 보일지. 1부터 센다 + */ + public static EventQuestion create(Long eventId, int displayOrder, EventQuestionCommand command) { + boolean choice = command.questionType().isChoice(); + return new EventQuestion( + null, + eventId, + command.questionText(), + command.questionType(), + command.required(), + displayOrder, + choice ? List.copyOf(command.options()) : List.of(), + choice && command.otherOptionAllowed() + ); + } } diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventQuestionCommand.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventQuestionCommand.java new file mode 100644 index 00000000..e5de6e19 --- /dev/null +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventQuestionCommand.java @@ -0,0 +1,15 @@ +package kr.ac.kookmin.stream.event.domain.event.domain; + +import java.util.List; + +/** + * 행사 등록·수정 요청에 담긴 신청서 질문 한 건. 노출 순서는 목록에서의 위치로 정한다. + */ +public record EventQuestionCommand( + String questionText, + QuestionType questionType, + boolean required, + List options, + boolean otherOptionAllowed +) { +} diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/QuestionType.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/QuestionType.java index a5990530..4f936b35 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/QuestionType.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/QuestionType.java @@ -17,4 +17,9 @@ public enum QuestionType { /** 답변 길이 제한. 선택형 질문은 답변이 텍스트가 아니라 null이다. */ private final Integer maxLength; + + /** 선택지에서 고르는 질문인지. 선택지·'기타' 선택지는 이 유형에만 의미가 있다. */ + public boolean isChoice() { + return this == SINGLE_CHOICE || this == MULTIPLE_CHOICE; + } } diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/repository/EventRepository.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/repository/EventRepository.java index bd109be5..b12caceb 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/repository/EventRepository.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/repository/EventRepository.java @@ -16,6 +16,10 @@ public interface EventRepository { Optional findById(Long id); + Event save(Event event); + + List saveQuestions(List questions); + /** * 주어진 식별자의 행사를 조회한다. 삭제 여부로 거르지 않는다. *

diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/EventService.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/EventService.java index c032dee9..c1214c53 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/EventService.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/EventService.java @@ -1,15 +1,22 @@ package kr.ac.kookmin.stream.event.domain.event.service; import kr.ac.kookmin.stream.common.CursorSliceResult; +import kr.ac.kookmin.stream.event.domain.event.domain.Event; +import kr.ac.kookmin.stream.event.domain.event.domain.EventCreateCommand; import kr.ac.kookmin.stream.event.domain.event.domain.EventCursor; import kr.ac.kookmin.stream.event.domain.event.domain.EventDetail; import kr.ac.kookmin.stream.event.domain.event.domain.EventSummary; import kr.ac.kookmin.stream.event.domain.event.domain.RecruitStatus; -/** 행사 자체의 조회. 신청·취소는 {@link EventApplicationService}가 맡는다. */ +/** 행사 자체의 조회와 운영진의 등록·수정. 신청·취소는 {@link EventApplicationService}가 맡는다. */ public interface EventService { CursorSliceResult getPublishedEvents(RecruitStatus recruitStatus, EventCursor cursor, int size); EventDetail getPublishedEvent(Long eventId); + + /** + * 운영진이 행사를 등록한다. 신청서 질문도 함께 저장하며, 등록한 행사는 비공개로 시작한다. + */ + Event create(Long createdBy, EventCreateCommand command); } diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImpl.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImpl.java index cc42849a..90ef1f7e 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImpl.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImpl.java @@ -3,12 +3,16 @@ import java.time.LocalDateTime; import java.util.List; import java.util.Map; +import java.util.stream.IntStream; import kr.ac.kookmin.stream.common.BusinessException; import kr.ac.kookmin.stream.common.CursorSliceResult; import kr.ac.kookmin.stream.event.domain.event.domain.Event; +import kr.ac.kookmin.stream.event.domain.event.domain.EventCreateCommand; import kr.ac.kookmin.stream.event.domain.event.domain.EventCursor; import kr.ac.kookmin.stream.event.domain.event.domain.EventDetail; import kr.ac.kookmin.stream.event.domain.event.domain.EventErrorCode; +import kr.ac.kookmin.stream.event.domain.event.domain.EventQuestion; +import kr.ac.kookmin.stream.event.domain.event.domain.EventQuestionCommand; import kr.ac.kookmin.stream.event.domain.event.domain.EventSummary; import kr.ac.kookmin.stream.event.domain.event.domain.RecruitStatus; import kr.ac.kookmin.stream.event.domain.event.repository.EventRepository; @@ -22,6 +26,7 @@ class EventServiceImpl implements EventService { private final EventRepository eventRepository; + private final EventWriteValidator eventWriteValidator; @Override @Transactional(readOnly = true) @@ -56,4 +61,21 @@ public EventDetail getPublishedEvent(Long eventId) { LocalDateTime now = LocalDateTime.now(); return EventDetail.of(event, eventRepository.countAppliedByEventId(eventId), now); } + + @Override + @Transactional + public Event create(Long createdBy, EventCreateCommand command) { + eventWriteValidator.validate(command); + + Event event = eventRepository.save(Event.create(createdBy, command)); + eventRepository.saveQuestions(toQuestions(event.getId(), command.questions())); + return event; + } + + // 신청서에 보이는 순서는 요청 목록의 순서를 따른다 + private static List toQuestions(Long eventId, List commands) { + return IntStream.range(0, commands.size()) + .mapToObj(index -> EventQuestion.create(eventId, index + 1, commands.get(index))) + .toList(); + } } diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventWriteValidator.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventWriteValidator.java new file mode 100644 index 00000000..4a224051 --- /dev/null +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventWriteValidator.java @@ -0,0 +1,47 @@ +package kr.ac.kookmin.stream.event.domain.event.service.impl; + +import java.time.LocalDateTime; +import java.util.List; +import kr.ac.kookmin.stream.common.BusinessException; +import kr.ac.kookmin.stream.event.domain.event.domain.EventCreateCommand; +import kr.ac.kookmin.stream.event.domain.event.domain.EventErrorCode; +import kr.ac.kookmin.stream.event.domain.event.domain.EventQuestionCommand; +import kr.ac.kookmin.stream.event.domain.event.domain.RecruitType; +import org.springframework.stereotype.Component; + +/** + * 운영진이 행사를 등록·수정할 때 요청 형식(Bean Validation)만으로는 걸러지지 않는 규칙을 확인한다. + * 필드 하나로 판단할 수 없는 조합(시작·종료 순서, 모집 방식에 따른 정원, 질문 유형에 따른 선택지)을 본다. + */ +@Component +class EventWriteValidator { + + void validate(EventCreateCommand command) { + validatePeriod(command.eventStartAt(), command.eventEndAt()); + validatePeriod(command.applyStartAt(), command.applyEndAt()); + validateCapacity(command.recruitType(), command.capacity()); + validateQuestions(command.questions()); + } + + private void validatePeriod(LocalDateTime startAt, LocalDateTime endAt) { + if (!endAt.isAfter(startAt)) { + throw new BusinessException(EventErrorCode.EVENT_INVALID_PERIOD); + } + } + + // 상시 모집은 인원 제한이 없어 정원을 보지 않는다 + private void validateCapacity(RecruitType recruitType, Integer capacity) { + if (recruitType == RecruitType.FIRST_COME && (capacity == null || capacity < 1)) { + throw new BusinessException(EventErrorCode.EVENT_INVALID_CAPACITY); + } + } + + private void validateQuestions(List questions) { + boolean choiceWithoutOptions = questions.stream() + .anyMatch(question -> question.questionType().isChoice() + && (question.options() == null || question.options().isEmpty())); + if (choiceWithoutOptions) { + throw new BusinessException(EventErrorCode.EVENT_INVALID_QUESTION); + } + } +} diff --git a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImplTest.java b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImplTest.java index bc272f9b..630a4f7f 100644 --- a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImplTest.java +++ b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImplTest.java @@ -45,7 +45,7 @@ void matchesApplicantCountToItsEvent() { List.of(event(1L), event(2L)), Map.of(1L, 3L, 2L, 7L)); - List content = new EventServiceImpl(repository) + List content = new EventServiceImpl(repository, new EventWriteValidator()) .getPublishedEvents(null, null, 20).content(); // 정원 100명이라 신청자 수가 모집 상태를 바꾸지 않는다. 짝이 맞는지는 D-Day가 아니라 아래에서 직접 본다 @@ -61,7 +61,7 @@ void keepsEventsWithoutApplicants() { List.of(event(1L), event(2L)), Map.of(1L, 3L)); - List content = new EventServiceImpl(repository) + List content = new EventServiceImpl(repository, new EventWriteValidator()) .getPublishedEvents(null, null, 20).content(); assertEquals(2, content.size()); @@ -84,7 +84,7 @@ void appliesCountPerEvent() { List.of(full, roomy), Map.of(1L, 3L, 2L, 3L)); - List content = new EventServiceImpl(repository) + List content = new EventServiceImpl(repository, new EventWriteValidator()) .getPublishedEvents(null, null, 20).content(); assertEquals(RecruitStatus.CLOSED, content.get(0).recruitStatus()); @@ -97,7 +97,7 @@ void detectsNextPage() { FakeEventRepository repository = new FakeEventRepository( List.of(event(1L), event(2L), event(3L)), Map.of()); - CursorSliceResult result = new EventServiceImpl(repository) + CursorSliceResult result = new EventServiceImpl(repository, new EventWriteValidator()) .getPublishedEvents(null, null, 2); assertEquals(3, repository.requestedLimit); @@ -111,7 +111,7 @@ void detectsNextPage() { void noCursorOnLastPage() { FakeEventRepository repository = new FakeEventRepository(List.of(event(1L), event(2L)), Map.of()); - CursorSliceResult result = new EventServiceImpl(repository) + CursorSliceResult result = new EventServiceImpl(repository, new EventWriteValidator()) .getPublishedEvents(null, null, 2); assertFalse(result.hasNext()); @@ -124,7 +124,7 @@ void asksNothingWhenSliceIsEmpty() { FakeEventRepository repository = new FakeEventRepository( List.of(), Map.of()); - CursorSliceResult result = new EventServiceImpl(repository) + CursorSliceResult result = new EventServiceImpl(repository, new EventWriteValidator()) .getPublishedEvents(null, null, 20); assertTrue(result.content().isEmpty()); @@ -162,6 +162,16 @@ public Optional findById(Long id) { throw new UnsupportedOperationException(); } + @Override + public Event save(Event event) { + throw new UnsupportedOperationException(); + } + + @Override + public List saveQuestions(List questions) { + throw new UnsupportedOperationException(); + } + @Override public Optional findPublishedById(Long id) { throw new UnsupportedOperationException(); diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventRepositoryImpl.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventRepositoryImpl.java index 58b9f5e1..326a0275 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventRepositoryImpl.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventRepositoryImpl.java @@ -28,6 +28,21 @@ public class EventRepositoryImpl implements EventRepository { private final EventApplicationJpaRepository eventApplicationJpaRepository; private final EventApplicationAnswerJpaRepository eventApplicationAnswerJpaRepository; + @Override + public Event save(Event event) { + return eventJpaRepository.save(EventJpaEntity.from(event)).toDomain(); + } + + @Override + public List saveQuestions(List questions) { + List entities = questions.stream() + .map(EventQuestionJpaEntity::from) + .toList(); + return eventQuestionJpaRepository.saveAll(entities).stream() + .map(EventQuestionJpaEntity::toDomain) + .toList(); + } + @Override public Optional findById(Long id) { return eventJpaRepository.findByIdAndIsDeletedFalse(id).map(EventJpaEntity::toDomain); From 958e546f07f2c6f90eca39ae9fd35e18c004fbf6 Mon Sep 17 00:00:00 2001 From: gain <163835628+leegain1@users.noreply.github.com> Date: Sun, 18 Oct 2026 00:33:46 +0900 Subject: [PATCH 03/19] =?UTF-8?q?feat:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=ED=96=89=EC=82=AC=20=EB=AA=A9=EB=A1=9D=C2=B7=EC=83=81=EC=84=B8?= =?UTF-8?q?=20=EC=A1=B0=ED=9A=8C=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/admin/event/event/AdminEventApi.java | 23 +++++ .../event/event/AdminEventController.java | 35 +++++++ .../event/request/EventSearchParams.java | 14 +++ .../response/AdminEventDetailResponse.java | 96 +++++++++++++++++++ .../response/AdminEventListItemResponse.java | 36 +++++++ .../event/domain/event/domain/Event.java | 7 ++ .../event/domain/EventManageDetail.java | 28 ++++++ .../event/domain/EventManageSummary.java | 36 +++++++ .../event/repository/EventRepository.java | 9 ++ .../domain/event/service/EventService.java | 14 +++ .../event/service/impl/EventServiceImpl.java | 34 +++++++ .../service/impl/EventServiceImplTest.java | 7 ++ .../stream/db/event/EventJpaRepository.java | 19 ++++ .../stream/db/event/EventRepositoryImpl.java | 18 ++++ 14 files changed, 376 insertions(+) create mode 100644 api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/request/EventSearchParams.java create mode 100644 api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/response/AdminEventDetailResponse.java create mode 100644 api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/response/AdminEventListItemResponse.java create mode 100644 core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventManageDetail.java create mode 100644 core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventManageSummary.java diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/AdminEventApi.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/AdminEventApi.java index 0535ab86..ce01b283 100644 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/AdminEventApi.java +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/AdminEventApi.java @@ -4,12 +4,18 @@ import io.swagger.v3.oas.annotations.tags.Tag; import kr.ac.kookmin.stream.api.admin.AdminApiUser; import kr.ac.kookmin.stream.api.admin.event.event.request.EventCreateRequest; +import kr.ac.kookmin.stream.api.admin.event.event.request.EventSearchParams; +import kr.ac.kookmin.stream.api.admin.event.event.response.AdminEventDetailResponse; +import kr.ac.kookmin.stream.api.admin.event.event.response.AdminEventListItemResponse; import kr.ac.kookmin.stream.api.admin.event.event.response.EventCreateResponse; import kr.ac.kookmin.stream.api.common.dto.ApiResponse; +import kr.ac.kookmin.stream.api.common.dto.PageParams; +import kr.ac.kookmin.stream.api.common.dto.PageResponse; import kr.ac.kookmin.stream.api.common.openapi.ApiErrorCode; import kr.ac.kookmin.stream.common.CommonErrorCode; import kr.ac.kookmin.stream.event.domain.event.domain.EventErrorCode; import kr.ac.kookmin.stream.file.domain.FileErrorCode; +import org.springdoc.core.annotations.ParameterObject; /** * 운영진 행사 API의 문서 명세. 구현은 {@link AdminEventController}가 맡는다. @@ -30,4 +36,21 @@ public interface AdminEventApi { ) @ApiErrorCode(type = FileErrorCode.class, codes = {"FILE_NOT_FOUND"}) ApiResponse createEvent(AdminApiUser apiUser, EventCreateRequest request); + + /** 행사 목록. 비공개 행사도 함께 내려준다. */ + @Operation(summary = "행사 목록 조회", + description = "삭제되지 않은 행사를 공개 여부와 상관없이 최근 등록 순으로 페이지 단위 조회한다. keyword로 제목을 검색한다. " + + "모집 상태는 조회 시점의 신청 기간·잔여 정원으로 계산한다.") + @ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"}) + ApiResponse> getEvents( + AdminApiUser apiUser, + @ParameterObject EventSearchParams params, + @ParameterObject PageParams pageParams + ); + + /** 행사 상세. 수정 화면의 초기값으로 쓴다. */ + @Operation(summary = "행사 상세 조회", + description = "행사 정보와 신청서 질문 전체를 조회한다. 비공개 행사도 조회할 수 있다.") + @ApiErrorCode(type = EventErrorCode.class, codes = {"EVENT_NOT_FOUND"}) + ApiResponse getEvent(AdminApiUser apiUser, Long eventId); } diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/AdminEventController.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/AdminEventController.java index ddb96553..0cad3621 100644 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/AdminEventController.java +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/AdminEventController.java @@ -1,13 +1,26 @@ package kr.ac.kookmin.stream.api.admin.event.event; import jakarta.validation.Valid; +import java.util.Map; import kr.ac.kookmin.stream.api.admin.AdminApiUser; import kr.ac.kookmin.stream.api.admin.event.event.request.EventCreateRequest; +import kr.ac.kookmin.stream.api.admin.event.event.request.EventSearchParams; +import kr.ac.kookmin.stream.api.admin.event.event.response.AdminEventDetailResponse; +import kr.ac.kookmin.stream.api.admin.event.event.response.AdminEventListItemResponse; import kr.ac.kookmin.stream.api.admin.event.event.response.EventCreateResponse; import kr.ac.kookmin.stream.api.admin.event.event.usecase.AdminEventWriteUseCase; import kr.ac.kookmin.stream.api.common.dto.ApiResponse; +import kr.ac.kookmin.stream.api.common.dto.PageParams; +import kr.ac.kookmin.stream.api.common.dto.PageResponse; import kr.ac.kookmin.stream.event.domain.event.domain.Event; +import kr.ac.kookmin.stream.event.domain.event.domain.EventManageDetail; +import kr.ac.kookmin.stream.event.domain.event.service.EventService; +import kr.ac.kookmin.stream.file.domain.File; +import kr.ac.kookmin.stream.file.service.FileService; import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -18,6 +31,8 @@ @RequiredArgsConstructor public class AdminEventController implements AdminEventApi { + private final EventService eventService; + private final FileService fileService; private final AdminEventWriteUseCase adminEventWriteUseCase; @Override @@ -29,4 +44,24 @@ public ApiResponse createEvent( Event event = adminEventWriteUseCase.create(apiUser.userId(), request.toCommand()); return ApiResponse.success(EventCreateResponse.from(event)); } + + @Override + @GetMapping + public ApiResponse> getEvents( + AdminApiUser apiUser, + @Valid @ModelAttribute EventSearchParams params, + @Valid @ModelAttribute PageParams pageParams + ) { + return ApiResponse.success(PageResponse.from( + eventService.getEvents(params.toKeyword(), pageParams.toOffset()), AdminEventListItemResponse::from)); + } + + @Override + @GetMapping("/{eventId}") + public ApiResponse getEvent(AdminApiUser apiUser, @PathVariable Long eventId) { + EventManageDetail detail = eventService.getEvent(eventId); + Map imagesById = fileService.findAllByIdIn(detail.imageIds()); + + return ApiResponse.success(AdminEventDetailResponse.from(detail, imagesById)); + } } diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/request/EventSearchParams.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/request/EventSearchParams.java new file mode 100644 index 00000000..86cfeb9d --- /dev/null +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/request/EventSearchParams.java @@ -0,0 +1,14 @@ +package kr.ac.kookmin.stream.api.admin.event.event.request; + +import io.swagger.v3.oas.annotations.media.Schema; + +public record EventSearchParams( + @Schema(description = "행사 제목 검색어", example = "체육대회") + String keyword +) { + + // 빈 검색어는 검색하지 않는 것으로 본다 + public String toKeyword() { + return keyword == null || keyword.isBlank() ? null : keyword.strip(); + } +} diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/response/AdminEventDetailResponse.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/response/AdminEventDetailResponse.java new file mode 100644 index 00000000..ea781078 --- /dev/null +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/response/AdminEventDetailResponse.java @@ -0,0 +1,96 @@ +package kr.ac.kookmin.stream.api.admin.event.event.response; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import kr.ac.kookmin.stream.api.common.StorageUrlBuilder; +import kr.ac.kookmin.stream.event.domain.event.domain.Event; +import kr.ac.kookmin.stream.event.domain.event.domain.EventManageDetail; +import kr.ac.kookmin.stream.event.domain.event.domain.EventQuestion; +import kr.ac.kookmin.stream.event.domain.event.domain.QuestionType; +import kr.ac.kookmin.stream.event.domain.event.domain.RecruitStatus; +import kr.ac.kookmin.stream.event.domain.event.domain.RecruitType; +import kr.ac.kookmin.stream.file.domain.File; + +/** + * 수정 화면의 초기값. 필드 이름은 등록·수정 요청 본문과 맞춘다. + * + * @param capacity 선착순 모집의 정원. 상시 모집이면 {@code null} + */ +public record AdminEventDetailResponse( + Long eventId, + String title, + String description, + String place, + LocalDateTime eventStartAt, + LocalDateTime eventEndAt, + LocalDateTime applyStartAt, + LocalDateTime applyEndAt, + RecruitType recruitType, + Integer capacity, + boolean feePaidOnly, + long applicantCount, + RecruitStatus recruitStatus, + boolean isPublished, + List images, + List questions +) { + + /** + * 존재하지 않는(삭제된) fileId는 imagesById에 키가 없다 — 그런 항목은 목록에서 조용히 제외한다. + */ + public static AdminEventDetailResponse from(EventManageDetail detail, Map imagesById) { + Event event = detail.event(); + return new AdminEventDetailResponse( + event.getId(), + event.getTitle(), + event.getDescription(), + event.getPlace(), + event.getEventStartAt(), + event.getEventEndAt(), + event.getApplyStartAt(), + event.getApplyEndAt(), + event.getRecruitType(), + event.capacityLimit(), + event.isFeePaidOnly(), + detail.applicantCount(), + detail.recruitStatus(), + event.isPublished(), + detail.imageIds().stream() + .filter(imagesById::containsKey) + .map(fileId -> Image.of(fileId, imagesById.get(fileId))) + .toList(), + detail.questions().stream().map(Question::from).toList() + ); + } + + public record Image(Long fileId, String fileUrl) { + + public static Image of(Long fileId, File file) { + return new Image(fileId, StorageUrlBuilder.build(file.getFileKey())); + } + } + + public record Question( + Long questionId, + QuestionType questionType, + String questionText, + boolean isRequired, + int displayOrder, + List options, + boolean hasOtherOption + ) { + + public static Question from(EventQuestion question) { + return new Question( + question.getId(), + question.getQuestionType(), + question.getQuestionText(), + question.isRequired(), + question.getDisplayOrder(), + question.getOptions() == null ? List.of() : question.getOptions(), + question.isOtherOptionAllowed() + ); + } + } +} diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/response/AdminEventListItemResponse.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/response/AdminEventListItemResponse.java new file mode 100644 index 00000000..5e4cee62 --- /dev/null +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/response/AdminEventListItemResponse.java @@ -0,0 +1,36 @@ +package kr.ac.kookmin.stream.api.admin.event.event.response; + +import java.time.LocalDateTime; +import kr.ac.kookmin.stream.event.domain.event.domain.EventManageSummary; +import kr.ac.kookmin.stream.event.domain.event.domain.RecruitStatus; +import kr.ac.kookmin.stream.event.domain.event.domain.RecruitType; + +/** + * @param capacity 선착순 모집의 정원. 상시 모집이면 {@code null}이고 화면에 "인원 제한 없음"으로 보인다 + */ +public record AdminEventListItemResponse( + Long eventId, + String title, + long applicantCount, + RecruitType recruitType, + Integer capacity, + RecruitStatus recruitStatus, + boolean isPublished, + LocalDateTime applyStartAt, + LocalDateTime applyEndAt +) { + + public static AdminEventListItemResponse from(EventManageSummary summary) { + return new AdminEventListItemResponse( + summary.eventId(), + summary.title(), + summary.applicantCount(), + summary.recruitType(), + summary.capacity(), + summary.recruitStatus(), + summary.published(), + summary.applyStartAt(), + summary.applyEndAt() + ); + } +} diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/Event.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/Event.java index f7bbef14..7d0b178e 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/Event.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/Event.java @@ -117,6 +117,13 @@ public Long thumbnailFileId() { return imageIds == null || imageIds.isEmpty() ? null : imageIds.getFirst(); } + /** + * 운영진 화면에 보여줄 정원. 상시 모집은 인원 제한이 없어 {@code null}이다(저장값 0은 의미가 없다). + */ + public Integer capacityLimit() { + return recruitType == RecruitType.FIRST_COME ? capacity : null; + } + /** * 정원이 찼는지 판정한다. 선착순 모집에만 정원 제한이 있고, 상시 모집은 인원 제한이 없다. */ diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventManageDetail.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventManageDetail.java new file mode 100644 index 00000000..42a757b1 --- /dev/null +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventManageDetail.java @@ -0,0 +1,28 @@ +package kr.ac.kookmin.stream.event.domain.event.domain; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 운영진 행사 상세. 수정 화면의 초기값으로 쓰므로 행사 전체와 신청서 질문을 그대로 담고, + * 조회 시점의 신청자 수와 모집 상태를 덧붙인다. + * + * @param questions 노출 순서대로 정렬된 신청서 질문 + */ +public record EventManageDetail( + Event event, + List questions, + long applicantCount, + RecruitStatus recruitStatus +) { + + public static EventManageDetail of(Event event, List questions, long applicantCount, + LocalDateTime now) { + return new EventManageDetail(event, questions, applicantCount, event.calculateRecruitStatus(now, applicantCount)); + } + + /** 행사 이미지 id. 이미지가 없으면 {@code null}이 아니라 빈 목록이다. */ + public List imageIds() { + return event.getImageIds() == null ? List.of() : event.getImageIds(); + } +} diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventManageSummary.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventManageSummary.java new file mode 100644 index 00000000..04527079 --- /dev/null +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventManageSummary.java @@ -0,0 +1,36 @@ +package kr.ac.kookmin.stream.event.domain.event.domain; + +import java.time.LocalDateTime; + +/** + * 운영진 행사 목록 한 건. 학생 목록({@link EventSummary})과 달리 비공개 행사도 담고, 신청자 수·정원·공개 여부를 함께 싣는다. + * 모집 상태는 저장값이 아니라 조회 시점 기준으로 계산한다. + * + * @param capacity 선착순 모집의 정원. 상시 모집은 인원 제한이 없어 {@code null} + */ +public record EventManageSummary( + Long eventId, + String title, + long applicantCount, + RecruitType recruitType, + Integer capacity, + RecruitStatus recruitStatus, + boolean published, + LocalDateTime applyStartAt, + LocalDateTime applyEndAt +) { + + public static EventManageSummary of(Event event, long applicantCount, LocalDateTime now) { + return new EventManageSummary( + event.getId(), + event.getTitle(), + applicantCount, + event.getRecruitType(), + event.capacityLimit(), + event.calculateRecruitStatus(now, applicantCount), + event.isPublished(), + event.getApplyStartAt(), + event.getApplyEndAt() + ); + } +} diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/repository/EventRepository.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/repository/EventRepository.java index b12caceb..cc90207b 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/repository/EventRepository.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/repository/EventRepository.java @@ -4,6 +4,8 @@ import java.util.List; import java.util.Map; import java.util.Optional; +import kr.ac.kookmin.stream.common.PageOffset; +import kr.ac.kookmin.stream.common.PageResult; import kr.ac.kookmin.stream.event.domain.event.domain.Event; import kr.ac.kookmin.stream.event.domain.event.domain.EventApplication; import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationAnswer; @@ -20,6 +22,13 @@ public interface EventRepository { List saveQuestions(List questions); + /** + * 삭제되지 않은 행사를 공개 여부와 상관없이 최근 등록 순(eventId 내림차순)으로 페이지 조회한다. 운영진 목록용이다. + * + * @param keyword 제목 부분 일치 검색어. {@code null}이면 거르지 않는다 + */ + PageResult findPage(String keyword, PageOffset pageOffset); + /** * 주어진 식별자의 행사를 조회한다. 삭제 여부로 거르지 않는다. *

diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/EventService.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/EventService.java index c1214c53..c8ec37e5 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/EventService.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/EventService.java @@ -1,10 +1,14 @@ package kr.ac.kookmin.stream.event.domain.event.service; import kr.ac.kookmin.stream.common.CursorSliceResult; +import kr.ac.kookmin.stream.common.PageOffset; +import kr.ac.kookmin.stream.common.PageResult; import kr.ac.kookmin.stream.event.domain.event.domain.Event; import kr.ac.kookmin.stream.event.domain.event.domain.EventCreateCommand; import kr.ac.kookmin.stream.event.domain.event.domain.EventCursor; import kr.ac.kookmin.stream.event.domain.event.domain.EventDetail; +import kr.ac.kookmin.stream.event.domain.event.domain.EventManageDetail; +import kr.ac.kookmin.stream.event.domain.event.domain.EventManageSummary; import kr.ac.kookmin.stream.event.domain.event.domain.EventSummary; import kr.ac.kookmin.stream.event.domain.event.domain.RecruitStatus; @@ -19,4 +23,14 @@ public interface EventService { * 운영진이 행사를 등록한다. 신청서 질문도 함께 저장하며, 등록한 행사는 비공개로 시작한다. */ Event create(Long createdBy, EventCreateCommand command); + + /** + * 운영진 행사 목록. 비공개 행사도 담는다. + * + * @param keyword 제목 부분 일치 검색어. {@code null}이면 거르지 않는다 + */ + PageResult getEvents(String keyword, PageOffset pageOffset); + + /** 운영진 행사 상세. 비공개 행사도 조회한다. */ + EventManageDetail getEvent(Long eventId); } diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImpl.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImpl.java index 90ef1f7e..56b84516 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImpl.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImpl.java @@ -6,11 +6,15 @@ import java.util.stream.IntStream; import kr.ac.kookmin.stream.common.BusinessException; import kr.ac.kookmin.stream.common.CursorSliceResult; +import kr.ac.kookmin.stream.common.PageOffset; +import kr.ac.kookmin.stream.common.PageResult; import kr.ac.kookmin.stream.event.domain.event.domain.Event; import kr.ac.kookmin.stream.event.domain.event.domain.EventCreateCommand; import kr.ac.kookmin.stream.event.domain.event.domain.EventCursor; import kr.ac.kookmin.stream.event.domain.event.domain.EventDetail; import kr.ac.kookmin.stream.event.domain.event.domain.EventErrorCode; +import kr.ac.kookmin.stream.event.domain.event.domain.EventManageDetail; +import kr.ac.kookmin.stream.event.domain.event.domain.EventManageSummary; import kr.ac.kookmin.stream.event.domain.event.domain.EventQuestion; import kr.ac.kookmin.stream.event.domain.event.domain.EventQuestionCommand; import kr.ac.kookmin.stream.event.domain.event.domain.EventSummary; @@ -72,6 +76,36 @@ public Event create(Long createdBy, EventCreateCommand command) { return event; } + @Override + @Transactional(readOnly = true) + public PageResult getEvents(String keyword, PageOffset pageOffset) { + PageResult page = eventRepository.findPage(keyword, pageOffset); + + // 신청자 수는 한 번에 모아 조회한다. 행사마다 따로 세면 페이지 크기만큼 쿼리가 더 나간다 + Map applicantCounts = eventRepository.countAppliedByEventIds( + page.content().stream().map(Event::getId).toList()); + LocalDateTime now = LocalDateTime.now(); + + return page.map(event -> EventManageSummary.of(event, applicantCounts.getOrDefault(event.getId(), 0L), now)); + } + + @Override + @Transactional(readOnly = true) + public EventManageDetail getEvent(Long eventId) { + Event event = getEventOrThrow(eventId); + return EventManageDetail.of( + event, + eventRepository.findQuestionsByEventId(eventId), + eventRepository.countAppliedByEventId(eventId), + LocalDateTime.now() + ); + } + + private Event getEventOrThrow(Long eventId) { + return eventRepository.findById(eventId) + .orElseThrow(() -> new BusinessException(EventErrorCode.EVENT_NOT_FOUND)); + } + // 신청서에 보이는 순서는 요청 목록의 순서를 따른다 private static List toQuestions(Long eventId, List commands) { return IntStream.range(0, commands.size()) diff --git a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImplTest.java b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImplTest.java index 630a4f7f..5ad27990 100644 --- a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImplTest.java +++ b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImplTest.java @@ -10,6 +10,8 @@ import java.util.Map; import java.util.Optional; import kr.ac.kookmin.stream.common.CursorSliceResult; +import kr.ac.kookmin.stream.common.PageOffset; +import kr.ac.kookmin.stream.common.PageResult; import kr.ac.kookmin.stream.event.domain.event.domain.Event; import kr.ac.kookmin.stream.event.domain.event.domain.EventApplication; import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationAnswer; @@ -172,6 +174,11 @@ public List saveQuestions(List questions) { throw new UnsupportedOperationException(); } + @Override + public PageResult findPage(String keyword, PageOffset pageOffset) { + throw new UnsupportedOperationException(); + } + @Override public Optional findPublishedById(Long id) { throw new UnsupportedOperationException(); diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventJpaRepository.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventJpaRepository.java index 79d81881..4dff1a62 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventJpaRepository.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventJpaRepository.java @@ -5,6 +5,7 @@ import java.util.Optional; import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationStatus; import kr.ac.kookmin.stream.event.domain.event.domain.RecruitType; +import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; @@ -16,6 +17,24 @@ public interface EventJpaRepository extends JpaRepository Optional findByIdAndIsDeletedFalseAndIsPublishedTrue(Long id); + /** + * 삭제되지 않은 행사를 공개 여부와 상관없이 최근 등록 순으로 페이지 조회한다. 운영진 목록용이다. + */ + @Query( + value = """ + SELECT e FROM EventJpaEntity e + WHERE e.isDeleted = false + AND (:keyword IS NULL OR e.title LIKE CONCAT('%', :keyword, '%')) + ORDER BY e.id DESC + """, + countQuery = """ + SELECT COUNT(e) FROM EventJpaEntity e + WHERE e.isDeleted = false + AND (:keyword IS NULL OR e.title LIKE CONCAT('%', :keyword, '%')) + """ + ) + Page search(@Param("keyword") String keyword, Pageable pageable); + /** * 게시된 행사를 (event_start_at, event_id) keyset 커서로 조회한다. *

diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventRepositoryImpl.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventRepositoryImpl.java index 326a0275..e17bb66f 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventRepositoryImpl.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventRepositoryImpl.java @@ -5,6 +5,8 @@ import java.util.Map; import java.util.Optional; import java.util.stream.Collectors; +import kr.ac.kookmin.stream.common.PageOffset; +import kr.ac.kookmin.stream.common.PageResult; import kr.ac.kookmin.stream.event.domain.event.domain.Event; import kr.ac.kookmin.stream.event.domain.event.domain.EventApplication; import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationAnswer; @@ -16,6 +18,8 @@ import kr.ac.kookmin.stream.event.domain.event.domain.RecruitType; import kr.ac.kookmin.stream.event.domain.event.repository.EventRepository; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Repository; @@ -43,6 +47,20 @@ public List saveQuestions(List questions) { .toList(); } + @Override + public PageResult findPage(String keyword, PageOffset pageOffset) { + Page result = eventJpaRepository.search( + keyword, PageRequest.of(pageOffset.page(), pageOffset.size()) + ); + + return PageResult.of( + result.getContent().stream().map(EventJpaEntity::toDomain).toList(), + result.getNumber(), + result.getSize(), + result.getTotalElements() + ); + } + @Override public Optional findById(Long id) { return eventJpaRepository.findByIdAndIsDeletedFalse(id).map(EventJpaEntity::toDomain); From a14633987f7620b406e2b42383b6b09b397ee227 Mon Sep 17 00:00:00 2001 From: gain <163835628+leegain1@users.noreply.github.com> Date: Sun, 18 Oct 2026 00:36:03 +0900 Subject: [PATCH 04/19] =?UTF-8?q?feat:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=ED=96=89=EC=82=AC=20=EC=88=98=EC=A0=95=20API=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/admin/event/event/AdminEventApi.java | 18 ++++ .../event/event/AdminEventController.java | 14 +++ .../event/request/EventUpdateRequest.java | 94 +++++++++++++++++++ .../response/AdminEventDetailResponse.java | 10 +- .../event/response/EventUpdateResponse.java | 10 ++ .../event/usecase/AdminEventWriteUseCase.java | 6 ++ .../event/domain/event/domain/Event.java | 28 +++++- .../domain/event/domain/EventErrorCode.java | 5 +- .../domain/event/domain/EventQuestion.java | 13 +++ .../event/domain/EventUpdateCommand.java | 25 +++++ .../event/repository/EventRepository.java | 8 ++ .../domain/event/service/EventService.java | 8 ++ .../event/service/impl/EventServiceImpl.java | 48 ++++++++++ .../service/impl/EventWriteValidator.java | 8 ++ .../domain/EventApplicationCursorTest.java | 2 +- .../domain/EventApplicationDetailTest.java | 2 +- .../domain/event/domain/EventCursorTest.java | 2 +- .../domain/event/domain/EventDetailTest.java | 2 +- .../domain/event/domain/EventSummaryTest.java | 2 +- .../event/domain/event/domain/EventTest.java | 2 +- .../service/impl/EventServiceImplTest.java | 16 +++- .../event/EventApplicationJpaRepository.java | 2 + .../stream/db/event/EventJpaEntity.java | 2 +- .../db/event/EventQuestionJpaRepository.java | 7 ++ .../stream/db/event/EventRepositoryImpl.java | 11 +++ 25 files changed, 328 insertions(+), 17 deletions(-) create mode 100644 api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/request/EventUpdateRequest.java create mode 100644 api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/response/EventUpdateResponse.java create mode 100644 core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventUpdateCommand.java diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/AdminEventApi.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/AdminEventApi.java index ce01b283..b2368aef 100644 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/AdminEventApi.java +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/AdminEventApi.java @@ -5,9 +5,11 @@ import kr.ac.kookmin.stream.api.admin.AdminApiUser; import kr.ac.kookmin.stream.api.admin.event.event.request.EventCreateRequest; import kr.ac.kookmin.stream.api.admin.event.event.request.EventSearchParams; +import kr.ac.kookmin.stream.api.admin.event.event.request.EventUpdateRequest; import kr.ac.kookmin.stream.api.admin.event.event.response.AdminEventDetailResponse; import kr.ac.kookmin.stream.api.admin.event.event.response.AdminEventListItemResponse; import kr.ac.kookmin.stream.api.admin.event.event.response.EventCreateResponse; +import kr.ac.kookmin.stream.api.admin.event.event.response.EventUpdateResponse; import kr.ac.kookmin.stream.api.common.dto.ApiResponse; import kr.ac.kookmin.stream.api.common.dto.PageParams; import kr.ac.kookmin.stream.api.common.dto.PageResponse; @@ -53,4 +55,20 @@ ApiResponse> getEvents( description = "행사 정보와 신청서 질문 전체를 조회한다. 비공개 행사도 조회할 수 있다.") @ApiErrorCode(type = EventErrorCode.class, codes = {"EVENT_NOT_FOUND"}) ApiResponse getEvent(AdminApiUser apiUser, Long eventId); + + /** 행사 수정. 요청의 수정 시각이 저장된 값과 다르면 거부한다. */ + @Operation(summary = "행사 수정", + description = "행사 내용과 신청서 질문을 통째로 바꾼다. 수정 화면이 행사를 읽은 뒤 다른 관리자가 먼저 고쳤다면" + + "(수정 시각 불일치) 409로 거부한다. 신청 내역(취소 포함)이 있는 행사는 질문을 바꿀 수 없고, " + + "선착순 정원은 현재 신청자 수보다 줄일 수 없다. 공개 여부는 바꾸지 않는다.") + @ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"}) + @ApiErrorCode( + type = EventErrorCode.class, + codes = { + "EVENT_NOT_FOUND", "EVENT_INVALID_PERIOD", "EVENT_INVALID_CAPACITY", "EVENT_INVALID_QUESTION", + "EVENT_QUESTIONS_LOCKED", "EVENT_CAPACITY_BELOW_APPLICANTS", "EVENT_UPDATE_CONFLICT" + } + ) + @ApiErrorCode(type = FileErrorCode.class, codes = {"FILE_NOT_FOUND"}) + ApiResponse updateEvent(AdminApiUser apiUser, Long eventId, EventUpdateRequest request); } diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/AdminEventController.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/AdminEventController.java index 0cad3621..3ce4c469 100644 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/AdminEventController.java +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/AdminEventController.java @@ -5,9 +5,11 @@ import kr.ac.kookmin.stream.api.admin.AdminApiUser; import kr.ac.kookmin.stream.api.admin.event.event.request.EventCreateRequest; import kr.ac.kookmin.stream.api.admin.event.event.request.EventSearchParams; +import kr.ac.kookmin.stream.api.admin.event.event.request.EventUpdateRequest; import kr.ac.kookmin.stream.api.admin.event.event.response.AdminEventDetailResponse; import kr.ac.kookmin.stream.api.admin.event.event.response.AdminEventListItemResponse; import kr.ac.kookmin.stream.api.admin.event.event.response.EventCreateResponse; +import kr.ac.kookmin.stream.api.admin.event.event.response.EventUpdateResponse; import kr.ac.kookmin.stream.api.admin.event.event.usecase.AdminEventWriteUseCase; import kr.ac.kookmin.stream.api.common.dto.ApiResponse; import kr.ac.kookmin.stream.api.common.dto.PageParams; @@ -22,6 +24,7 @@ import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -64,4 +67,15 @@ public ApiResponse getEvent(AdminApiUser apiUser, @Pat return ApiResponse.success(AdminEventDetailResponse.from(detail, imagesById)); } + + @Override + @PutMapping("/{eventId}") + public ApiResponse updateEvent( + AdminApiUser apiUser, + @PathVariable Long eventId, + @Valid @RequestBody EventUpdateRequest request + ) { + Event event = adminEventWriteUseCase.update(eventId, request.toCommand()); + return ApiResponse.success(EventUpdateResponse.from(event)); + } } diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/request/EventUpdateRequest.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/request/EventUpdateRequest.java new file mode 100644 index 00000000..8a0722a9 --- /dev/null +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/request/EventUpdateRequest.java @@ -0,0 +1,94 @@ +package kr.ac.kookmin.stream.api.admin.event.event.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import java.time.Instant; +import java.time.LocalDateTime; +import java.util.List; +import kr.ac.kookmin.stream.common.DateUtil; +import kr.ac.kookmin.stream.event.domain.event.domain.EventUpdateCommand; +import kr.ac.kookmin.stream.event.domain.event.domain.RecruitType; + +/** + * 공개 여부는 받지 않는다. 공개 전환은 별도 API가 맡는다. + */ +public record EventUpdateRequest( + @Schema(description = "행사 제목", example = "체육대회") + @NotBlank(message = "행사 제목을 입력해 주세요.") + @Size(max = 200, message = "행사 제목은 200자 이하로 입력해 주세요.") + String title, + + // description은 TEXT 컬럼(최대 65,535바이트)이다. 글자당 최대 4바이트로 잡아도 넘치지 않는 길이로 막는다 + @Schema(description = "행사 소개글", example = "학과 대항 체육대회입니다.") + @NotBlank(message = "행사 소개글을 입력해 주세요.") + @Size(max = 16000, message = "행사 소개글은 16000자 이하로 입력해 주세요.") + String description, + + @Schema(description = "행사 장소", example = "종합운동장") + @Size(max = 255, message = "행사 장소는 255자 이하로 입력해 주세요.") + String place, + + @Schema(description = "행사 시작 일시", example = "2026-11-01T10:00:00") + @NotNull(message = "행사 시작 일시를 입력해 주세요.") + LocalDateTime eventStartAt, + + @Schema(description = "행사 종료 일시. 시작 일시보다 늦어야 한다", example = "2026-11-01T18:00:00") + @NotNull(message = "행사 종료 일시를 입력해 주세요.") + LocalDateTime eventEndAt, + + @Schema(description = "신청 시작 일시", example = "2026-10-14T10:00:00") + @NotNull(message = "신청 시작 일시를 입력해 주세요.") + LocalDateTime applyStartAt, + + @Schema(description = "신청 마감 일시. 시작 일시보다 늦어야 한다", example = "2026-10-25T23:59:59") + @NotNull(message = "신청 마감 일시를 입력해 주세요.") + LocalDateTime applyEndAt, + + @Schema(description = "모집 방식. 선착순이면 FIRST_COME, 인원 제한이 없으면 OPEN") + @NotNull(message = "모집 방식을 입력해 주세요.") + RecruitType recruitType, + + @Schema(description = "모집 정원. FIRST_COME이면 1 이상 필요하고, OPEN이면 무시한다", example = "100") + Integer capacity, + + @Schema(description = "학생회비 납부자만 신청 가능 여부") + @NotNull(message = "학생회비 납부자 전용 여부를 입력해 주세요.") + Boolean feePaidOnly, + + @Schema(description = "행사 이미지 파일 ID 목록. 첫 번째가 대표 이미지가 된다", example = "[31, 32]") + @NotNull(message = "이미지 파일 목록을 입력해 주세요.") + @Size(max = 10, message = "이미지는 10장까지 등록할 수 있습니다.") + List<@NotNull(message = "이미지 파일 ID는 비워 둘 수 없습니다.") Long> imageIds, + + @Schema(description = "신청서 질문 목록. 목록 순서대로 신청서에 노출된다") + @NotNull(message = "신청서 질문 목록을 입력해 주세요.") + @Valid + List<@NotNull(message = "신청서 질문이 비어 있습니다.") EventQuestionRequest> questions, + + @Schema(description = "수정 화면이 행사를 읽었을 때의 수정 시각(KST 기준 epoch 밀리초). 그 사이 다른 관리자가 고쳤다면 409", + example = "1756339200000") + @NotNull(message = "수정 시각을 입력해 주세요.") + Long updatedAt +) { + + public EventUpdateCommand toCommand() { + return new EventUpdateCommand( + title, + description, + place, + eventStartAt, + eventEndAt, + applyStartAt, + applyEndAt, + recruitType, + capacity, + feePaidOnly, + imageIds.stream().distinct().toList(), + questions.stream().map(EventQuestionRequest::toCommand).toList(), + LocalDateTime.ofInstant(Instant.ofEpochMilli(updatedAt), DateUtil.KST) + ); + } +} diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/response/AdminEventDetailResponse.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/response/AdminEventDetailResponse.java index ea781078..87366c2f 100644 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/response/AdminEventDetailResponse.java +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/response/AdminEventDetailResponse.java @@ -4,6 +4,7 @@ import java.util.List; import java.util.Map; import kr.ac.kookmin.stream.api.common.StorageUrlBuilder; +import kr.ac.kookmin.stream.common.DateUtil; import kr.ac.kookmin.stream.event.domain.event.domain.Event; import kr.ac.kookmin.stream.event.domain.event.domain.EventManageDetail; import kr.ac.kookmin.stream.event.domain.event.domain.EventQuestion; @@ -15,7 +16,8 @@ /** * 수정 화면의 초기값. 필드 이름은 등록·수정 요청 본문과 맞춘다. * - * @param capacity 선착순 모집의 정원. 상시 모집이면 {@code null} + * @param capacity 선착순 모집의 정원. 상시 모집이면 {@code null} + * @param updatedAt 마지막 수정 시각(KST 기준 epoch 밀리초). 수정 요청에 그대로 돌려보내 동시 수정을 감지한다 */ public record AdminEventDetailResponse( Long eventId, @@ -33,7 +35,8 @@ public record AdminEventDetailResponse( RecruitStatus recruitStatus, boolean isPublished, List images, - List questions + List questions, + long updatedAt ) { /** @@ -60,7 +63,8 @@ public static AdminEventDetailResponse from(EventManageDetail detail, Map Image.of(fileId, imagesById.get(fileId))) .toList(), - detail.questions().stream().map(Question::from).toList() + detail.questions().stream().map(Question::from).toList(), + event.getUpdatedAt().atZone(DateUtil.KST).toInstant().toEpochMilli() ); } diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/response/EventUpdateResponse.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/response/EventUpdateResponse.java new file mode 100644 index 00000000..93cc991d --- /dev/null +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/response/EventUpdateResponse.java @@ -0,0 +1,10 @@ +package kr.ac.kookmin.stream.api.admin.event.event.response; + +import kr.ac.kookmin.stream.event.domain.event.domain.Event; + +public record EventUpdateResponse(Long eventId) { + + public static EventUpdateResponse from(Event event) { + return new EventUpdateResponse(event.getId()); + } +} diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/usecase/AdminEventWriteUseCase.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/usecase/AdminEventWriteUseCase.java index 0deb52b0..0ca98572 100644 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/usecase/AdminEventWriteUseCase.java +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/usecase/AdminEventWriteUseCase.java @@ -4,6 +4,7 @@ import kr.ac.kookmin.stream.common.BusinessException; import kr.ac.kookmin.stream.event.domain.event.domain.Event; import kr.ac.kookmin.stream.event.domain.event.domain.EventCreateCommand; +import kr.ac.kookmin.stream.event.domain.event.domain.EventUpdateCommand; import kr.ac.kookmin.stream.event.domain.event.service.EventService; import kr.ac.kookmin.stream.file.domain.FileErrorCode; import kr.ac.kookmin.stream.file.service.FileService; @@ -29,6 +30,11 @@ public Event create(Long createdBy, EventCreateCommand command) { return eventService.create(createdBy, command); } + public Event update(Long eventId, EventUpdateCommand command) { + requireImagesExist(command.imageIds()); + return eventService.update(eventId, command); + } + // 요청 DTO가 중복을 걸러 넘기므로 조회 결과 개수와 바로 비교할 수 있다 private void requireImagesExist(List imageIds) { if (fileService.findAllByIdIn(imageIds).size() != imageIds.size()) { diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/Event.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/Event.java index 7d0b178e..6ab5f3e1 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/Event.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/Event.java @@ -28,6 +28,8 @@ public class Event { private boolean feePaidOnly; private boolean published; private Long createdBy; + /** 마지막 수정 시각. DB가 채우며, 운영진 동시 수정 감지에 쓴다 */ + private LocalDateTime updatedAt; public static Event of( Long id, @@ -43,16 +45,18 @@ public static Event of( int capacity, boolean feePaidOnly, boolean published, - Long createdBy + Long createdBy, + LocalDateTime updatedAt ) { return new Event( id, title, description, place, eventStartAt, eventEndAt, applyStartAt, - applyEndAt, recruitType, imageIds, capacity, feePaidOnly, published, createdBy + applyEndAt, recruitType, imageIds, capacity, feePaidOnly, published, createdBy, updatedAt ); } /** * 새 행사를 만든다. 학생에게 바로 보이지 않도록 비공개로 시작한다. 상시 모집은 정원이 의미 없어 0으로 둔다. + * 수정 시각은 DB가 채운다. */ public static Event create(Long createdBy, EventCreateCommand command) { return new Event( @@ -69,10 +73,28 @@ public static Event create(Long createdBy, EventCreateCommand command) { capacityOf(command.recruitType(), command.capacity()), command.feePaidOnly(), false, - createdBy + createdBy, + null ); } + /** + * 운영진이 입력한 값으로 행사 내용을 통째로 바꾼다. 공개 여부·작성자는 그대로 둔다. + */ + public void update(EventUpdateCommand command) { + this.title = command.title(); + this.description = command.description(); + this.place = command.place(); + this.eventStartAt = command.eventStartAt(); + this.eventEndAt = command.eventEndAt(); + this.applyStartAt = command.applyStartAt(); + this.applyEndAt = command.applyEndAt(); + this.recruitType = command.recruitType(); + this.imageIds = command.imageIds(); + this.capacity = capacityOf(command.recruitType(), command.capacity()); + this.feePaidOnly = command.feePaidOnly(); + } + /** * 현재 시각과 신청자 수를 반영한 모집 상태를 계산한다. *

diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventErrorCode.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventErrorCode.java index 98e1454b..f978529e 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventErrorCode.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventErrorCode.java @@ -23,7 +23,10 @@ public enum EventErrorCode implements ErrorCode { EVENT_INVALID_RECRUIT_STATUS(ErrorStatus.BAD_REQUEST, "유효하지 않은 모집 상태입니다."), EVENT_INVALID_PERIOD(ErrorStatus.BAD_REQUEST, "종료 일시는 시작 일시보다 늦어야 합니다."), EVENT_INVALID_CAPACITY(ErrorStatus.BAD_REQUEST, "선착순 모집은 1명 이상의 정원을 입력해야 합니다."), - EVENT_INVALID_QUESTION(ErrorStatus.BAD_REQUEST, "선택형 질문에는 선택지를 1개 이상 입력해야 합니다."); + EVENT_INVALID_QUESTION(ErrorStatus.BAD_REQUEST, "선택형 질문에는 선택지를 1개 이상 입력해야 합니다."), + EVENT_QUESTIONS_LOCKED(ErrorStatus.CONFLICT, "신청 내역이 있는 행사는 신청서 질문을 수정할 수 없습니다."), + EVENT_CAPACITY_BELOW_APPLICANTS(ErrorStatus.CONFLICT, "정원을 현재 신청자 수보다 적게 줄일 수 없습니다."), + EVENT_UPDATE_CONFLICT(ErrorStatus.CONFLICT, "다른 관리자가 먼저 행사를 수정했습니다. 새로고침한 뒤 다시 수정해 주세요."); private final int status; private final String message; diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventQuestion.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventQuestion.java index 34481a4e..34dcbd72 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventQuestion.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventQuestion.java @@ -1,6 +1,7 @@ package kr.ac.kookmin.stream.event.domain.event.domain; import java.util.List; +import java.util.Objects; import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.EqualsAndHashCode; @@ -54,4 +55,16 @@ public static EventQuestion create(Long eventId, int displayOrder, EventQuestion choice && command.otherOptionAllowed() ); } + + /** + * 신청서에 보이는 내용이 같은지. 식별자·소속 행사·노출 순서 값은 보지 않는다(순서는 호출부가 목록 위치로 비교한다). + * 행사 수정 때 질문이 실제로 바뀌었는지 판단한다. + */ + public boolean hasSameContentAs(EventQuestion other) { + return questionType == other.questionType + && required == other.required + && otherOptionAllowed == other.otherOptionAllowed + && Objects.equals(questionText, other.questionText) + && Objects.equals(options, other.options); + } } diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventUpdateCommand.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventUpdateCommand.java new file mode 100644 index 00000000..31ea49c9 --- /dev/null +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventUpdateCommand.java @@ -0,0 +1,25 @@ +package kr.ac.kookmin.stream.event.domain.event.domain; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * @param capacity 선착순 모집의 정원. 상시 모집이면 쓰이지 않는다 + * @param expectedUpdatedAt 수정 화면이 행사를 읽었을 때의 수정 시각. 그 사이 다른 운영진이 고쳤는지 확인한다 + */ +public record EventUpdateCommand( + String title, + String description, + String place, + LocalDateTime eventStartAt, + LocalDateTime eventEndAt, + LocalDateTime applyStartAt, + LocalDateTime applyEndAt, + RecruitType recruitType, + Integer capacity, + boolean feePaidOnly, + List imageIds, + List questions, + LocalDateTime expectedUpdatedAt +) { +} diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/repository/EventRepository.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/repository/EventRepository.java index cc90207b..f75feddb 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/repository/EventRepository.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/repository/EventRepository.java @@ -22,6 +22,14 @@ public interface EventRepository { List saveQuestions(List questions); + /** 행사의 신청서 질문을 모두 지우고 주어진 질문으로 바꾼다. */ + List replaceQuestions(Long eventId, List questions); + + /** + * 행사에 신청이 한 건이라도 있는지. 취소된 신청도 센다 — 취소돼도 답변이 질문 id·선택지 순서를 참조한다. + */ + boolean existsApplicationByEventId(Long eventId); + /** * 삭제되지 않은 행사를 공개 여부와 상관없이 최근 등록 순(eventId 내림차순)으로 페이지 조회한다. 운영진 목록용이다. * diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/EventService.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/EventService.java index c8ec37e5..c03767a5 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/EventService.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/EventService.java @@ -10,6 +10,7 @@ import kr.ac.kookmin.stream.event.domain.event.domain.EventManageDetail; import kr.ac.kookmin.stream.event.domain.event.domain.EventManageSummary; import kr.ac.kookmin.stream.event.domain.event.domain.EventSummary; +import kr.ac.kookmin.stream.event.domain.event.domain.EventUpdateCommand; import kr.ac.kookmin.stream.event.domain.event.domain.RecruitStatus; /** 행사 자체의 조회와 운영진의 등록·수정. 신청·취소는 {@link EventApplicationService}가 맡는다. */ @@ -33,4 +34,11 @@ public interface EventService { /** 운영진 행사 상세. 비공개 행사도 조회한다. */ EventManageDetail getEvent(Long eventId); + + /** + * 운영진이 행사 내용을 통째로 바꾼다. 수정 화면이 읽은 뒤 다른 운영진이 먼저 고쳤다면 + * {@code EVENT_UPDATE_CONFLICT}로 거부한다. 신청이 있는 행사는 신청서 질문을 바꿀 수 없고, + * 선착순 정원을 현재 신청자 수보다 줄일 수 없다. + */ + Event update(Long eventId, EventUpdateCommand command); } diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImpl.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImpl.java index 56b84516..9b9f112d 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImpl.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImpl.java @@ -1,6 +1,7 @@ package kr.ac.kookmin.stream.event.domain.event.service.impl; import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; import java.util.List; import java.util.Map; import java.util.stream.IntStream; @@ -18,7 +19,9 @@ import kr.ac.kookmin.stream.event.domain.event.domain.EventQuestion; import kr.ac.kookmin.stream.event.domain.event.domain.EventQuestionCommand; import kr.ac.kookmin.stream.event.domain.event.domain.EventSummary; +import kr.ac.kookmin.stream.event.domain.event.domain.EventUpdateCommand; import kr.ac.kookmin.stream.event.domain.event.domain.RecruitStatus; +import kr.ac.kookmin.stream.event.domain.event.domain.RecruitType; import kr.ac.kookmin.stream.event.domain.event.repository.EventRepository; import kr.ac.kookmin.stream.event.domain.event.service.EventService; import lombok.RequiredArgsConstructor; @@ -101,11 +104,56 @@ public EventManageDetail getEvent(Long eventId) { ); } + @Override + @Transactional + public Event update(Long eventId, EventUpdateCommand command) { + Event event = getEventOrThrow(eventId); + validateNotModified(event, command.expectedUpdatedAt()); + eventWriteValidator.validate(command); + validateCapacityNotBelowApplicants(eventId, command); + + List questions = toQuestions(eventId, command.questions()); + if (questionsChanged(eventRepository.findQuestionsByEventId(eventId), questions)) { + // 답변이 질문 id·선택지 순서로 저장돼 있어 신청이 생긴 뒤 바꾸면 기존 답변의 뜻이 달라진다 + if (eventRepository.existsApplicationByEventId(eventId)) { + throw new BusinessException(EventErrorCode.EVENT_QUESTIONS_LOCKED); + } + eventRepository.replaceQuestions(eventId, questions); + } + + event.update(command); + return eventRepository.save(event); + } + private Event getEventOrThrow(Long eventId) { return eventRepository.findById(eventId) .orElseThrow(() -> new BusinessException(EventErrorCode.EVENT_NOT_FOUND)); } + // updated_at이 초 단위 DATETIME이라 같은 초로 맞춰 비교한다. 수정 화면이 읽은 뒤 고쳐졌다면 시각이 달라진다 + private void validateNotModified(Event event, LocalDateTime expectedUpdatedAt) { + LocalDateTime current = event.getUpdatedAt().truncatedTo(ChronoUnit.SECONDS); + if (!current.equals(expectedUpdatedAt.truncatedTo(ChronoUnit.SECONDS))) { + throw new BusinessException(EventErrorCode.EVENT_UPDATE_CONFLICT); + } + } + + // 이미 받은 신청을 정원 밖으로 밀어낼 수는 없다. 상시 모집은 인원 제한이 없어 보지 않는다 + private void validateCapacityNotBelowApplicants(Long eventId, EventUpdateCommand command) { + if (command.recruitType() == RecruitType.FIRST_COME + && command.capacity() < eventRepository.countAppliedByEventId(eventId)) { + throw new BusinessException(EventErrorCode.EVENT_CAPACITY_BELOW_APPLICANTS); + } + } + + private static boolean questionsChanged(List current, List requested) { + if (current.size() != requested.size()) { + return true; + } + return IntStream.range(0, current.size()) + .anyMatch(index -> !current.get(index).hasSameContentAs(requested.get(index))); + } + // 신청서에 보이는 순서는 요청 목록의 순서를 따른다 private static List toQuestions(Long eventId, List commands) { return IntStream.range(0, commands.size()) diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventWriteValidator.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventWriteValidator.java index 4a224051..938c5547 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventWriteValidator.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventWriteValidator.java @@ -6,6 +6,7 @@ import kr.ac.kookmin.stream.event.domain.event.domain.EventCreateCommand; import kr.ac.kookmin.stream.event.domain.event.domain.EventErrorCode; import kr.ac.kookmin.stream.event.domain.event.domain.EventQuestionCommand; +import kr.ac.kookmin.stream.event.domain.event.domain.EventUpdateCommand; import kr.ac.kookmin.stream.event.domain.event.domain.RecruitType; import org.springframework.stereotype.Component; @@ -23,6 +24,13 @@ void validate(EventCreateCommand command) { validateQuestions(command.questions()); } + void validate(EventUpdateCommand command) { + validatePeriod(command.eventStartAt(), command.eventEndAt()); + validatePeriod(command.applyStartAt(), command.applyEndAt()); + validateCapacity(command.recruitType(), command.capacity()); + validateQuestions(command.questions()); + } + private void validatePeriod(LocalDateTime startAt, LocalDateTime endAt) { if (!endAt.isAfter(startAt)) { throw new BusinessException(EventErrorCode.EVENT_INVALID_PERIOD); diff --git a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventApplicationCursorTest.java b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventApplicationCursorTest.java index 4c1f878d..3967763c 100644 --- a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventApplicationCursorTest.java +++ b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventApplicationCursorTest.java @@ -29,7 +29,7 @@ void fromSummary() { Event event = Event.of( 15L, "행사", "설명", "학생회관", APPLIED_AT.plusDays(30), APPLIED_AT.plusDays(31), APPLIED_AT.minusDays(7), APPLIED_AT.plusDays(3), - RecruitType.OPEN, List.of(), 0, false, true, 100L + RecruitType.OPEN, List.of(), 0, false, true, 100L, null ); EventApplication application = EventApplication.of( 102L, 15L, 1L, EventApplicationStatus.APPLIED, APPLIED_AT, null); diff --git a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventApplicationDetailTest.java b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventApplicationDetailTest.java index 614aa38b..c2c356f8 100644 --- a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventApplicationDetailTest.java +++ b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventApplicationDetailTest.java @@ -23,7 +23,7 @@ private static Event event() { return Event.of( 15L, "동문패널톡", "설명", "미래관 419호", APPLIED_AT.plusDays(30), APPLIED_AT.plusDays(31), APPLIED_AT.minusDays(7), APPLIED_AT.plusDays(3), - RecruitType.OPEN, List.of(7L), 0, false, true, 100L + RecruitType.OPEN, List.of(7L), 0, false, true, 100L, null ); } diff --git a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventCursorTest.java b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventCursorTest.java index 7c90c1d7..8aed3345 100644 --- a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventCursorTest.java +++ b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventCursorTest.java @@ -29,7 +29,7 @@ void fromEvent() { Event event = Event.of( 42L, "행사", "설명", "학생회관", EVENT_START_AT, EVENT_START_AT.plusHours(2), EVENT_START_AT.minusDays(7), EVENT_START_AT.minusDays(1), - RecruitType.OPEN, List.of(), 0, false, true, 100L + RecruitType.OPEN, List.of(), 0, false, true, 100L, null ); assertEquals(new EventCursor(EVENT_START_AT, 42L), EventCursor.of(event)); diff --git a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventDetailTest.java b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventDetailTest.java index fb7a7ec0..ae397121 100644 --- a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventDetailTest.java +++ b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventDetailTest.java @@ -18,7 +18,7 @@ private static Event event(RecruitType recruitType, List imageIds) { return Event.of( 15L, "2026 소융대 개강 행사", "소융대 재학생을 위한 개강 행사입니다.", "101호", NOW.plusDays(10), NOW.plusDays(10).plusHours(2), NOW.minusDays(1), NOW.plusDays(3), - recruitType, imageIds, 30, false, true, 1L + recruitType, imageIds, 30, false, true, 1L, null ); } diff --git a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventSummaryTest.java b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventSummaryTest.java index 01fef67d..0d66b174 100644 --- a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventSummaryTest.java +++ b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventSummaryTest.java @@ -23,7 +23,7 @@ private static Event event( return Event.of( 1L, "행사", "설명", "학생회관", NOW.plusDays(30), NOW.plusDays(31), applyStartAt, applyEndAt, - recruitType, imageIds, capacity, false, true, 100L + recruitType, imageIds, capacity, false, true, 100L, null ); } diff --git a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventTest.java b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventTest.java index 493e1496..8a102ca7 100644 --- a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventTest.java +++ b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventTest.java @@ -22,7 +22,7 @@ private static Event event(RecruitType recruitType) { 1L, "2026 소융대 개강 행사", "설명", "101호", LocalDateTime.of(2026, 9, 15, 18, 0), LocalDateTime.of(2026, 9, 15, 20, 0), APPLY_START_AT, APPLY_END_AT, - recruitType, List.of(), CAPACITY, false, true, 1L + recruitType, List.of(), CAPACITY, false, true, 1L, null ); } diff --git a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImplTest.java b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImplTest.java index 5ad27990..3d12b664 100644 --- a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImplTest.java +++ b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImplTest.java @@ -36,7 +36,7 @@ private static Event event(Long id) { return Event.of( id, "행사 " + id, "설명", "101호", NOW.plusDays(10), NOW.plusDays(10).plusHours(2), NOW.minusDays(1), NOW.plusDays(3), - RecruitType.FIRST_COME, List.of(), 100, false, true, 1L + RecruitType.FIRST_COME, List.of(), 100, false, true, 1L, null ); } @@ -76,11 +76,11 @@ void appliesCountPerEvent() { Event full = Event.of( 1L, "정원 3명", "설명", "101호", NOW.plusDays(10), null, NOW.minusDays(1), NOW.plusDays(3), - RecruitType.FIRST_COME, List.of(), 3, false, true, 1L); + RecruitType.FIRST_COME, List.of(), 3, false, true, 1L, null); Event roomy = Event.of( 2L, "정원 100명", "설명", "101호", NOW.plusDays(10), null, NOW.minusDays(1), NOW.plusDays(3), - RecruitType.FIRST_COME, List.of(), 100, false, true, 1L); + RecruitType.FIRST_COME, List.of(), 100, false, true, 1L, null); FakeEventRepository repository = new FakeEventRepository( List.of(full, roomy), @@ -179,6 +179,16 @@ public PageResult findPage(String keyword, PageOffset pageOffset) { throw new UnsupportedOperationException(); } + @Override + public List replaceQuestions(Long eventId, List questions) { + throw new UnsupportedOperationException(); + } + + @Override + public boolean existsApplicationByEventId(Long eventId) { + throw new UnsupportedOperationException(); + } + @Override public Optional findPublishedById(Long id) { throw new UnsupportedOperationException(); diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventApplicationJpaRepository.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventApplicationJpaRepository.java index 863e98e1..ff405012 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventApplicationJpaRepository.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventApplicationJpaRepository.java @@ -13,6 +13,8 @@ public interface EventApplicationJpaRepository extends JpaRepository findByIdAndMemberId(Long id, Long memberId); diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventJpaEntity.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventJpaEntity.java index 80faadac..fef068c1 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventJpaEntity.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventJpaEntity.java @@ -99,6 +99,6 @@ public static EventJpaEntity from(Event event) { public Event toDomain() { return Event.of(id, title, description, place, eventStartAt, eventEndAt, applyStartAt, applyEndAt, recruitType, imageIds, capacity, feePaidOnly, - isPublished, createdBy); + isPublished, createdBy, getUpdatedAt()); } } diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventQuestionJpaRepository.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventQuestionJpaRepository.java index 5fea788b..d39e6f72 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventQuestionJpaRepository.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventQuestionJpaRepository.java @@ -2,8 +2,15 @@ import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface EventQuestionJpaRepository extends JpaRepository { List findAllByEventIdOrderByDisplayOrderAsc(Long eventId); + + @Modifying + @Query("DELETE FROM EventQuestionJpaEntity q WHERE q.eventId = :eventId") + void deleteAllByEventId(@Param("eventId") Long eventId); } diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventRepositoryImpl.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventRepositoryImpl.java index e17bb66f..4750e94c 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventRepositoryImpl.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventRepositoryImpl.java @@ -47,6 +47,17 @@ public List saveQuestions(List questions) { .toList(); } + @Override + public List replaceQuestions(Long eventId, List questions) { + eventQuestionJpaRepository.deleteAllByEventId(eventId); + return saveQuestions(questions); + } + + @Override + public boolean existsApplicationByEventId(Long eventId) { + return eventApplicationJpaRepository.existsByEventId(eventId); + } + @Override public PageResult findPage(String keyword, PageOffset pageOffset) { Page result = eventJpaRepository.search( From caf334d0fc08feab68d41d95e8e53ab5c6aaa918 Mon Sep 17 00:00:00 2001 From: gain <163835628+leegain1@users.noreply.github.com> Date: Sun, 18 Oct 2026 00:40:15 +0900 Subject: [PATCH 05/19] =?UTF-8?q?fix:=20=ED=96=89=EC=82=AC=20=EC=A7=88?= =?UTF-8?q?=EB=AC=B8=EB=A7=8C=20=EC=88=98=EC=A0=95=ED=95=B4=EB=8F=84=20?= =?UTF-8?q?=EC=88=98=EC=A0=95=20=EC=8B=9C=EA=B0=81=EC=9D=B4=20=EA=B0=B1?= =?UTF-8?q?=EC=8B=A0=EB=90=98=EB=8F=84=EB=A1=9D=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../event/domain/event/repository/EventRepository.java | 6 ++++++ .../event/domain/event/service/impl/EventServiceImpl.java | 1 + .../domain/event/service/impl/EventServiceImplTest.java | 5 +++++ .../kr/ac/kookmin/stream/db/event/EventJpaRepository.java | 6 ++++++ .../kr/ac/kookmin/stream/db/event/EventRepositoryImpl.java | 5 +++++ 5 files changed, 23 insertions(+) diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/repository/EventRepository.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/repository/EventRepository.java index f75feddb..fb496043 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/repository/EventRepository.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/repository/EventRepository.java @@ -30,6 +30,12 @@ public interface EventRepository { */ boolean existsApplicationByEventId(Long eventId); + /** + * 행사의 수정 시각을 지금으로 갱신한다. 신청서 질문만 바뀌면 events 행의 값이 그대로라 + * DB가 updated_at을 올리지 않으므로, 동시 수정 감지를 위해 직접 올린다. + */ + void touchUpdatedAt(Long eventId); + /** * 삭제되지 않은 행사를 공개 여부와 상관없이 최근 등록 순(eventId 내림차순)으로 페이지 조회한다. 운영진 목록용이다. * diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImpl.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImpl.java index 9b9f112d..c9c01b8e 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImpl.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImpl.java @@ -119,6 +119,7 @@ public Event update(Long eventId, EventUpdateCommand command) { throw new BusinessException(EventErrorCode.EVENT_QUESTIONS_LOCKED); } eventRepository.replaceQuestions(eventId, questions); + eventRepository.touchUpdatedAt(eventId); } event.update(command); diff --git a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImplTest.java b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImplTest.java index 3d12b664..dc7afcef 100644 --- a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImplTest.java +++ b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImplTest.java @@ -189,6 +189,11 @@ public boolean existsApplicationByEventId(Long eventId) { throw new UnsupportedOperationException(); } + @Override + public void touchUpdatedAt(Long eventId) { + throw new UnsupportedOperationException(); + } + @Override public Optional findPublishedById(Long id) { throw new UnsupportedOperationException(); diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventJpaRepository.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventJpaRepository.java index 4dff1a62..dfc8848b 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventJpaRepository.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventJpaRepository.java @@ -8,6 +8,7 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -35,6 +36,11 @@ SELECT COUNT(e) FROM EventJpaEntity e ) Page search(@Param("keyword") String keyword, Pageable pageable); + // updated_at은 엔티티에서 읽기 전용(updatable = false)이라 네이티브 쿼리로 직접 올린다 + @Modifying + @Query(value = "UPDATE events SET updated_at = CURRENT_TIMESTAMP WHERE event_id = :eventId", nativeQuery = true) + void touchUpdatedAt(@Param("eventId") Long eventId); + /** * 게시된 행사를 (event_start_at, event_id) keyset 커서로 조회한다. *

diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventRepositoryImpl.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventRepositoryImpl.java index 4750e94c..b90bcb3a 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventRepositoryImpl.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventRepositoryImpl.java @@ -58,6 +58,11 @@ public boolean existsApplicationByEventId(Long eventId) { return eventApplicationJpaRepository.existsByEventId(eventId); } + @Override + public void touchUpdatedAt(Long eventId) { + eventJpaRepository.touchUpdatedAt(eventId); + } + @Override public PageResult findPage(String keyword, PageOffset pageOffset) { Page result = eventJpaRepository.search( From 1a1c822956316dc813621d49c6338f2c99c56910 Mon Sep 17 00:00:00 2001 From: gain <163835628+leegain1@users.noreply.github.com> Date: Sun, 18 Oct 2026 00:48:31 +0900 Subject: [PATCH 06/19] =?UTF-8?q?refactor:=20=EA=B4=80=EB=A6=AC=EC=9E=90?= =?UTF-8?q?=20=ED=96=89=EC=82=AC=20=EB=AA=A9=EB=A1=9D=EC=97=90=EC=84=9C=20?= =?UTF-8?q?EventManageSummary=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EventManageSummary는 행사 필드를 목록 응답과 1:1로 복사만 하는 객체였다. 스스로 지키는 규칙이 없어 도메인에 둘 이유가 없고, 화면 필드가 늘 때마다 도메인과 응답 두 곳을 고쳐야 한다. 서비스는 행사 페이지(getEvents)와 신청자 수(getApplicantCounts)를 따로 돌려주고, 컨트롤러가 조합한다. 모집 상태는 응답을 만들 때 Event.calculateRecruitStatus로 계산한다. 상세의 EventManageDetail도 미리 계산한 모집 상태를 빼고 행사·질문·신청자 수 묶음만 남긴다. --- .../event/event/AdminEventController.java | 13 +++++-- .../response/AdminEventDetailResponse.java | 5 +-- .../response/AdminEventListItemResponse.java | 25 +++++++------ .../event/domain/EventManageDetail.java | 16 +++------ .../event/domain/EventManageSummary.java | 36 ------------------- .../domain/event/service/EventService.java | 12 +++++-- .../event/service/impl/EventServiceImpl.java | 21 +++++------ 7 files changed, 50 insertions(+), 78 deletions(-) delete mode 100644 core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventManageSummary.java diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/AdminEventController.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/AdminEventController.java index 3ce4c469..e6399076 100644 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/AdminEventController.java +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/AdminEventController.java @@ -1,6 +1,7 @@ package kr.ac.kookmin.stream.api.admin.event.event; import jakarta.validation.Valid; +import java.time.LocalDateTime; import java.util.Map; import kr.ac.kookmin.stream.api.admin.AdminApiUser; import kr.ac.kookmin.stream.api.admin.event.event.request.EventCreateRequest; @@ -15,6 +16,7 @@ import kr.ac.kookmin.stream.api.common.dto.PageParams; import kr.ac.kookmin.stream.api.common.dto.PageResponse; import kr.ac.kookmin.stream.event.domain.event.domain.Event; +import kr.ac.kookmin.stream.common.PageResult; import kr.ac.kookmin.stream.event.domain.event.domain.EventManageDetail; import kr.ac.kookmin.stream.event.domain.event.service.EventService; import kr.ac.kookmin.stream.file.domain.File; @@ -55,8 +57,13 @@ public ApiResponse> getEvents( @Valid @ModelAttribute EventSearchParams params, @Valid @ModelAttribute PageParams pageParams ) { - return ApiResponse.success(PageResponse.from( - eventService.getEvents(params.toKeyword(), pageParams.toOffset()), AdminEventListItemResponse::from)); + PageResult events = eventService.getEvents(params.toKeyword(), pageParams.toOffset()); + Map applicantCounts = eventService.getApplicantCounts( + events.content().stream().map(Event::getId).toList()); + LocalDateTime now = LocalDateTime.now(); + + return ApiResponse.success(PageResponse.from(events, event -> + AdminEventListItemResponse.of(event, applicantCounts.getOrDefault(event.getId(), 0L), now))); } @Override @@ -65,7 +72,7 @@ public ApiResponse getEvent(AdminApiUser apiUser, @Pat EventManageDetail detail = eventService.getEvent(eventId); Map imagesById = fileService.findAllByIdIn(detail.imageIds()); - return ApiResponse.success(AdminEventDetailResponse.from(detail, imagesById)); + return ApiResponse.success(AdminEventDetailResponse.of(detail, imagesById, LocalDateTime.now())); } @Override diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/response/AdminEventDetailResponse.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/response/AdminEventDetailResponse.java index 87366c2f..7b9fc9a1 100644 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/response/AdminEventDetailResponse.java +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/response/AdminEventDetailResponse.java @@ -41,8 +41,9 @@ public record AdminEventDetailResponse( /** * 존재하지 않는(삭제된) fileId는 imagesById에 키가 없다 — 그런 항목은 목록에서 조용히 제외한다. + * 모집 상태는 저장값이 아니라 {@code now} 기준으로 행사가 계산한다. */ - public static AdminEventDetailResponse from(EventManageDetail detail, Map imagesById) { + public static AdminEventDetailResponse of(EventManageDetail detail, Map imagesById, LocalDateTime now) { Event event = detail.event(); return new AdminEventDetailResponse( event.getId(), @@ -57,7 +58,7 @@ public static AdminEventDetailResponse from(EventManageDetail detail, Map questions, - long applicantCount, - RecruitStatus recruitStatus + long applicantCount ) { - public static EventManageDetail of(Event event, List questions, long applicantCount, - LocalDateTime now) { - return new EventManageDetail(event, questions, applicantCount, event.calculateRecruitStatus(now, applicantCount)); - } - /** 행사 이미지 id. 이미지가 없으면 {@code null}이 아니라 빈 목록이다. */ public List imageIds() { return event.getImageIds() == null ? List.of() : event.getImageIds(); diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventManageSummary.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventManageSummary.java deleted file mode 100644 index 04527079..00000000 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventManageSummary.java +++ /dev/null @@ -1,36 +0,0 @@ -package kr.ac.kookmin.stream.event.domain.event.domain; - -import java.time.LocalDateTime; - -/** - * 운영진 행사 목록 한 건. 학생 목록({@link EventSummary})과 달리 비공개 행사도 담고, 신청자 수·정원·공개 여부를 함께 싣는다. - * 모집 상태는 저장값이 아니라 조회 시점 기준으로 계산한다. - * - * @param capacity 선착순 모집의 정원. 상시 모집은 인원 제한이 없어 {@code null} - */ -public record EventManageSummary( - Long eventId, - String title, - long applicantCount, - RecruitType recruitType, - Integer capacity, - RecruitStatus recruitStatus, - boolean published, - LocalDateTime applyStartAt, - LocalDateTime applyEndAt -) { - - public static EventManageSummary of(Event event, long applicantCount, LocalDateTime now) { - return new EventManageSummary( - event.getId(), - event.getTitle(), - applicantCount, - event.getRecruitType(), - event.capacityLimit(), - event.calculateRecruitStatus(now, applicantCount), - event.isPublished(), - event.getApplyStartAt(), - event.getApplyEndAt() - ); - } -} diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/EventService.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/EventService.java index c03767a5..b6dfc05e 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/EventService.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/EventService.java @@ -1,5 +1,7 @@ package kr.ac.kookmin.stream.event.domain.event.service; +import java.util.List; +import java.util.Map; import kr.ac.kookmin.stream.common.CursorSliceResult; import kr.ac.kookmin.stream.common.PageOffset; import kr.ac.kookmin.stream.common.PageResult; @@ -8,7 +10,6 @@ import kr.ac.kookmin.stream.event.domain.event.domain.EventCursor; import kr.ac.kookmin.stream.event.domain.event.domain.EventDetail; import kr.ac.kookmin.stream.event.domain.event.domain.EventManageDetail; -import kr.ac.kookmin.stream.event.domain.event.domain.EventManageSummary; import kr.ac.kookmin.stream.event.domain.event.domain.EventSummary; import kr.ac.kookmin.stream.event.domain.event.domain.EventUpdateCommand; import kr.ac.kookmin.stream.event.domain.event.domain.RecruitStatus; @@ -26,11 +27,16 @@ public interface EventService { Event create(Long createdBy, EventCreateCommand command); /** - * 운영진 행사 목록. 비공개 행사도 담는다. + * 운영진 행사 목록. 비공개 행사도 담는다. 신청자 수는 {@link #getApplicantCounts}로 따로 조회해 조합한다. * * @param keyword 제목 부분 일치 검색어. {@code null}이면 거르지 않는다 */ - PageResult getEvents(String keyword, PageOffset pageOffset); + PageResult getEvents(String keyword, PageOffset pageOffset); + + /** + * 행사별 유효(APPLIED) 신청자 수. 신청이 한 건도 없는 행사는 결과에 담기지 않는다. + */ + Map getApplicantCounts(List eventIds); /** 운영진 행사 상세. 비공개 행사도 조회한다. */ EventManageDetail getEvent(Long eventId); diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImpl.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImpl.java index c9c01b8e..6842679e 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImpl.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImpl.java @@ -15,7 +15,6 @@ import kr.ac.kookmin.stream.event.domain.event.domain.EventDetail; import kr.ac.kookmin.stream.event.domain.event.domain.EventErrorCode; import kr.ac.kookmin.stream.event.domain.event.domain.EventManageDetail; -import kr.ac.kookmin.stream.event.domain.event.domain.EventManageSummary; import kr.ac.kookmin.stream.event.domain.event.domain.EventQuestion; import kr.ac.kookmin.stream.event.domain.event.domain.EventQuestionCommand; import kr.ac.kookmin.stream.event.domain.event.domain.EventSummary; @@ -81,26 +80,24 @@ public Event create(Long createdBy, EventCreateCommand command) { @Override @Transactional(readOnly = true) - public PageResult getEvents(String keyword, PageOffset pageOffset) { - PageResult page = eventRepository.findPage(keyword, pageOffset); - - // 신청자 수는 한 번에 모아 조회한다. 행사마다 따로 세면 페이지 크기만큼 쿼리가 더 나간다 - Map applicantCounts = eventRepository.countAppliedByEventIds( - page.content().stream().map(Event::getId).toList()); - LocalDateTime now = LocalDateTime.now(); + public PageResult getEvents(String keyword, PageOffset pageOffset) { + return eventRepository.findPage(keyword, pageOffset); + } - return page.map(event -> EventManageSummary.of(event, applicantCounts.getOrDefault(event.getId(), 0L), now)); + @Override + @Transactional(readOnly = true) + public Map getApplicantCounts(List eventIds) { + return eventRepository.countAppliedByEventIds(eventIds); } @Override @Transactional(readOnly = true) public EventManageDetail getEvent(Long eventId) { Event event = getEventOrThrow(eventId); - return EventManageDetail.of( + return new EventManageDetail( event, eventRepository.findQuestionsByEventId(eventId), - eventRepository.countAppliedByEventId(eventId), - LocalDateTime.now() + eventRepository.countAppliedByEventId(eventId) ); } From fb9d2f16eae652b59d7061d6e593b0560db35153 Mon Sep 17 00:00:00 2001 From: gain <163835628+leegain1@users.noreply.github.com> Date: Sun, 18 Oct 2026 00:51:51 +0900 Subject: [PATCH 07/19] =?UTF-8?q?test:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=ED=96=89=EC=82=AC=20=EB=93=B1=EB=A1=9D=C2=B7=EC=88=98=EC=A0=95?= =?UTF-8?q?=20=EB=8B=A8=EC=9C=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../event/domain/EventQuestionTest.java | 122 +++++ .../event/domain/event/domain/EventTest.java | 117 ++++ .../impl/EventServiceImplManageTest.java | 515 ++++++++++++++++++ .../service/impl/EventWriteValidatorTest.java | 195 +++++++ 4 files changed, 949 insertions(+) create mode 100644 core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventQuestionTest.java create mode 100644 core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImplManageTest.java create mode 100644 core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventWriteValidatorTest.java diff --git a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventQuestionTest.java b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventQuestionTest.java new file mode 100644 index 00000000..2b0a1cd2 --- /dev/null +++ b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventQuestionTest.java @@ -0,0 +1,122 @@ +package kr.ac.kookmin.stream.event.domain.event.domain; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.util.ArrayList; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class EventQuestionTest { + + private static EventQuestionCommand command(QuestionType type, List options, boolean otherOptionAllowed) { + return new EventQuestionCommand("참여 종목", type, true, options, otherOptionAllowed); + } + + private static EventQuestion saved(Long id, QuestionType type, List options, boolean otherOptionAllowed) { + return EventQuestion.of(id, 15L, "참여 종목", type, true, 1, options, otherOptionAllowed); + } + + @Nested + @DisplayName("새 질문 만들기") + class Create { + + @Test + @DisplayName("소속 행사와 노출 순서를 붙여 입력값을 담는다") + void copiesCommand() { + EventQuestion question = EventQuestion.create( + 15L, 2, command(QuestionType.MULTIPLE_CHOICE, List.of("축구", "농구"), true)); + + assertNull(question.getId()); + assertEquals(15L, question.getEventId()); + assertEquals(2, question.getDisplayOrder()); + assertEquals("참여 종목", question.getQuestionText()); + assertEquals(QuestionType.MULTIPLE_CHOICE, question.getQuestionType()); + assertTrue(question.isRequired()); + assertEquals(List.of("축구", "농구"), question.getOptions()); + assertTrue(question.isOtherOptionAllowed()); + } + + @Test + @DisplayName("텍스트형 질문에 딸려 온 선택지와 '기타' 여부는 버린다") + void textQuestionDropsChoiceFields() { + for (QuestionType type : List.of(QuestionType.SHORT_TEXT, QuestionType.LONG_TEXT)) { + EventQuestion question = EventQuestion.create(15L, 1, command(type, List.of("축구"), true)); + + assertEquals(List.of(), question.getOptions()); + assertFalse(question.isOtherOptionAllowed()); + } + } + + @Test + @DisplayName("요청 목록을 나중에 바꿔도 질문의 선택지는 바뀌지 않는다") + void copiesOptions() { + List options = new ArrayList<>(List.of("축구")); + EventQuestion question = EventQuestion.create(15L, 1, command(QuestionType.SINGLE_CHOICE, options, false)); + + options.add("농구"); + + assertEquals(List.of("축구"), question.getOptions()); + } + } + + @Nested + @DisplayName("내용 비교") + class SameContent { + + @Test + @DisplayName("식별자·소속 행사·노출 순서 값이 달라도 신청서에 보이는 내용이 같으면 같다") + void ignoresIdentityAndOrderValue() { + EventQuestion saved = saved(101L, QuestionType.SINGLE_CHOICE, List.of("참석", "불참"), false); + EventQuestion requested = EventQuestion.create( + 15L, 5, command(QuestionType.SINGLE_CHOICE, List.of("참석", "불참"), false)); + + assertTrue(saved.hasSameContentAs(requested)); + } + + @Test + @DisplayName("유형이 다르면 다르다") + void differentType() { + assertFalse(saved(1L, QuestionType.SINGLE_CHOICE, List.of("참석"), false) + .hasSameContentAs(saved(1L, QuestionType.MULTIPLE_CHOICE, List.of("참석"), false))); + } + + @Test + @DisplayName("선택지 순서가 바뀌면 다르다 — 답변이 선택지 순서로 저장되기 때문이다") + void differentOptionOrder() { + assertFalse(saved(1L, QuestionType.SINGLE_CHOICE, List.of("참석", "불참"), false) + .hasSameContentAs(saved(1L, QuestionType.SINGLE_CHOICE, List.of("불참", "참석"), false))); + } + + @Test + @DisplayName("'기타' 여부가 다르면 다르다") + void differentOtherOption() { + assertFalse(saved(1L, QuestionType.SINGLE_CHOICE, List.of("참석"), false) + .hasSameContentAs(saved(1L, QuestionType.SINGLE_CHOICE, List.of("참석"), true))); + } + + @Test + @DisplayName("질문 내용이나 필수 여부가 다르면 다르다") + void differentTextOrRequired() { + EventQuestion base = saved(1L, QuestionType.SHORT_TEXT, List.of(), false); + + assertFalse(base.hasSameContentAs( + EventQuestion.of(1L, 15L, "전화번호", QuestionType.SHORT_TEXT, true, 1, List.of(), false))); + assertFalse(base.hasSameContentAs( + EventQuestion.of(1L, 15L, "참여 종목", QuestionType.SHORT_TEXT, false, 1, List.of(), false))); + } + } + + @Test + @DisplayName("선택형 유형은 단일·복수 선택뿐이다") + void choiceTypes() { + assertTrue(QuestionType.SINGLE_CHOICE.isChoice()); + assertTrue(QuestionType.MULTIPLE_CHOICE.isChoice()); + assertFalse(QuestionType.SHORT_TEXT.isChoice()); + assertFalse(QuestionType.LONG_TEXT.isChoice()); + } +} diff --git a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventTest.java b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventTest.java index 8a102ca7..8c327aed 100644 --- a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventTest.java +++ b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventTest.java @@ -172,4 +172,121 @@ void notFull() { assertFalse(firstComeEvent().isClosedByCapacityOnly(APPLY_START_AT.plusDays(1), CAPACITY - 1)); } } + + @Nested + @DisplayName("운영진 등록") + class Create { + + private EventCreateCommand command(RecruitType recruitType, Integer capacity) { + return new EventCreateCommand( + "체육대회", "학과 대항 체육대회", "종합운동장", + LocalDateTime.of(2026, 11, 1, 10, 0), LocalDateTime.of(2026, 11, 1, 18, 0), + APPLY_START_AT, APPLY_END_AT, recruitType, capacity, true, List.of(31L, 32L), List.of() + ); + } + + @Test + @DisplayName("입력값을 그대로 담고 작성자를 기록한다") + void copiesCommand() { + Event event = Event.create(7L, command(RecruitType.FIRST_COME, 100)); + + assertNull(event.getId()); + assertEquals("체육대회", event.getTitle()); + assertEquals("학과 대항 체육대회", event.getDescription()); + assertEquals("종합운동장", event.getPlace()); + assertEquals(APPLY_START_AT, event.getApplyStartAt()); + assertEquals(APPLY_END_AT, event.getApplyEndAt()); + assertEquals(RecruitType.FIRST_COME, event.getRecruitType()); + assertEquals(100, event.getCapacity()); + assertTrue(event.isFeePaidOnly()); + assertEquals(List.of(31L, 32L), event.getImageIds()); + assertEquals(7L, event.getCreatedBy()); + } + + @Test + @DisplayName("학생에게 바로 보이지 않도록 비공개로 시작한다") + void startsUnpublished() { + assertFalse(Event.create(7L, command(RecruitType.FIRST_COME, 100)).isPublished()); + } + + @Test + @DisplayName("상시 모집은 입력한 정원과 상관없이 0으로 저장한다") + void openRecruitStoresZeroCapacity() { + assertEquals(0, Event.create(7L, command(RecruitType.OPEN, 50)).getCapacity()); + assertEquals(0, Event.create(7L, command(RecruitType.OPEN, null)).getCapacity()); + } + } + + @Nested + @DisplayName("운영진 수정") + class Update { + + private EventUpdateCommand command(RecruitType recruitType, Integer capacity) { + return new EventUpdateCommand( + "체육대회(변경)", "장소가 바뀌었습니다", "미래관 대강당", + LocalDateTime.of(2026, 11, 2, 10, 0), LocalDateTime.of(2026, 11, 2, 18, 0), + APPLY_START_AT.plusDays(1), APPLY_END_AT.plusDays(1), recruitType, capacity, true, + List.of(40L), List.of(), LocalDateTime.now() + ); + } + + @Test + @DisplayName("내용을 통째로 바꾼다") + void replacesContent() { + Event event = firstComeEvent(); + + event.update(command(RecruitType.FIRST_COME, 50)); + + assertEquals("체육대회(변경)", event.getTitle()); + assertEquals("장소가 바뀌었습니다", event.getDescription()); + assertEquals("미래관 대강당", event.getPlace()); + assertEquals(LocalDateTime.of(2026, 11, 2, 10, 0), event.getEventStartAt()); + assertEquals(LocalDateTime.of(2026, 11, 2, 18, 0), event.getEventEndAt()); + assertEquals(APPLY_START_AT.plusDays(1), event.getApplyStartAt()); + assertEquals(APPLY_END_AT.plusDays(1), event.getApplyEndAt()); + assertEquals(50, event.getCapacity()); + assertTrue(event.isFeePaidOnly()); + assertEquals(List.of(40L), event.getImageIds()); + } + + @Test + @DisplayName("공개 여부와 작성자는 그대로 둔다") + void keepsPublishedAndCreator() { + Event event = firstComeEvent(); + + event.update(command(RecruitType.FIRST_COME, 50)); + + assertTrue(event.isPublished()); + assertEquals(1L, event.getCreatedBy()); + assertEquals(1L, event.getId()); + } + + @Test + @DisplayName("상시 모집으로 바꾸면 정원을 0으로 둔다") + void switchToOpenRecruitClearsCapacity() { + Event event = firstComeEvent(); + + event.update(command(RecruitType.OPEN, 50)); + + assertEquals(RecruitType.OPEN, event.getRecruitType()); + assertEquals(0, event.getCapacity()); + } + } + + @Nested + @DisplayName("운영진 화면의 정원") + class CapacityLimit { + + @Test + @DisplayName("선착순이면 정원을 돌려준다") + void firstComeReturnsCapacity() { + assertEquals(CAPACITY, firstComeEvent().capacityLimit()); + } + + @Test + @DisplayName("상시 모집은 인원 제한이 없어 null이다") + void openRecruitReturnsNull() { + assertNull(event(RecruitType.OPEN).capacityLimit()); + } + } } diff --git a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImplManageTest.java b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImplManageTest.java new file mode 100644 index 00000000..b4285436 --- /dev/null +++ b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImplManageTest.java @@ -0,0 +1,515 @@ +package kr.ac.kookmin.stream.event.domain.event.service.impl; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertSame; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import kr.ac.kookmin.stream.common.BusinessException; +import kr.ac.kookmin.stream.common.PageOffset; +import kr.ac.kookmin.stream.common.PageResult; +import kr.ac.kookmin.stream.event.domain.event.domain.Event; +import kr.ac.kookmin.stream.event.domain.event.domain.EventApplication; +import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationAnswer; +import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationCursor; +import kr.ac.kookmin.stream.event.domain.event.domain.EventCreateCommand; +import kr.ac.kookmin.stream.event.domain.event.domain.EventCursor; +import kr.ac.kookmin.stream.event.domain.event.domain.EventErrorCode; +import kr.ac.kookmin.stream.event.domain.event.domain.EventManageDetail; +import kr.ac.kookmin.stream.event.domain.event.domain.EventQuestion; +import kr.ac.kookmin.stream.event.domain.event.domain.EventQuestionCommand; +import kr.ac.kookmin.stream.event.domain.event.domain.EventUpdateCommand; +import kr.ac.kookmin.stream.event.domain.event.domain.QuestionType; +import kr.ac.kookmin.stream.event.domain.event.domain.RecruitStatus; +import kr.ac.kookmin.stream.event.domain.event.domain.RecruitType; +import kr.ac.kookmin.stream.event.domain.event.repository.EventRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +/** + * 운영진 행사 등록·조회·수정. 형식 규칙 자체는 {@code EventWriteValidatorTest}가 덮고, + * 여기서는 저장 흐름과 수정 시의 규칙(동시 수정·정원 하한·질문 잠금)을 본다. + */ +class EventServiceImplManageTest { + + private static final Long EVENT_ID = 15L; + private static final LocalDateTime UPDATED_AT = LocalDateTime.of(2026, 10, 10, 12, 30, 15); + private static final LocalDateTime APPLY_START_AT = LocalDateTime.of(2026, 10, 14, 10, 0); + private static final LocalDateTime APPLY_END_AT = LocalDateTime.of(2026, 10, 25, 23, 59, 59); + private static final LocalDateTime EVENT_START_AT = LocalDateTime.of(2026, 11, 1, 10, 0); + private static final LocalDateTime EVENT_END_AT = LocalDateTime.of(2026, 11, 1, 18, 0); + + private static final EventQuestionCommand SPORTS = new EventQuestionCommand( + "참여 종목", QuestionType.MULTIPLE_CHOICE, true, List.of("축구", "농구"), true); + private static final EventQuestionCommand PHONE = new EventQuestionCommand( + "전화번호", QuestionType.SHORT_TEXT, true, List.of(), false); + + private final InMemoryEventRepository repository = new InMemoryEventRepository(); + private final EventServiceImpl service = new EventServiceImpl(repository, new EventWriteValidator()); + + private static Event savedEvent(RecruitType recruitType, int capacity) { + return Event.of( + EVENT_ID, "체육대회", "설명", "종합운동장", EVENT_START_AT, EVENT_END_AT, APPLY_START_AT, APPLY_END_AT, + recruitType, List.of(31L), capacity, false, true, 7L, UPDATED_AT + ); + } + + private static EventCreateCommand createCommand( + RecruitType recruitType, Integer capacity, List questions) { + return new EventCreateCommand( + "체육대회", "설명", "종합운동장", EVENT_START_AT, EVENT_END_AT, APPLY_START_AT, APPLY_END_AT, + recruitType, capacity, false, List.of(31L), questions + ); + } + + private static EventUpdateCommand updateCommand( + RecruitType recruitType, Integer capacity, List questions, LocalDateTime expectedUpdatedAt) { + return new EventUpdateCommand( + "체육대회(변경)", "설명", "미래관", EVENT_START_AT, EVENT_END_AT, APPLY_START_AT, APPLY_END_AT, + recruitType, capacity, true, List.of(40L), questions, expectedUpdatedAt + ); + } + + private static EventUpdateCommand updateCommand(List questions) { + return updateCommand(RecruitType.FIRST_COME, 100, questions, UPDATED_AT); + } + + /** 저장된 질문처럼 id가 붙은 질문 목록. 노출 순서는 1부터 센다. */ + private static List savedQuestions(EventQuestionCommand... commands) { + List questions = new ArrayList<>(); + for (int i = 0; i < commands.length; i++) { + EventQuestionCommand command = commands[i]; + questions.add(EventQuestion.of( + 100L + i, EVENT_ID, command.questionText(), command.questionType(), command.required(), i + 1, + command.options(), command.otherOptionAllowed())); + } + return questions; + } + + private static void assertRejected(EventErrorCode expected, Runnable action) { + BusinessException e = assertThrows(BusinessException.class, action::run); + assertEquals(expected, e.getErrorCode()); + } + + @Nested + @DisplayName("등록") + class Create { + + @Test + @DisplayName("행사를 비공개로 저장하고 작성자를 기록한다") + void savesUnpublishedEvent() { + Event created = service.create(7L, createCommand(RecruitType.FIRST_COME, 100, List.of())); + + Event saved = repository.events.get(created.getId()); + assertFalse(saved.isPublished()); + assertEquals(7L, saved.getCreatedBy()); + assertEquals(100, saved.getCapacity()); + } + + @Test + @DisplayName("질문을 요청 순서대로 1부터 노출 순서를 붙여 저장한다") + void savesQuestionsInRequestOrder() { + Event created = service.create(7L, createCommand(RecruitType.OPEN, null, List.of(PHONE, SPORTS))); + + List questions = repository.questions.get(created.getId()); + assertEquals(List.of("전화번호", "참여 종목"), + questions.stream().map(EventQuestion::getQuestionText).toList()); + assertEquals(List.of(1, 2), questions.stream().map(EventQuestion::getDisplayOrder).toList()); + assertTrue(questions.stream().allMatch(question -> question.getEventId().equals(created.getId()))); + } + + @Test + @DisplayName("질문이 없으면 빈 신청서로 저장한다") + void savesEmptyForm() { + Event created = service.create(7L, createCommand(RecruitType.OPEN, null, List.of())); + + assertTrue(repository.findQuestionsByEventId(created.getId()).isEmpty()); + } + + @Test + @DisplayName("규칙을 어기면 아무것도 저장하지 않는다") + void savesNothingWhenInvalid() { + assertRejected(EventErrorCode.EVENT_INVALID_CAPACITY, + () -> service.create(7L, createCommand(RecruitType.FIRST_COME, 0, List.of(SPORTS)))); + + assertTrue(repository.events.isEmpty()); + assertTrue(repository.questions.isEmpty()); + } + } + + @Nested + @DisplayName("상세 조회") + class GetEvent { + + @Test + @DisplayName("행사·질문·유효 신청자 수를 함께 돌려준다") + void returnsBundle() { + repository.events.put(EVENT_ID, savedEvent(RecruitType.FIRST_COME, 100)); + repository.questions.put(EVENT_ID, savedQuestions(PHONE, SPORTS)); + repository.appliedCounts.put(EVENT_ID, 3L); + + EventManageDetail detail = service.getEvent(EVENT_ID); + + assertEquals(EVENT_ID, detail.event().getId()); + assertEquals(2, detail.questions().size()); + assertEquals(3L, detail.applicantCount()); + } + + @Test + @DisplayName("없거나 삭제된 행사는 찾을 수 없다") + void notFound() { + assertRejected(EventErrorCode.EVENT_NOT_FOUND, () -> service.getEvent(EVENT_ID)); + } + } + + @Nested + @DisplayName("수정") + class Update { + + private void givenEvent(RecruitType recruitType, int capacity, List questions) { + repository.events.put(EVENT_ID, savedEvent(recruitType, capacity)); + repository.questions.put(EVENT_ID, questions); + } + + @Test + @DisplayName("내용을 바꿔 저장하고 공개 여부·작성자는 그대로 둔다") + void updatesContent() { + givenEvent(RecruitType.FIRST_COME, 100, savedQuestions(SPORTS)); + + service.update(EVENT_ID, updateCommand(List.of(SPORTS))); + + Event saved = repository.events.get(EVENT_ID); + assertEquals("체육대회(변경)", saved.getTitle()); + assertEquals("미래관", saved.getPlace()); + assertEquals(List.of(40L), saved.getImageIds()); + assertTrue(saved.isFeePaidOnly()); + assertTrue(saved.isPublished()); + assertEquals(7L, saved.getCreatedBy()); + } + + @Test + @DisplayName("없거나 삭제된 행사는 찾을 수 없다") + void notFound() { + assertRejected(EventErrorCode.EVENT_NOT_FOUND, () -> service.update(EVENT_ID, updateCommand(List.of()))); + } + + @Nested + @DisplayName("동시 수정") + class Conflict { + + @Test + @DisplayName("수정 화면이 읽은 뒤 다른 운영진이 고쳤다면 거부한다") + void rejectsWhenModifiedMeanwhile() { + givenEvent(RecruitType.FIRST_COME, 100, savedQuestions(SPORTS)); + + assertRejected(EventErrorCode.EVENT_UPDATE_CONFLICT, () -> service.update(EVENT_ID, + updateCommand(RecruitType.FIRST_COME, 100, List.of(SPORTS), UPDATED_AT.minusSeconds(1)))); + assertEquals("체육대회", repository.events.get(EVENT_ID).getTitle()); + } + + @Test + @DisplayName("같은 초 안의 밀리초 차이는 같은 시각으로 본다 — DB가 초 단위로 저장한다") + void ignoresSubSecondDifference() { + givenEvent(RecruitType.FIRST_COME, 100, savedQuestions(SPORTS)); + + assertDoesNotThrow(() -> service.update(EVENT_ID, + updateCommand(RecruitType.FIRST_COME, 100, List.of(SPORTS), UPDATED_AT.plusNanos(999_000_000)))); + } + + @Test + @DisplayName("동시 수정 검사는 형식 검증보다 먼저 한다") + void checkedBeforeFormat() { + givenEvent(RecruitType.FIRST_COME, 100, savedQuestions(SPORTS)); + + assertRejected(EventErrorCode.EVENT_UPDATE_CONFLICT, () -> service.update(EVENT_ID, + updateCommand(RecruitType.FIRST_COME, 0, List.of(SPORTS), UPDATED_AT.minusDays(1)))); + } + } + + @Nested + @DisplayName("정원 하한") + class CapacityFloor { + + @Test + @DisplayName("선착순 정원을 현재 신청자 수보다 줄일 수 없다") + void rejectsBelowApplicants() { + givenEvent(RecruitType.FIRST_COME, 100, savedQuestions(SPORTS)); + repository.appliedCounts.put(EVENT_ID, 10L); + + assertRejected(EventErrorCode.EVENT_CAPACITY_BELOW_APPLICANTS, () -> service.update(EVENT_ID, + updateCommand(RecruitType.FIRST_COME, 9, List.of(SPORTS), UPDATED_AT))); + } + + @Test + @DisplayName("신청자 수와 같은 정원까지는 줄일 수 있다") + void allowsEqualToApplicants() { + givenEvent(RecruitType.FIRST_COME, 100, savedQuestions(SPORTS)); + repository.appliedCounts.put(EVENT_ID, 10L); + + service.update(EVENT_ID, updateCommand(RecruitType.FIRST_COME, 10, List.of(SPORTS), UPDATED_AT)); + + assertEquals(10, repository.events.get(EVENT_ID).getCapacity()); + } + + @Test + @DisplayName("상시 모집에서 선착순으로 바꿀 때도 신청자 수 이상이어야 한다") + void switchingToFirstComeChecksApplicants() { + givenEvent(RecruitType.OPEN, 0, savedQuestions(SPORTS)); + repository.appliedCounts.put(EVENT_ID, 30L); + + assertRejected(EventErrorCode.EVENT_CAPACITY_BELOW_APPLICANTS, () -> service.update(EVENT_ID, + updateCommand(RecruitType.FIRST_COME, 20, List.of(SPORTS), UPDATED_AT))); + } + + @Test + @DisplayName("상시 모집은 신청자 수와 상관없이 수정할 수 있다") + void openRecruitIgnoresApplicants() { + givenEvent(RecruitType.FIRST_COME, 100, savedQuestions(SPORTS)); + repository.appliedCounts.put(EVENT_ID, 300L); + + assertDoesNotThrow(() -> service.update(EVENT_ID, + updateCommand(RecruitType.OPEN, null, List.of(SPORTS), UPDATED_AT))); + } + } + + @Nested + @DisplayName("신청서 질문") + class Questions { + + @Test + @DisplayName("질문이 그대로면 교체하지 않아 질문 id가 유지된다") + void keepsQuestionsWhenUnchanged() { + List original = savedQuestions(PHONE, SPORTS); + givenEvent(RecruitType.FIRST_COME, 100, original); + repository.applicationExists = true; + + service.update(EVENT_ID, updateCommand(List.of(PHONE, SPORTS))); + + assertFalse(repository.questionsReplaced); + assertFalse(repository.updatedAtTouched); + assertSame(original, repository.questions.get(EVENT_ID)); + } + + @Test + @DisplayName("신청이 없으면 바뀐 질문으로 통째로 교체한다") + void replacesWhenNoApplication() { + givenEvent(RecruitType.FIRST_COME, 100, savedQuestions(PHONE, SPORTS)); + + service.update(EVENT_ID, updateCommand(List.of(SPORTS))); + + assertTrue(repository.questionsReplaced); + List replaced = repository.questions.get(EVENT_ID); + assertEquals(List.of("참여 종목"), replaced.stream().map(EventQuestion::getQuestionText).toList()); + assertEquals(1, replaced.getFirst().getDisplayOrder()); + assertNull(replaced.getFirst().getId()); + } + + @Test + @DisplayName("질문을 교체하면 행사 수정 시각도 올린다 — 질문만 바뀌면 DB가 시각을 올리지 않는다") + void touchesUpdatedAtWhenReplaced() { + givenEvent(RecruitType.FIRST_COME, 100, savedQuestions(SPORTS)); + + service.update(EVENT_ID, updateCommand(List.of(SPORTS, PHONE))); + + assertTrue(repository.updatedAtTouched); + } + + @Test + @DisplayName("순서만 바꿔도 바뀐 것으로 본다") + void reorderCountsAsChange() { + givenEvent(RecruitType.FIRST_COME, 100, savedQuestions(PHONE, SPORTS)); + + service.update(EVENT_ID, updateCommand(List.of(SPORTS, PHONE))); + + assertTrue(repository.questionsReplaced); + } + + @Test + @DisplayName("신청(취소 포함)이 있으면 질문을 바꿀 수 없고 행사도 저장하지 않는다") + void lockedWhenApplicationExists() { + givenEvent(RecruitType.FIRST_COME, 100, savedQuestions(SPORTS)); + repository.applicationExists = true; + + assertRejected(EventErrorCode.EVENT_QUESTIONS_LOCKED, + () -> service.update(EVENT_ID, updateCommand(List.of(SPORTS, PHONE)))); + assertFalse(repository.questionsReplaced); + assertEquals("체육대회", repository.events.get(EVENT_ID).getTitle()); + } + } + } + + @Nested + @DisplayName("목록 조회") + class GetEvents { + + @Test + @DisplayName("검색어와 페이지를 그대로 넘겨 행사 페이지를 돌려준다") + void delegatesToRepository() { + repository.events.put(EVENT_ID, savedEvent(RecruitType.FIRST_COME, 100)); + + PageResult page = service.getEvents("체육", PageOffset.of(1, 10)); + + assertEquals("체육", repository.requestedKeyword); + assertEquals(PageOffset.of(1, 10), repository.requestedPageOffset); + assertEquals(1, page.content().size()); + } + + @Test + @DisplayName("신청자 수는 행사 id 목록으로 한 번에 조회한다") + void countsApplicantsAtOnce() { + repository.appliedCounts.put(1L, 3L); + repository.appliedCounts.put(2L, 5L); + + assertEquals(Map.of(1L, 3L, 2L, 5L), service.getApplicantCounts(List.of(1L, 2L))); + } + } + + @Test + @DisplayName("상세 조회의 모집 상태 계산에 쓰는 신청자 수는 유효 신청만 센다") + void detailApplicantCountFeedsRecruitStatus() { + repository.events.put(EVENT_ID, savedEvent(RecruitType.FIRST_COME, 3)); + repository.questions.put(EVENT_ID, List.of()); + repository.appliedCounts.put(EVENT_ID, 3L); + + EventManageDetail detail = service.getEvent(EVENT_ID); + + assertEquals(RecruitStatus.CLOSED, + detail.event().calculateRecruitStatus(APPLY_START_AT.plusDays(1), detail.applicantCount())); + } + + /** 운영진 등록·조회·수정에 쓰는 메서드만 메모리로 흉내 내고, 나머지는 이 테스트가 건드리지 않는다. */ + private static final class InMemoryEventRepository implements EventRepository { + + private final Map events = new HashMap<>(); + private final Map> questions = new HashMap<>(); + private final Map appliedCounts = new HashMap<>(); + private boolean applicationExists; + private boolean questionsReplaced; + private boolean updatedAtTouched; + private String requestedKeyword; + private PageOffset requestedPageOffset; + private long nextId = 1; + + @Override + public Optional findById(Long id) { + return Optional.ofNullable(events.get(id)); + } + + @Override + public Event save(Event event) { + Long id = event.getId() == null ? nextId++ : event.getId(); + Event saved = Event.of( + id, event.getTitle(), event.getDescription(), event.getPlace(), event.getEventStartAt(), + event.getEventEndAt(), event.getApplyStartAt(), event.getApplyEndAt(), event.getRecruitType(), + event.getImageIds(), event.getCapacity(), event.isFeePaidOnly(), event.isPublished(), + event.getCreatedBy(), event.getUpdatedAt()); + events.put(id, saved); + return saved; + } + + @Override + public List saveQuestions(List saved) { + saved.forEach(question -> + questions.computeIfAbsent(question.getEventId(), key -> new ArrayList<>()).add(question)); + return saved; + } + + @Override + public List replaceQuestions(Long eventId, List replaced) { + questionsReplaced = true; + questions.put(eventId, replaced); + return replaced; + } + + @Override + public boolean existsApplicationByEventId(Long eventId) { + return applicationExists; + } + + @Override + public void touchUpdatedAt(Long eventId) { + updatedAtTouched = true; + } + + @Override + public PageResult findPage(String keyword, PageOffset pageOffset) { + requestedKeyword = keyword; + requestedPageOffset = pageOffset; + List content = List.copyOf(events.values()); + return PageResult.of(content, pageOffset.page(), pageOffset.size(), content.size()); + } + + @Override + public List findQuestionsByEventId(Long eventId) { + return questions.getOrDefault(eventId, List.of()); + } + + @Override + public long countAppliedByEventId(Long eventId) { + return appliedCounts.getOrDefault(eventId, 0L); + } + + @Override + public Map countAppliedByEventIds(List eventIds) { + Map counts = new HashMap<>(); + eventIds.stream().filter(appliedCounts::containsKey).forEach(id -> counts.put(id, appliedCounts.get(id))); + return counts; + } + + @Override + public List findAllByIds(List eventIds) { + throw new UnsupportedOperationException(); + } + + @Override + public Optional findPublishedById(Long id) { + throw new UnsupportedOperationException(); + } + + @Override + public List findPublishedSlice( + RecruitStatus recruitStatus, EventCursor cursor, int limit, LocalDateTime now) { + throw new UnsupportedOperationException(); + } + + @Override + public boolean existsAppliedByEventIdAndMemberId(Long eventId, Long memberId) { + throw new UnsupportedOperationException(); + } + + @Override + public EventApplication saveApplication(EventApplication application) { + throw new UnsupportedOperationException(); + } + + @Override + public List saveAnswers(List answers) { + throw new UnsupportedOperationException(); + } + + @Override + public List findApplicationSlice(Long memberId, EventApplicationCursor cursor, int limit) { + throw new UnsupportedOperationException(); + } + + @Override + public Optional findApplicationByIdAndMemberId(Long applicationId, Long memberId) { + throw new UnsupportedOperationException(); + } + + @Override + public List findAnswersByApplicationId(Long applicationId) { + throw new UnsupportedOperationException(); + } + } +} diff --git a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventWriteValidatorTest.java b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventWriteValidatorTest.java new file mode 100644 index 00000000..37ff1230 --- /dev/null +++ b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventWriteValidatorTest.java @@ -0,0 +1,195 @@ +package kr.ac.kookmin.stream.event.domain.event.service.impl; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.time.LocalDateTime; +import java.util.List; +import kr.ac.kookmin.stream.common.BusinessException; +import kr.ac.kookmin.stream.event.domain.event.domain.EventCreateCommand; +import kr.ac.kookmin.stream.event.domain.event.domain.EventErrorCode; +import kr.ac.kookmin.stream.event.domain.event.domain.EventQuestionCommand; +import kr.ac.kookmin.stream.event.domain.event.domain.EventUpdateCommand; +import kr.ac.kookmin.stream.event.domain.event.domain.QuestionType; +import kr.ac.kookmin.stream.event.domain.event.domain.RecruitType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class EventWriteValidatorTest { + + private static final LocalDateTime APPLY_START_AT = LocalDateTime.of(2026, 10, 14, 10, 0); + private static final LocalDateTime APPLY_END_AT = LocalDateTime.of(2026, 10, 25, 23, 59, 59); + private static final LocalDateTime EVENT_START_AT = LocalDateTime.of(2026, 11, 1, 10, 0); + private static final LocalDateTime EVENT_END_AT = LocalDateTime.of(2026, 11, 1, 18, 0); + + private final EventWriteValidator validator = new EventWriteValidator(); + + private static EventCreateCommand createCommand( + LocalDateTime eventStartAt, + LocalDateTime eventEndAt, + LocalDateTime applyStartAt, + LocalDateTime applyEndAt, + RecruitType recruitType, + Integer capacity, + List questions + ) { + return new EventCreateCommand( + "체육대회", "설명", "종합운동장", eventStartAt, eventEndAt, applyStartAt, applyEndAt, + recruitType, capacity, false, List.of(), questions + ); + } + + private static EventCreateCommand validCommand() { + return createCommand( + EVENT_START_AT, EVENT_END_AT, APPLY_START_AT, APPLY_END_AT, RecruitType.FIRST_COME, 100, List.of()); + } + + private static EventQuestionCommand question(QuestionType type, List options) { + return new EventQuestionCommand("질문", type, true, options, false); + } + + private static void assertRejected(EventErrorCode expected, Runnable validation) { + BusinessException e = assertThrows(BusinessException.class, validation::run); + assertEquals(expected, e.getErrorCode()); + } + + @Test + @DisplayName("규칙을 모두 지키면 통과한다") + void validCommandPasses() { + assertDoesNotThrow(() -> validator.validate(validCommand())); + } + + @Nested + @DisplayName("기간") + class Period { + + @Test + @DisplayName("행사 종료가 시작보다 빠르면 거부한다") + void eventEndsBeforeStart() { + assertRejected(EventErrorCode.EVENT_INVALID_PERIOD, () -> validator.validate(createCommand( + EVENT_START_AT, EVENT_START_AT.minusMinutes(1), APPLY_START_AT, APPLY_END_AT, + RecruitType.OPEN, null, List.of()))); + } + + @Test + @DisplayName("행사 시작과 종료가 같으면 거부한다") + void eventEndsAtStart() { + assertRejected(EventErrorCode.EVENT_INVALID_PERIOD, () -> validator.validate(createCommand( + EVENT_START_AT, EVENT_START_AT, APPLY_START_AT, APPLY_END_AT, RecruitType.OPEN, null, List.of()))); + } + + @Test + @DisplayName("신청 마감이 시작보다 빠르면 거부한다") + void applyEndsBeforeStart() { + assertRejected(EventErrorCode.EVENT_INVALID_PERIOD, () -> validator.validate(createCommand( + EVENT_START_AT, EVENT_END_AT, APPLY_START_AT, APPLY_START_AT.minusSeconds(1), + RecruitType.OPEN, null, List.of()))); + } + + @Test + @DisplayName("신청 기간과 행사 기간이 겹쳐도 각각 순서만 맞으면 통과한다") + void overlappingPeriodsAreAllowed() { + assertDoesNotThrow(() -> validator.validate(createCommand( + APPLY_START_AT.plusDays(1), APPLY_END_AT.plusDays(1), APPLY_START_AT, APPLY_END_AT, + RecruitType.OPEN, null, List.of()))); + } + } + + @Nested + @DisplayName("정원") + class Capacity { + + @Test + @DisplayName("선착순인데 정원이 없으면 거부한다") + void firstComeWithoutCapacity() { + assertRejected(EventErrorCode.EVENT_INVALID_CAPACITY, () -> validator.validate(createCommand( + EVENT_START_AT, EVENT_END_AT, APPLY_START_AT, APPLY_END_AT, RecruitType.FIRST_COME, null, List.of()))); + } + + @Test + @DisplayName("선착순인데 정원이 0명이면 거부한다") + void firstComeWithZeroCapacity() { + assertRejected(EventErrorCode.EVENT_INVALID_CAPACITY, () -> validator.validate(createCommand( + EVENT_START_AT, EVENT_END_AT, APPLY_START_AT, APPLY_END_AT, RecruitType.FIRST_COME, 0, List.of()))); + } + + @Test + @DisplayName("선착순 정원은 1명부터 허용한다") + void firstComeWithOneCapacity() { + assertDoesNotThrow(() -> validator.validate(createCommand( + EVENT_START_AT, EVENT_END_AT, APPLY_START_AT, APPLY_END_AT, RecruitType.FIRST_COME, 1, List.of()))); + } + + @Test + @DisplayName("상시 모집은 정원을 보지 않는다") + void openRecruitIgnoresCapacity() { + assertDoesNotThrow(() -> validator.validate(createCommand( + EVENT_START_AT, EVENT_END_AT, APPLY_START_AT, APPLY_END_AT, RecruitType.OPEN, null, List.of()))); + assertDoesNotThrow(() -> validator.validate(createCommand( + EVENT_START_AT, EVENT_END_AT, APPLY_START_AT, APPLY_END_AT, RecruitType.OPEN, -1, List.of()))); + } + } + + @Nested + @DisplayName("신청서 질문") + class Questions { + + @Test + @DisplayName("선택형 질문에 선택지가 없으면 거부한다") + void choiceWithoutOptions() { + for (QuestionType type : List.of(QuestionType.SINGLE_CHOICE, QuestionType.MULTIPLE_CHOICE)) { + assertRejected(EventErrorCode.EVENT_INVALID_QUESTION, () -> validator.validate(createCommand( + EVENT_START_AT, EVENT_END_AT, APPLY_START_AT, APPLY_END_AT, RecruitType.OPEN, null, + List.of(question(type, List.of()))))); + assertRejected(EventErrorCode.EVENT_INVALID_QUESTION, () -> validator.validate(createCommand( + EVENT_START_AT, EVENT_END_AT, APPLY_START_AT, APPLY_END_AT, RecruitType.OPEN, null, + List.of(question(type, null))))); + } + } + + @Test + @DisplayName("여러 질문 중 하나라도 규칙을 어기면 거부한다") + void oneInvalidAmongMany() { + assertRejected(EventErrorCode.EVENT_INVALID_QUESTION, () -> validator.validate(createCommand( + EVENT_START_AT, EVENT_END_AT, APPLY_START_AT, APPLY_END_AT, RecruitType.OPEN, null, List.of( + question(QuestionType.SHORT_TEXT, List.of()), + question(QuestionType.SINGLE_CHOICE, List.of("참석", "불참")), + question(QuestionType.MULTIPLE_CHOICE, List.of()))))); + } + + @Test + @DisplayName("텍스트형 질문은 선택지가 없어도 통과한다") + void textWithoutOptions() { + assertDoesNotThrow(() -> validator.validate(createCommand( + EVENT_START_AT, EVENT_END_AT, APPLY_START_AT, APPLY_END_AT, RecruitType.OPEN, null, List.of( + question(QuestionType.SHORT_TEXT, null), + question(QuestionType.LONG_TEXT, List.of()))))); + } + } + + @Nested + @DisplayName("수정 요청") + class UpdateCommand { + + @Test + @DisplayName("등록과 같은 규칙으로 검증한다") + void sameRulesAsCreate() { + EventUpdateCommand invalidPeriod = new EventUpdateCommand( + "체육대회", "설명", null, EVENT_START_AT, EVENT_START_AT, APPLY_START_AT, APPLY_END_AT, + RecruitType.OPEN, null, false, List.of(), List.of(), LocalDateTime.now()); + EventUpdateCommand invalidCapacity = new EventUpdateCommand( + "체육대회", "설명", null, EVENT_START_AT, EVENT_END_AT, APPLY_START_AT, APPLY_END_AT, + RecruitType.FIRST_COME, 0, false, List.of(), List.of(), LocalDateTime.now()); + EventUpdateCommand invalidQuestion = new EventUpdateCommand( + "체육대회", "설명", null, EVENT_START_AT, EVENT_END_AT, APPLY_START_AT, APPLY_END_AT, + RecruitType.OPEN, null, false, List.of(), List.of(question(QuestionType.SINGLE_CHOICE, List.of())), + LocalDateTime.now()); + + assertRejected(EventErrorCode.EVENT_INVALID_PERIOD, () -> validator.validate(invalidPeriod)); + assertRejected(EventErrorCode.EVENT_INVALID_CAPACITY, () -> validator.validate(invalidCapacity)); + assertRejected(EventErrorCode.EVENT_INVALID_QUESTION, () -> validator.validate(invalidQuestion)); + } + } +} From ce7fdab409ca011c6fa5528af972be38f23bd96b Mon Sep 17 00:00:00 2001 From: gain <163835628+leegain1@users.noreply.github.com> Date: Wed, 28 Oct 2026 14:27:37 +0900 Subject: [PATCH 08/19] =?UTF-8?q?chore:=20=ED=96=89=EC=82=AC=20=EC=8B=A0?= =?UTF-8?q?=EC=B2=AD=20=EC=A0=9C=ED=95=9C=C2=B7=EA=B8=B0=ED=83=80=20?= =?UTF-8?q?=EC=84=A0=ED=83=9D=EC=A7=80=20=EB=A7=88=EC=9D=B4=EA=B7=B8?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=85=98=20=EB=B2=84=EC=A0=84=EC=9D=84=20V22?= =?UTF-8?q?=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ... => V22__add_fee_paid_only_and_has_other_option_to_events.sql} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename infrastructure/db/src/main/resources/db/migration/{V19__add_fee_paid_only_and_has_other_option_to_events.sql => V22__add_fee_paid_only_and_has_other_option_to_events.sql} (100%) diff --git a/infrastructure/db/src/main/resources/db/migration/V19__add_fee_paid_only_and_has_other_option_to_events.sql b/infrastructure/db/src/main/resources/db/migration/V22__add_fee_paid_only_and_has_other_option_to_events.sql similarity index 100% rename from infrastructure/db/src/main/resources/db/migration/V19__add_fee_paid_only_and_has_other_option_to_events.sql rename to infrastructure/db/src/main/resources/db/migration/V22__add_fee_paid_only_and_has_other_option_to_events.sql From 6e4c4b63d964afb5c01ee7c150bf369b20a68079 Mon Sep 17 00:00:00 2001 From: gain <163835628+leegain1@users.noreply.github.com> Date: Wed, 28 Oct 2026 14:28:34 +0900 Subject: [PATCH 09/19] =?UTF-8?q?test:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=ED=96=89=EC=82=AC=20=EC=84=9C=EB=B9=84=EC=8A=A4=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=EC=9D=98=20=EA=B0=80=EC=A7=9C=20=EC=A0=80?= =?UTF-8?q?=EC=9E=A5=EC=86=8C=EC=97=90=20=EC=95=8C=EB=A6=BC=20=EB=8C=80?= =?UTF-8?q?=EC=83=81=20=EC=A1=B0=ED=9A=8C=20=EB=A9=94=EC=84=9C=EB=93=9C=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/impl/EventServiceImplManageTest.java | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImplManageTest.java b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImplManageTest.java index b4285436..0f7c4637 100644 --- a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImplManageTest.java +++ b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImplManageTest.java @@ -482,6 +482,21 @@ public List findPublishedSlice( throw new UnsupportedOperationException(); } + @Override + public List findPublishedStartingBetween(LocalDateTime from, LocalDateTime to) { + throw new UnsupportedOperationException(); + } + + @Override + public List findPublishedApplyEndingBetween(LocalDateTime from, LocalDateTime to) { + throw new UnsupportedOperationException(); + } + + @Override + public List findAppliedMemberIds(Long eventId) { + throw new UnsupportedOperationException(); + } + @Override public boolean existsAppliedByEventIdAndMemberId(Long eventId, Long memberId) { throw new UnsupportedOperationException(); From d1fc1133042af28496328c5159cfc9ff753244e9 Mon Sep 17 00:00:00 2001 From: gain <163835628+leegain1@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:01:12 +0900 Subject: [PATCH 10/19] =?UTF-8?q?fix:=20=EC=9A=B4=EC=98=81=EC=A7=84=20?= =?UTF-8?q?=ED=96=89=EC=82=AC=20=EA=B2=80=EC=83=89=EC=96=B4=EC=9D=98=20LIK?= =?UTF-8?q?E=20=EC=99=80=EC=9D=BC=EB=93=9C=EC=B9=B4=EB=93=9C=EB=A5=BC=20?= =?UTF-8?q?=EC=9D=B4=EC=8A=A4=EC=BC=80=EC=9D=B4=ED=94=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../stream/api/admin/event/event/AdminEventController.java | 2 +- .../api/admin/event/event/request/EventSearchParams.java | 5 ----- .../event/domain/event/repository/EventRepository.java | 3 ++- .../stream/event/domain/event/service/EventService.java | 3 ++- .../kr/ac/kookmin/stream/db/event/EventJpaRepository.java | 6 ++++-- .../kr/ac/kookmin/stream/db/event/EventRepositoryImpl.java | 3 ++- 6 files changed, 11 insertions(+), 11 deletions(-) diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/AdminEventController.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/AdminEventController.java index e6399076..4db43a86 100644 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/AdminEventController.java +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/AdminEventController.java @@ -57,7 +57,7 @@ public ApiResponse> getEvents( @Valid @ModelAttribute EventSearchParams params, @Valid @ModelAttribute PageParams pageParams ) { - PageResult events = eventService.getEvents(params.toKeyword(), pageParams.toOffset()); + PageResult events = eventService.getEvents(params.keyword(), pageParams.toOffset()); Map applicantCounts = eventService.getApplicantCounts( events.content().stream().map(Event::getId).toList()); LocalDateTime now = LocalDateTime.now(); diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/request/EventSearchParams.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/request/EventSearchParams.java index 86cfeb9d..8a615475 100644 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/request/EventSearchParams.java +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/request/EventSearchParams.java @@ -6,9 +6,4 @@ public record EventSearchParams( @Schema(description = "행사 제목 검색어", example = "체육대회") String keyword ) { - - // 빈 검색어는 검색하지 않는 것으로 본다 - public String toKeyword() { - return keyword == null || keyword.isBlank() ? null : keyword.strip(); - } } diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/repository/EventRepository.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/repository/EventRepository.java index 99658509..cf64f4cd 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/repository/EventRepository.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/repository/EventRepository.java @@ -39,7 +39,8 @@ public interface EventRepository { /** * 삭제되지 않은 행사를 공개 여부와 상관없이 최근 등록 순(eventId 내림차순)으로 페이지 조회한다. 운영진 목록용이다. * - * @param keyword 제목 부분 일치 검색어. {@code null}이면 거르지 않는다 + * @param keyword 제목 부분 일치 검색어. null이거나 공백이면 거르지 않는다. 앞뒤 공백은 지우고 {@code %}·{@code _}는 글자 + * 그대로 검색한다 */ PageResult findPage(String keyword, PageOffset pageOffset); diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/EventService.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/EventService.java index b6dfc05e..6890de37 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/EventService.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/EventService.java @@ -29,7 +29,8 @@ public interface EventService { /** * 운영진 행사 목록. 비공개 행사도 담는다. 신청자 수는 {@link #getApplicantCounts}로 따로 조회해 조합한다. * - * @param keyword 제목 부분 일치 검색어. {@code null}이면 거르지 않는다 + * @param keyword 제목 부분 일치 검색어. null이거나 공백이면 거르지 않는다. 앞뒤 공백은 지우고 {@code %}·{@code _}는 글자 + * 그대로 검색한다 */ PageResult getEvents(String keyword, PageOffset pageOffset); diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventJpaRepository.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventJpaRepository.java index 145f4ad6..5aeaec5a 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventJpaRepository.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventJpaRepository.java @@ -20,18 +20,20 @@ public interface EventJpaRepository extends JpaRepository /** * 삭제되지 않은 행사를 공개 여부와 상관없이 최근 등록 순으로 페이지 조회한다. 운영진 목록용이다. + *

+ * keyword는 LikePatterns.escape로 이스케이프한 검색어다. 쿼리의 ESCAPE 문자는 LikePatterns.ESCAPE와 같아야 한다. */ @Query( value = """ SELECT e FROM EventJpaEntity e WHERE e.isDeleted = false - AND (:keyword IS NULL OR e.title LIKE CONCAT('%', :keyword, '%')) + AND (:keyword IS NULL OR e.title LIKE CONCAT('%', :keyword, '%') ESCAPE '!') ORDER BY e.id DESC """, countQuery = """ SELECT COUNT(e) FROM EventJpaEntity e WHERE e.isDeleted = false - AND (:keyword IS NULL OR e.title LIKE CONCAT('%', :keyword, '%')) + AND (:keyword IS NULL OR e.title LIKE CONCAT('%', :keyword, '%') ESCAPE '!') """ ) Page search(@Param("keyword") String keyword, Pageable pageable); diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventRepositoryImpl.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventRepositoryImpl.java index 27554f73..a9228b1a 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventRepositoryImpl.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventRepositoryImpl.java @@ -7,6 +7,7 @@ import java.util.stream.Collectors; import kr.ac.kookmin.stream.common.PageOffset; import kr.ac.kookmin.stream.common.PageResult; +import kr.ac.kookmin.stream.db.common.LikePatterns; import kr.ac.kookmin.stream.event.domain.event.domain.Event; import kr.ac.kookmin.stream.event.domain.event.domain.EventApplication; import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationAnswer; @@ -66,7 +67,7 @@ public void touchUpdatedAt(Long eventId) { @Override public PageResult findPage(String keyword, PageOffset pageOffset) { Page result = eventJpaRepository.search( - keyword, PageRequest.of(pageOffset.page(), pageOffset.size()) + LikePatterns.escape(keyword), PageRequest.of(pageOffset.page(), pageOffset.size()) ); return PageResult.of( From 2d697f8764245a94aa58871062e4cee51e111480 Mon Sep 17 00:00:00 2001 From: gain <163835628+leegain1@users.noreply.github.com> Date: Wed, 28 Oct 2026 15:01:15 +0900 Subject: [PATCH 11/19] =?UTF-8?q?refactor:=20=EC=9A=B4=EC=98=81=EC=A7=84?= =?UTF-8?q?=20=ED=96=89=EC=82=AC=20=EA=B8=B0=EB=8A=A5=EC=9D=84=20AdminEven?= =?UTF-8?q?tService=EB=A1=9C=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../event/event/AdminEventController.java | 10 +- .../event/usecase/AdminEventWriteUseCase.java | 8 +- .../event/service/AdminEventService.java | 45 +++++++ .../domain/event/service/EventService.java | 41 +----- .../service/impl/AdminEventServiceImpl.java | 122 ++++++++++++++++++ .../event/service/impl/EventServiceImpl.java | 102 --------------- ...st.java => AdminEventServiceImplTest.java} | 4 +- .../service/impl/EventServiceImplTest.java | 12 +- 8 files changed, 188 insertions(+), 156 deletions(-) create mode 100644 core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/AdminEventService.java create mode 100644 core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/AdminEventServiceImpl.java rename core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/{EventServiceImplManageTest.java => AdminEventServiceImplTest.java} (99%) diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/AdminEventController.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/AdminEventController.java index 4db43a86..be2248b3 100644 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/AdminEventController.java +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/AdminEventController.java @@ -18,7 +18,7 @@ import kr.ac.kookmin.stream.event.domain.event.domain.Event; import kr.ac.kookmin.stream.common.PageResult; import kr.ac.kookmin.stream.event.domain.event.domain.EventManageDetail; -import kr.ac.kookmin.stream.event.domain.event.service.EventService; +import kr.ac.kookmin.stream.event.domain.event.service.AdminEventService; import kr.ac.kookmin.stream.file.domain.File; import kr.ac.kookmin.stream.file.service.FileService; import lombok.RequiredArgsConstructor; @@ -36,7 +36,7 @@ @RequiredArgsConstructor public class AdminEventController implements AdminEventApi { - private final EventService eventService; + private final AdminEventService adminEventService; private final FileService fileService; private final AdminEventWriteUseCase adminEventWriteUseCase; @@ -57,8 +57,8 @@ public ApiResponse> getEvents( @Valid @ModelAttribute EventSearchParams params, @Valid @ModelAttribute PageParams pageParams ) { - PageResult events = eventService.getEvents(params.keyword(), pageParams.toOffset()); - Map applicantCounts = eventService.getApplicantCounts( + PageResult events = adminEventService.getEvents(params.keyword(), pageParams.toOffset()); + Map applicantCounts = adminEventService.getApplicantCounts( events.content().stream().map(Event::getId).toList()); LocalDateTime now = LocalDateTime.now(); @@ -69,7 +69,7 @@ public ApiResponse> getEvents( @Override @GetMapping("/{eventId}") public ApiResponse getEvent(AdminApiUser apiUser, @PathVariable Long eventId) { - EventManageDetail detail = eventService.getEvent(eventId); + EventManageDetail detail = adminEventService.getEvent(eventId); Map imagesById = fileService.findAllByIdIn(detail.imageIds()); return ApiResponse.success(AdminEventDetailResponse.of(detail, imagesById, LocalDateTime.now())); diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/usecase/AdminEventWriteUseCase.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/usecase/AdminEventWriteUseCase.java index 0ca98572..fb01f479 100644 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/usecase/AdminEventWriteUseCase.java +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/usecase/AdminEventWriteUseCase.java @@ -5,7 +5,7 @@ import kr.ac.kookmin.stream.event.domain.event.domain.Event; import kr.ac.kookmin.stream.event.domain.event.domain.EventCreateCommand; import kr.ac.kookmin.stream.event.domain.event.domain.EventUpdateCommand; -import kr.ac.kookmin.stream.event.domain.event.service.EventService; +import kr.ac.kookmin.stream.event.domain.event.service.AdminEventService; import kr.ac.kookmin.stream.file.domain.FileErrorCode; import kr.ac.kookmin.stream.file.service.FileService; import lombok.RequiredArgsConstructor; @@ -22,17 +22,17 @@ @RequiredArgsConstructor public class AdminEventWriteUseCase { - private final EventService eventService; + private final AdminEventService adminEventService; private final FileService fileService; public Event create(Long createdBy, EventCreateCommand command) { requireImagesExist(command.imageIds()); - return eventService.create(createdBy, command); + return adminEventService.create(createdBy, command); } public Event update(Long eventId, EventUpdateCommand command) { requireImagesExist(command.imageIds()); - return eventService.update(eventId, command); + return adminEventService.update(eventId, command); } // 요청 DTO가 중복을 걸러 넘기므로 조회 결과 개수와 바로 비교할 수 있다 diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/AdminEventService.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/AdminEventService.java new file mode 100644 index 00000000..d308bf74 --- /dev/null +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/AdminEventService.java @@ -0,0 +1,45 @@ +package kr.ac.kookmin.stream.event.domain.event.service; + +import java.util.List; +import java.util.Map; +import kr.ac.kookmin.stream.common.PageOffset; +import kr.ac.kookmin.stream.common.PageResult; +import kr.ac.kookmin.stream.event.domain.event.domain.Event; +import kr.ac.kookmin.stream.event.domain.event.domain.EventCreateCommand; +import kr.ac.kookmin.stream.event.domain.event.domain.EventManageDetail; +import kr.ac.kookmin.stream.event.domain.event.domain.EventUpdateCommand; + +/** + * 운영진용 행사 서비스. 비공개 행사를 포함한 목록·상세 조회와 등록·수정을 맡는다. 학생 앱의 공개 행사 조회는 + * {@link EventService}가 맡는다. + */ +public interface AdminEventService { + + /** + * 운영진이 행사를 등록한다. 신청서 질문도 함께 저장하며, 등록한 행사는 비공개로 시작한다. + */ + Event create(Long createdBy, EventCreateCommand command); + + /** + * 운영진 행사 목록. 비공개 행사도 담는다. 신청자 수는 {@link #getApplicantCounts}로 따로 조회해 조합한다. + * + * @param keyword 제목 부분 일치 검색어. null이거나 공백이면 거르지 않는다. 앞뒤 공백은 지우고 {@code %}·{@code _}는 글자 + * 그대로 검색한다 + */ + PageResult getEvents(String keyword, PageOffset pageOffset); + + /** + * 행사별 유효(APPLIED) 신청자 수. 신청이 한 건도 없는 행사는 결과에 담기지 않는다. + */ + Map getApplicantCounts(List eventIds); + + /** 운영진 행사 상세. 비공개 행사도 조회한다. */ + EventManageDetail getEvent(Long eventId); + + /** + * 운영진이 행사 내용을 통째로 바꾼다. 수정 화면이 읽은 뒤 다른 운영진이 먼저 고쳤다면 + * {@code EVENT_UPDATE_CONFLICT}로 거부한다. 신청이 있는 행사는 신청서 질문을 바꿀 수 없고, + * 선착순 정원을 현재 신청자 수보다 줄일 수 없다. + */ + Event update(Long eventId, EventUpdateCommand command); +} diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/EventService.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/EventService.java index 6890de37..416a387e 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/EventService.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/EventService.java @@ -1,51 +1,18 @@ package kr.ac.kookmin.stream.event.domain.event.service; -import java.util.List; -import java.util.Map; import kr.ac.kookmin.stream.common.CursorSliceResult; -import kr.ac.kookmin.stream.common.PageOffset; -import kr.ac.kookmin.stream.common.PageResult; -import kr.ac.kookmin.stream.event.domain.event.domain.Event; -import kr.ac.kookmin.stream.event.domain.event.domain.EventCreateCommand; import kr.ac.kookmin.stream.event.domain.event.domain.EventCursor; import kr.ac.kookmin.stream.event.domain.event.domain.EventDetail; -import kr.ac.kookmin.stream.event.domain.event.domain.EventManageDetail; import kr.ac.kookmin.stream.event.domain.event.domain.EventSummary; -import kr.ac.kookmin.stream.event.domain.event.domain.EventUpdateCommand; import kr.ac.kookmin.stream.event.domain.event.domain.RecruitStatus; -/** 행사 자체의 조회와 운영진의 등록·수정. 신청·취소는 {@link EventApplicationService}가 맡는다. */ +/** + * 학생 앱의 공개 행사 조회. 운영진의 조회·등록·수정은 {@link AdminEventService}, 신청·취소는 + * {@link EventApplicationService}가 맡는다. + */ public interface EventService { CursorSliceResult getPublishedEvents(RecruitStatus recruitStatus, EventCursor cursor, int size); EventDetail getPublishedEvent(Long eventId); - - /** - * 운영진이 행사를 등록한다. 신청서 질문도 함께 저장하며, 등록한 행사는 비공개로 시작한다. - */ - Event create(Long createdBy, EventCreateCommand command); - - /** - * 운영진 행사 목록. 비공개 행사도 담는다. 신청자 수는 {@link #getApplicantCounts}로 따로 조회해 조합한다. - * - * @param keyword 제목 부분 일치 검색어. null이거나 공백이면 거르지 않는다. 앞뒤 공백은 지우고 {@code %}·{@code _}는 글자 - * 그대로 검색한다 - */ - PageResult getEvents(String keyword, PageOffset pageOffset); - - /** - * 행사별 유효(APPLIED) 신청자 수. 신청이 한 건도 없는 행사는 결과에 담기지 않는다. - */ - Map getApplicantCounts(List eventIds); - - /** 운영진 행사 상세. 비공개 행사도 조회한다. */ - EventManageDetail getEvent(Long eventId); - - /** - * 운영진이 행사 내용을 통째로 바꾼다. 수정 화면이 읽은 뒤 다른 운영진이 먼저 고쳤다면 - * {@code EVENT_UPDATE_CONFLICT}로 거부한다. 신청이 있는 행사는 신청서 질문을 바꿀 수 없고, - * 선착순 정원을 현재 신청자 수보다 줄일 수 없다. - */ - Event update(Long eventId, EventUpdateCommand command); } diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/AdminEventServiceImpl.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/AdminEventServiceImpl.java new file mode 100644 index 00000000..5fdb9af1 --- /dev/null +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/AdminEventServiceImpl.java @@ -0,0 +1,122 @@ +package kr.ac.kookmin.stream.event.domain.event.service.impl; + +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.Map; +import java.util.stream.IntStream; +import kr.ac.kookmin.stream.common.BusinessException; +import kr.ac.kookmin.stream.common.PageOffset; +import kr.ac.kookmin.stream.common.PageResult; +import kr.ac.kookmin.stream.event.domain.event.domain.Event; +import kr.ac.kookmin.stream.event.domain.event.domain.EventCreateCommand; +import kr.ac.kookmin.stream.event.domain.event.domain.EventErrorCode; +import kr.ac.kookmin.stream.event.domain.event.domain.EventManageDetail; +import kr.ac.kookmin.stream.event.domain.event.domain.EventQuestion; +import kr.ac.kookmin.stream.event.domain.event.domain.EventQuestionCommand; +import kr.ac.kookmin.stream.event.domain.event.domain.EventUpdateCommand; +import kr.ac.kookmin.stream.event.domain.event.domain.RecruitType; +import kr.ac.kookmin.stream.event.domain.event.repository.EventRepository; +import kr.ac.kookmin.stream.event.domain.event.service.AdminEventService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +class AdminEventServiceImpl implements AdminEventService { + + private final EventRepository eventRepository; + private final EventWriteValidator eventWriteValidator; + + @Override + @Transactional + public Event create(Long createdBy, EventCreateCommand command) { + eventWriteValidator.validate(command); + + Event event = eventRepository.save(Event.create(createdBy, command)); + eventRepository.saveQuestions(toQuestions(event.getId(), command.questions())); + return event; + } + + @Override + @Transactional(readOnly = true) + public PageResult getEvents(String keyword, PageOffset pageOffset) { + return eventRepository.findPage(keyword, pageOffset); + } + + @Override + @Transactional(readOnly = true) + public Map getApplicantCounts(List eventIds) { + return eventRepository.countAppliedByEventIds(eventIds); + } + + @Override + @Transactional(readOnly = true) + public EventManageDetail getEvent(Long eventId) { + Event event = getEventOrThrow(eventId); + return new EventManageDetail( + event, + eventRepository.findQuestionsByEventId(eventId), + eventRepository.countAppliedByEventId(eventId) + ); + } + + @Override + @Transactional + public Event update(Long eventId, EventUpdateCommand command) { + Event event = getEventOrThrow(eventId); + validateNotModified(event, command.expectedUpdatedAt()); + eventWriteValidator.validate(command); + validateCapacityNotBelowApplicants(eventId, command); + + List questions = toQuestions(eventId, command.questions()); + if (questionsChanged(eventRepository.findQuestionsByEventId(eventId), questions)) { + // 답변이 질문 id·선택지 순서로 저장돼 있어 신청이 생긴 뒤 바꾸면 기존 답변의 뜻이 달라진다 + if (eventRepository.existsApplicationByEventId(eventId)) { + throw new BusinessException(EventErrorCode.EVENT_QUESTIONS_LOCKED); + } + eventRepository.replaceQuestions(eventId, questions); + eventRepository.touchUpdatedAt(eventId); + } + + event.update(command); + return eventRepository.save(event); + } + + private Event getEventOrThrow(Long eventId) { + return eventRepository.findById(eventId) + .orElseThrow(() -> new BusinessException(EventErrorCode.EVENT_NOT_FOUND)); + } + + // updated_at이 초 단위 DATETIME이라 같은 초로 맞춰 비교한다. 수정 화면이 읽은 뒤 고쳐졌다면 시각이 달라진다 + private void validateNotModified(Event event, LocalDateTime expectedUpdatedAt) { + LocalDateTime current = event.getUpdatedAt().truncatedTo(ChronoUnit.SECONDS); + if (!current.equals(expectedUpdatedAt.truncatedTo(ChronoUnit.SECONDS))) { + throw new BusinessException(EventErrorCode.EVENT_UPDATE_CONFLICT); + } + } + + // 이미 받은 신청을 정원 밖으로 밀어낼 수는 없다. 상시 모집은 인원 제한이 없어 보지 않는다 + private void validateCapacityNotBelowApplicants(Long eventId, EventUpdateCommand command) { + if (command.recruitType() == RecruitType.FIRST_COME + && command.capacity() < eventRepository.countAppliedByEventId(eventId)) { + throw new BusinessException(EventErrorCode.EVENT_CAPACITY_BELOW_APPLICANTS); + } + } + + private static boolean questionsChanged(List current, List requested) { + if (current.size() != requested.size()) { + return true; + } + return IntStream.range(0, current.size()) + .anyMatch(index -> !current.get(index).hasSameContentAs(requested.get(index))); + } + + // 신청서에 보이는 순서는 요청 목록의 순서를 따른다 + private static List toQuestions(Long eventId, List commands) { + return IntStream.range(0, commands.size()) + .mapToObj(index -> EventQuestion.create(eventId, index + 1, commands.get(index))) + .toList(); + } +} diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImpl.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImpl.java index 6842679e..cc42849a 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImpl.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImpl.java @@ -1,26 +1,16 @@ package kr.ac.kookmin.stream.event.domain.event.service.impl; import java.time.LocalDateTime; -import java.time.temporal.ChronoUnit; import java.util.List; import java.util.Map; -import java.util.stream.IntStream; import kr.ac.kookmin.stream.common.BusinessException; import kr.ac.kookmin.stream.common.CursorSliceResult; -import kr.ac.kookmin.stream.common.PageOffset; -import kr.ac.kookmin.stream.common.PageResult; import kr.ac.kookmin.stream.event.domain.event.domain.Event; -import kr.ac.kookmin.stream.event.domain.event.domain.EventCreateCommand; import kr.ac.kookmin.stream.event.domain.event.domain.EventCursor; import kr.ac.kookmin.stream.event.domain.event.domain.EventDetail; import kr.ac.kookmin.stream.event.domain.event.domain.EventErrorCode; -import kr.ac.kookmin.stream.event.domain.event.domain.EventManageDetail; -import kr.ac.kookmin.stream.event.domain.event.domain.EventQuestion; -import kr.ac.kookmin.stream.event.domain.event.domain.EventQuestionCommand; import kr.ac.kookmin.stream.event.domain.event.domain.EventSummary; -import kr.ac.kookmin.stream.event.domain.event.domain.EventUpdateCommand; import kr.ac.kookmin.stream.event.domain.event.domain.RecruitStatus; -import kr.ac.kookmin.stream.event.domain.event.domain.RecruitType; import kr.ac.kookmin.stream.event.domain.event.repository.EventRepository; import kr.ac.kookmin.stream.event.domain.event.service.EventService; import lombok.RequiredArgsConstructor; @@ -32,7 +22,6 @@ class EventServiceImpl implements EventService { private final EventRepository eventRepository; - private final EventWriteValidator eventWriteValidator; @Override @Transactional(readOnly = true) @@ -67,95 +56,4 @@ public EventDetail getPublishedEvent(Long eventId) { LocalDateTime now = LocalDateTime.now(); return EventDetail.of(event, eventRepository.countAppliedByEventId(eventId), now); } - - @Override - @Transactional - public Event create(Long createdBy, EventCreateCommand command) { - eventWriteValidator.validate(command); - - Event event = eventRepository.save(Event.create(createdBy, command)); - eventRepository.saveQuestions(toQuestions(event.getId(), command.questions())); - return event; - } - - @Override - @Transactional(readOnly = true) - public PageResult getEvents(String keyword, PageOffset pageOffset) { - return eventRepository.findPage(keyword, pageOffset); - } - - @Override - @Transactional(readOnly = true) - public Map getApplicantCounts(List eventIds) { - return eventRepository.countAppliedByEventIds(eventIds); - } - - @Override - @Transactional(readOnly = true) - public EventManageDetail getEvent(Long eventId) { - Event event = getEventOrThrow(eventId); - return new EventManageDetail( - event, - eventRepository.findQuestionsByEventId(eventId), - eventRepository.countAppliedByEventId(eventId) - ); - } - - @Override - @Transactional - public Event update(Long eventId, EventUpdateCommand command) { - Event event = getEventOrThrow(eventId); - validateNotModified(event, command.expectedUpdatedAt()); - eventWriteValidator.validate(command); - validateCapacityNotBelowApplicants(eventId, command); - - List questions = toQuestions(eventId, command.questions()); - if (questionsChanged(eventRepository.findQuestionsByEventId(eventId), questions)) { - // 답변이 질문 id·선택지 순서로 저장돼 있어 신청이 생긴 뒤 바꾸면 기존 답변의 뜻이 달라진다 - if (eventRepository.existsApplicationByEventId(eventId)) { - throw new BusinessException(EventErrorCode.EVENT_QUESTIONS_LOCKED); - } - eventRepository.replaceQuestions(eventId, questions); - eventRepository.touchUpdatedAt(eventId); - } - - event.update(command); - return eventRepository.save(event); - } - - private Event getEventOrThrow(Long eventId) { - return eventRepository.findById(eventId) - .orElseThrow(() -> new BusinessException(EventErrorCode.EVENT_NOT_FOUND)); - } - - // updated_at이 초 단위 DATETIME이라 같은 초로 맞춰 비교한다. 수정 화면이 읽은 뒤 고쳐졌다면 시각이 달라진다 - private void validateNotModified(Event event, LocalDateTime expectedUpdatedAt) { - LocalDateTime current = event.getUpdatedAt().truncatedTo(ChronoUnit.SECONDS); - if (!current.equals(expectedUpdatedAt.truncatedTo(ChronoUnit.SECONDS))) { - throw new BusinessException(EventErrorCode.EVENT_UPDATE_CONFLICT); - } - } - - // 이미 받은 신청을 정원 밖으로 밀어낼 수는 없다. 상시 모집은 인원 제한이 없어 보지 않는다 - private void validateCapacityNotBelowApplicants(Long eventId, EventUpdateCommand command) { - if (command.recruitType() == RecruitType.FIRST_COME - && command.capacity() < eventRepository.countAppliedByEventId(eventId)) { - throw new BusinessException(EventErrorCode.EVENT_CAPACITY_BELOW_APPLICANTS); - } - } - - private static boolean questionsChanged(List current, List requested) { - if (current.size() != requested.size()) { - return true; - } - return IntStream.range(0, current.size()) - .anyMatch(index -> !current.get(index).hasSameContentAs(requested.get(index))); - } - - // 신청서에 보이는 순서는 요청 목록의 순서를 따른다 - private static List toQuestions(Long eventId, List commands) { - return IntStream.range(0, commands.size()) - .mapToObj(index -> EventQuestion.create(eventId, index + 1, commands.get(index))) - .toList(); - } } diff --git a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImplManageTest.java b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/AdminEventServiceImplTest.java similarity index 99% rename from core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImplManageTest.java rename to core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/AdminEventServiceImplTest.java index 0f7c4637..6623dea1 100644 --- a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImplManageTest.java +++ b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/AdminEventServiceImplTest.java @@ -40,7 +40,7 @@ * 운영진 행사 등록·조회·수정. 형식 규칙 자체는 {@code EventWriteValidatorTest}가 덮고, * 여기서는 저장 흐름과 수정 시의 규칙(동시 수정·정원 하한·질문 잠금)을 본다. */ -class EventServiceImplManageTest { +class AdminEventServiceImplTest { private static final Long EVENT_ID = 15L; private static final LocalDateTime UPDATED_AT = LocalDateTime.of(2026, 10, 10, 12, 30, 15); @@ -55,7 +55,7 @@ class EventServiceImplManageTest { "전화번호", QuestionType.SHORT_TEXT, true, List.of(), false); private final InMemoryEventRepository repository = new InMemoryEventRepository(); - private final EventServiceImpl service = new EventServiceImpl(repository, new EventWriteValidator()); + private final AdminEventServiceImpl service = new AdminEventServiceImpl(repository, new EventWriteValidator()); private static Event savedEvent(RecruitType recruitType, int capacity) { return Event.of( diff --git a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImplTest.java b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImplTest.java index 656e37d8..c7bb8b70 100644 --- a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImplTest.java +++ b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImplTest.java @@ -47,7 +47,7 @@ void matchesApplicantCountToItsEvent() { List.of(event(1L), event(2L)), Map.of(1L, 3L, 2L, 7L)); - List content = new EventServiceImpl(repository, new EventWriteValidator()) + List content = new EventServiceImpl(repository) .getPublishedEvents(null, null, 20).content(); // 정원 100명이라 신청자 수가 모집 상태를 바꾸지 않는다. 짝이 맞는지는 D-Day가 아니라 아래에서 직접 본다 @@ -63,7 +63,7 @@ void keepsEventsWithoutApplicants() { List.of(event(1L), event(2L)), Map.of(1L, 3L)); - List content = new EventServiceImpl(repository, new EventWriteValidator()) + List content = new EventServiceImpl(repository) .getPublishedEvents(null, null, 20).content(); assertEquals(2, content.size()); @@ -86,7 +86,7 @@ void appliesCountPerEvent() { List.of(full, roomy), Map.of(1L, 3L, 2L, 3L)); - List content = new EventServiceImpl(repository, new EventWriteValidator()) + List content = new EventServiceImpl(repository) .getPublishedEvents(null, null, 20).content(); assertEquals(RecruitStatus.CLOSED, content.get(0).recruitStatus()); @@ -99,7 +99,7 @@ void detectsNextPage() { FakeEventRepository repository = new FakeEventRepository( List.of(event(1L), event(2L), event(3L)), Map.of()); - CursorSliceResult result = new EventServiceImpl(repository, new EventWriteValidator()) + CursorSliceResult result = new EventServiceImpl(repository) .getPublishedEvents(null, null, 2); assertEquals(3, repository.requestedLimit); @@ -113,7 +113,7 @@ void detectsNextPage() { void noCursorOnLastPage() { FakeEventRepository repository = new FakeEventRepository(List.of(event(1L), event(2L)), Map.of()); - CursorSliceResult result = new EventServiceImpl(repository, new EventWriteValidator()) + CursorSliceResult result = new EventServiceImpl(repository) .getPublishedEvents(null, null, 2); assertFalse(result.hasNext()); @@ -126,7 +126,7 @@ void asksNothingWhenSliceIsEmpty() { FakeEventRepository repository = new FakeEventRepository( List.of(), Map.of()); - CursorSliceResult result = new EventServiceImpl(repository, new EventWriteValidator()) + CursorSliceResult result = new EventServiceImpl(repository) .getPublishedEvents(null, null, 20); assertTrue(result.content().isEmpty()); From 47ccc216d845c6847ebd0fcbfef17d5070d25623 Mon Sep 17 00:00:00 2001 From: gain <163835628+leegain1@users.noreply.github.com> Date: Wed, 28 Oct 2026 16:49:03 +0900 Subject: [PATCH 12/19] =?UTF-8?q?refactor:=20=ED=96=89=EC=82=AC=20?= =?UTF-8?q?=EA=B8=B0=EA=B0=84=EA=B3=BC=20=EB=AA=A8=EC=A7=91=20=EC=A0=95?= =?UTF-8?q?=EC=9B=90=EC=9D=84=20EventPeriod=C2=B7RecruitPolicy=20VO?= =?UTF-8?q?=EB=A1=9C=20=EB=AC=B6=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../event/request/EventCreateRequest.java | 11 +- .../event/request/EventUpdateRequest.java | 12 +- .../event/domain/event/domain/Event.java | 82 ++++++---- .../event/domain/EventCreateCommand.java | 13 +- .../domain/event/domain/EventPeriod.java | 24 +++ .../event/domain/EventUpdateCommand.java | 10 +- .../domain/event/domain/RecruitPolicy.java | 47 ++++++ .../service/impl/AdminEventServiceImpl.java | 11 +- .../service/impl/EventWriteValidator.java | 24 +-- .../domain/event/domain/EventPeriodTest.java | 38 +++++ .../event/domain/event/domain/EventTest.java | 11 +- .../event/domain/RecruitPolicyTest.java | 42 +++++ .../impl/AdminEventServiceImplTest.java | 31 ++-- .../service/impl/EventWriteValidatorTest.java | 144 ++++-------------- 14 files changed, 271 insertions(+), 229 deletions(-) create mode 100644 core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventPeriod.java create mode 100644 core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/RecruitPolicy.java create mode 100644 core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventPeriodTest.java create mode 100644 core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/RecruitPolicyTest.java diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/request/EventCreateRequest.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/request/EventCreateRequest.java index d8509ffc..8615fe7e 100644 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/request/EventCreateRequest.java +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/request/EventCreateRequest.java @@ -8,6 +8,8 @@ import java.time.LocalDateTime; import java.util.List; import kr.ac.kookmin.stream.event.domain.event.domain.EventCreateCommand; +import kr.ac.kookmin.stream.event.domain.event.domain.EventPeriod; +import kr.ac.kookmin.stream.event.domain.event.domain.RecruitPolicy; import kr.ac.kookmin.stream.event.domain.event.domain.RecruitType; /** @@ -72,12 +74,9 @@ public EventCreateCommand toCommand() { title, description, place, - eventStartAt, - eventEndAt, - applyStartAt, - applyEndAt, - recruitType, - capacity, + EventPeriod.of(eventStartAt, eventEndAt), + EventPeriod.of(applyStartAt, applyEndAt), + RecruitPolicy.of(recruitType, capacity), feePaidOnly, imageIds.stream().distinct().toList(), questions.stream().map(EventQuestionRequest::toCommand).toList() diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/request/EventUpdateRequest.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/request/EventUpdateRequest.java index 8a0722a9..5225d77a 100644 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/request/EventUpdateRequest.java +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/request/EventUpdateRequest.java @@ -9,7 +9,9 @@ import java.time.LocalDateTime; import java.util.List; import kr.ac.kookmin.stream.common.DateUtil; +import kr.ac.kookmin.stream.event.domain.event.domain.EventPeriod; import kr.ac.kookmin.stream.event.domain.event.domain.EventUpdateCommand; +import kr.ac.kookmin.stream.event.domain.event.domain.RecruitPolicy; import kr.ac.kookmin.stream.event.domain.event.domain.RecruitType; /** @@ -74,17 +76,15 @@ public record EventUpdateRequest( Long updatedAt ) { + // 기간 순서·선착순 정원은 VO를 만들 때 검증한다. 입력만 보는 규칙이라 동시 수정(409)보다 먼저 400으로 거부된다 public EventUpdateCommand toCommand() { return new EventUpdateCommand( title, description, place, - eventStartAt, - eventEndAt, - applyStartAt, - applyEndAt, - recruitType, - capacity, + EventPeriod.of(eventStartAt, eventEndAt), + EventPeriod.of(applyStartAt, applyEndAt), + RecruitPolicy.of(recruitType, capacity), feePaidOnly, imageIds.stream().distinct().toList(), questions.stream().map(EventQuestionRequest::toCommand).toList(), diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/Event.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/Event.java index 6ab5f3e1..332b94be 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/Event.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/Event.java @@ -17,13 +17,10 @@ public class Event { private String title; private String description; private String place; - private LocalDateTime eventStartAt; - private LocalDateTime eventEndAt; - private LocalDateTime applyStartAt; - private LocalDateTime applyEndAt; - private RecruitType recruitType; + private EventPeriod eventPeriod; + private EventPeriod applyPeriod; + private RecruitPolicy recruitPolicy; private List imageIds; - private int capacity; /** 학생회비 납부자만 신청할 수 있는지. 신청 시 검증은 아직 없다(후속 작업) */ private boolean feePaidOnly; private boolean published; @@ -31,6 +28,10 @@ public class Event { /** 마지막 수정 시각. DB가 채우며, 운영진 동시 수정 감지에 쓴다 */ private LocalDateTime updatedAt; + /** + * DB에 저장된 값으로 행사를 복원한다. 기간·모집 값은 표준 생성자로 만들어 검증하지 않는다. 저장값은 등록·수정 때 이미 + * 검증했고, {@code event_end_at}이 NULL인 기존 행이 있어 검증하면 조회가 깨진다. + */ public static Event of( Long id, String title, @@ -49,14 +50,14 @@ public static Event of( LocalDateTime updatedAt ) { return new Event( - id, title, description, place, eventStartAt, eventEndAt, applyStartAt, - applyEndAt, recruitType, imageIds, capacity, feePaidOnly, published, createdBy, updatedAt + id, title, description, place, new EventPeriod(eventStartAt, eventEndAt), + new EventPeriod(applyStartAt, applyEndAt), new RecruitPolicy(recruitType, capacity), imageIds, + feePaidOnly, published, createdBy, updatedAt ); } /** - * 새 행사를 만든다. 학생에게 바로 보이지 않도록 비공개로 시작한다. 상시 모집은 정원이 의미 없어 0으로 둔다. - * 수정 시각은 DB가 채운다. + * 새 행사를 만든다. 학생에게 바로 보이지 않도록 비공개로 시작한다. 수정 시각은 DB가 채운다. */ public static Event create(Long createdBy, EventCreateCommand command) { return new Event( @@ -64,13 +65,10 @@ public static Event create(Long createdBy, EventCreateCommand command) { command.title(), command.description(), command.place(), - command.eventStartAt(), - command.eventEndAt(), - command.applyStartAt(), - command.applyEndAt(), - command.recruitType(), + command.eventPeriod(), + command.applyPeriod(), + command.recruitPolicy(), command.imageIds(), - capacityOf(command.recruitType(), command.capacity()), command.feePaidOnly(), false, createdBy, @@ -85,16 +83,38 @@ public void update(EventUpdateCommand command) { this.title = command.title(); this.description = command.description(); this.place = command.place(); - this.eventStartAt = command.eventStartAt(); - this.eventEndAt = command.eventEndAt(); - this.applyStartAt = command.applyStartAt(); - this.applyEndAt = command.applyEndAt(); - this.recruitType = command.recruitType(); + this.eventPeriod = command.eventPeriod(); + this.applyPeriod = command.applyPeriod(); + this.recruitPolicy = command.recruitPolicy(); this.imageIds = command.imageIds(); - this.capacity = capacityOf(command.recruitType(), command.capacity()); this.feePaidOnly = command.feePaidOnly(); } + // 기간·모집 값을 VO로 묶기 전부터 쓰던 getter다. 호출부가 VO 구조를 몰라도 되도록 남겨 둔다 + public LocalDateTime getEventStartAt() { + return eventPeriod.startAt(); + } + + public LocalDateTime getEventEndAt() { + return eventPeriod.endAt(); + } + + public LocalDateTime getApplyStartAt() { + return applyPeriod.startAt(); + } + + public LocalDateTime getApplyEndAt() { + return applyPeriod.endAt(); + } + + public RecruitType getRecruitType() { + return recruitPolicy.type(); + } + + public int getCapacity() { + return recruitPolicy.capacity(); + } + /** * 현재 시각과 신청자 수를 반영한 모집 상태를 계산한다. *

@@ -129,7 +149,7 @@ public Integer daysUntilDeadline(LocalDateTime now, RecruitStatus recruitStatus) if (recruitStatus != RecruitStatus.OPEN) { return null; } - return (int) ChronoUnit.DAYS.between(now.toLocalDate(), applyEndAt.toLocalDate()); + return (int) ChronoUnit.DAYS.between(now.toLocalDate(), applyPeriod.endAt().toLocalDate()); } /** @@ -140,17 +160,17 @@ public Long thumbnailFileId() { } /** - * 운영진 화면에 보여줄 정원. 상시 모집은 인원 제한이 없어 {@code null}이다(저장값 0은 의미가 없다). + * 운영진 화면에 보여줄 정원. 규칙은 {@link RecruitPolicy#capacityLimit()}를 따른다. */ public Integer capacityLimit() { - return recruitType == RecruitType.FIRST_COME ? capacity : null; + return recruitPolicy.capacityLimit(); } /** - * 정원이 찼는지 판정한다. 선착순 모집에만 정원 제한이 있고, 상시 모집은 인원 제한이 없다. + * 정원이 찼는지 판정한다. 규칙은 {@link RecruitPolicy#isFull(long)}을 따른다. */ public boolean isCapacityFull(long appliedCount) { - return recruitType == RecruitType.FIRST_COME && appliedCount >= capacity; + return recruitPolicy.isFull(appliedCount); } /** @@ -163,18 +183,14 @@ public boolean isClosedByCapacityOnly(LocalDateTime now, long appliedCount) { && isCapacityFull(appliedCount); } - private static int capacityOf(RecruitType recruitType, Integer capacity) { - return recruitType == RecruitType.FIRST_COME ? capacity : 0; - } - private boolean isBeforeApplyPeriod(LocalDateTime now) { - return now.isBefore(applyStartAt); + return now.isBefore(applyPeriod.startAt()); } /** * 신청 기간이 끝났는지. 모집 상태 계산뿐 아니라 신청 취소 가능 여부 판정에도 쓰인다. */ public boolean isAfterApplyPeriod(LocalDateTime now) { - return now.isAfter(applyEndAt); + return now.isAfter(applyPeriod.endAt()); } } diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventCreateCommand.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventCreateCommand.java index 2d63c3c1..21694f60 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventCreateCommand.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventCreateCommand.java @@ -1,21 +1,14 @@ package kr.ac.kookmin.stream.event.domain.event.domain; -import java.time.LocalDateTime; import java.util.List; -/** - * @param capacity 선착순 모집의 정원. 상시 모집이면 쓰이지 않는다 - */ public record EventCreateCommand( String title, String description, String place, - LocalDateTime eventStartAt, - LocalDateTime eventEndAt, - LocalDateTime applyStartAt, - LocalDateTime applyEndAt, - RecruitType recruitType, - Integer capacity, + EventPeriod eventPeriod, + EventPeriod applyPeriod, + RecruitPolicy recruitPolicy, boolean feePaidOnly, List imageIds, List questions diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventPeriod.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventPeriod.java new file mode 100644 index 00000000..dbbefe7e --- /dev/null +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventPeriod.java @@ -0,0 +1,24 @@ +package kr.ac.kookmin.stream.event.domain.event.domain; + +import java.time.LocalDateTime; +import kr.ac.kookmin.stream.common.BusinessException; + +/** + * 시작·종료 일시로 이루어진 기간. 행사 기간과 신청 기간에 함께 쓴다. + *

+ * 종료가 시작보다 늦어야 한다는 순서는 이 값 자체의 불변식이라 {@link #of}에서 검증한다. DB에서 복원할 때는 표준 + * 생성자로 만들어 검증하지 않는다. {@code event_end_at}이 NULL인 기존 행이 있어 검증하면 조회가 깨진다. + */ +public record EventPeriod(LocalDateTime startAt, LocalDateTime endAt) { + + public static EventPeriod of(LocalDateTime startAt, LocalDateTime endAt) { + validateOrder(startAt, endAt); + return new EventPeriod(startAt, endAt); + } + + private static void validateOrder(LocalDateTime startAt, LocalDateTime endAt) { + if (!endAt.isAfter(startAt)) { + throw new BusinessException(EventErrorCode.EVENT_INVALID_PERIOD); + } + } +} diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventUpdateCommand.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventUpdateCommand.java index 31ea49c9..8e767732 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventUpdateCommand.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventUpdateCommand.java @@ -4,19 +4,15 @@ import java.util.List; /** - * @param capacity 선착순 모집의 정원. 상시 모집이면 쓰이지 않는다 * @param expectedUpdatedAt 수정 화면이 행사를 읽었을 때의 수정 시각. 그 사이 다른 운영진이 고쳤는지 확인한다 */ public record EventUpdateCommand( String title, String description, String place, - LocalDateTime eventStartAt, - LocalDateTime eventEndAt, - LocalDateTime applyStartAt, - LocalDateTime applyEndAt, - RecruitType recruitType, - Integer capacity, + EventPeriod eventPeriod, + EventPeriod applyPeriod, + RecruitPolicy recruitPolicy, boolean feePaidOnly, List imageIds, List questions, diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/RecruitPolicy.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/RecruitPolicy.java new file mode 100644 index 00000000..7155b4de --- /dev/null +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/RecruitPolicy.java @@ -0,0 +1,47 @@ +package kr.ac.kookmin.stream.event.domain.event.domain; + +import kr.ac.kookmin.stream.common.BusinessException; + +/** + * 모집 방식과 정원. 정원은 선착순 모집에만 의미가 있어 두 값을 함께 다룬다. + *

+ * 선착순 정원이 1 이상이어야 한다는 규칙은 {@link #of}에서 검증하고, 상시 모집은 정원을 0으로 맞춘다. DB에서 복원할 + * 때는 저장된 값을 그대로 표준 생성자로 만든다. + * + * @param capacity 선착순 모집의 정원. 상시 모집이면 의미 없는 값 0이다 + */ +public record RecruitPolicy(RecruitType type, int capacity) { + + public static RecruitPolicy of(RecruitType type, Integer capacity) { + validateCapacity(type, capacity); + return new RecruitPolicy(type, type == RecruitType.FIRST_COME ? capacity : 0); + } + + /** + * 운영진 화면에 보여줄 정원. 상시 모집은 인원 제한이 없어 {@code null}이다(저장값 0은 의미가 없다). + */ + public Integer capacityLimit() { + return type == RecruitType.FIRST_COME ? capacity : null; + } + + /** + * 정원이 찼는지 판정한다. 선착순 모집에만 정원 제한이 있고, 상시 모집은 인원 제한이 없다. + */ + public boolean isFull(long appliedCount) { + return type == RecruitType.FIRST_COME && appliedCount >= capacity; + } + + /** + * 정원이 이미 받은 신청 수보다 적은지. 상시 모집은 인원 제한이 없어 보지 않는다. + */ + public boolean isCapacityBelow(long appliedCount) { + return type == RecruitType.FIRST_COME && capacity < appliedCount; + } + + // 상시 모집은 인원 제한이 없어 정원을 보지 않는다 + private static void validateCapacity(RecruitType type, Integer capacity) { + if (type == RecruitType.FIRST_COME && (capacity == null || capacity < 1)) { + throw new BusinessException(EventErrorCode.EVENT_INVALID_CAPACITY); + } + } +} diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/AdminEventServiceImpl.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/AdminEventServiceImpl.java index 5fdb9af1..3f97de82 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/AdminEventServiceImpl.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/AdminEventServiceImpl.java @@ -15,7 +15,7 @@ import kr.ac.kookmin.stream.event.domain.event.domain.EventQuestion; import kr.ac.kookmin.stream.event.domain.event.domain.EventQuestionCommand; import kr.ac.kookmin.stream.event.domain.event.domain.EventUpdateCommand; -import kr.ac.kookmin.stream.event.domain.event.domain.RecruitType; +import kr.ac.kookmin.stream.event.domain.event.domain.RecruitPolicy; import kr.ac.kookmin.stream.event.domain.event.repository.EventRepository; import kr.ac.kookmin.stream.event.domain.event.service.AdminEventService; import lombok.RequiredArgsConstructor; @@ -68,7 +68,7 @@ public Event update(Long eventId, EventUpdateCommand command) { Event event = getEventOrThrow(eventId); validateNotModified(event, command.expectedUpdatedAt()); eventWriteValidator.validate(command); - validateCapacityNotBelowApplicants(eventId, command); + validateCapacityNotBelowApplicants(eventId, command.recruitPolicy()); List questions = toQuestions(eventId, command.questions()); if (questionsChanged(eventRepository.findQuestionsByEventId(eventId), questions)) { @@ -97,10 +97,9 @@ private void validateNotModified(Event event, LocalDateTime expectedUpdatedAt) { } } - // 이미 받은 신청을 정원 밖으로 밀어낼 수는 없다. 상시 모집은 인원 제한이 없어 보지 않는다 - private void validateCapacityNotBelowApplicants(Long eventId, EventUpdateCommand command) { - if (command.recruitType() == RecruitType.FIRST_COME - && command.capacity() < eventRepository.countAppliedByEventId(eventId)) { + // 이미 받은 신청을 정원 밖으로 밀어낼 수는 없다 + private void validateCapacityNotBelowApplicants(Long eventId, RecruitPolicy recruitPolicy) { + if (recruitPolicy.isCapacityBelow(eventRepository.countAppliedByEventId(eventId))) { throw new BusinessException(EventErrorCode.EVENT_CAPACITY_BELOW_APPLICANTS); } } diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventWriteValidator.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventWriteValidator.java index 938c5547..b91b1b2d 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventWriteValidator.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventWriteValidator.java @@ -1,49 +1,29 @@ package kr.ac.kookmin.stream.event.domain.event.service.impl; -import java.time.LocalDateTime; import java.util.List; import kr.ac.kookmin.stream.common.BusinessException; import kr.ac.kookmin.stream.event.domain.event.domain.EventCreateCommand; import kr.ac.kookmin.stream.event.domain.event.domain.EventErrorCode; import kr.ac.kookmin.stream.event.domain.event.domain.EventQuestionCommand; import kr.ac.kookmin.stream.event.domain.event.domain.EventUpdateCommand; -import kr.ac.kookmin.stream.event.domain.event.domain.RecruitType; import org.springframework.stereotype.Component; /** * 운영진이 행사를 등록·수정할 때 요청 형식(Bean Validation)만으로는 걸러지지 않는 규칙을 확인한다. - * 필드 하나로 판단할 수 없는 조합(시작·종료 순서, 모집 방식에 따른 정원, 질문 유형에 따른 선택지)을 본다. + * 필드 하나로 판단할 수 없는 조합(질문 유형에 따른 선택지)을 본다. 기간 순서와 모집 방식에 따른 정원은 + * {@code EventPeriod}·{@code RecruitPolicy}를 만들 때 검증한다. */ @Component class EventWriteValidator { void validate(EventCreateCommand command) { - validatePeriod(command.eventStartAt(), command.eventEndAt()); - validatePeriod(command.applyStartAt(), command.applyEndAt()); - validateCapacity(command.recruitType(), command.capacity()); validateQuestions(command.questions()); } void validate(EventUpdateCommand command) { - validatePeriod(command.eventStartAt(), command.eventEndAt()); - validatePeriod(command.applyStartAt(), command.applyEndAt()); - validateCapacity(command.recruitType(), command.capacity()); validateQuestions(command.questions()); } - private void validatePeriod(LocalDateTime startAt, LocalDateTime endAt) { - if (!endAt.isAfter(startAt)) { - throw new BusinessException(EventErrorCode.EVENT_INVALID_PERIOD); - } - } - - // 상시 모집은 인원 제한이 없어 정원을 보지 않는다 - private void validateCapacity(RecruitType recruitType, Integer capacity) { - if (recruitType == RecruitType.FIRST_COME && (capacity == null || capacity < 1)) { - throw new BusinessException(EventErrorCode.EVENT_INVALID_CAPACITY); - } - } - private void validateQuestions(List questions) { boolean choiceWithoutOptions = questions.stream() .anyMatch(question -> question.questionType().isChoice() diff --git a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventPeriodTest.java b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventPeriodTest.java new file mode 100644 index 00000000..1b24c2ad --- /dev/null +++ b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventPeriodTest.java @@ -0,0 +1,38 @@ +package kr.ac.kookmin.stream.event.domain.event.domain; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.time.LocalDateTime; +import kr.ac.kookmin.stream.common.BusinessException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class EventPeriodTest { + + private static final LocalDateTime START_AT = LocalDateTime.of(2026, 11, 1, 10, 0); + + private static void assertRejected(Runnable creation) { + BusinessException e = assertThrows(BusinessException.class, creation::run); + assertEquals(EventErrorCode.EVENT_INVALID_PERIOD, e.getErrorCode()); + } + + @Test + @DisplayName("종료가 시작보다 빠르면 거부한다") + void endsBeforeStart() { + assertRejected(() -> EventPeriod.of(START_AT, START_AT.minusMinutes(1))); + } + + @Test + @DisplayName("시작과 종료가 같으면 거부한다") + void endsAtStart() { + assertRejected(() -> EventPeriod.of(START_AT, START_AT)); + } + + @Test + @DisplayName("종료가 시작보다 늦으면 기간을 만든다") + void endsAfterStart() { + assertDoesNotThrow(() -> EventPeriod.of(START_AT, START_AT.plusSeconds(1))); + } +} diff --git a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventTest.java b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventTest.java index 8c327aed..885e2799 100644 --- a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventTest.java +++ b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventTest.java @@ -180,8 +180,9 @@ class Create { private EventCreateCommand command(RecruitType recruitType, Integer capacity) { return new EventCreateCommand( "체육대회", "학과 대항 체육대회", "종합운동장", - LocalDateTime.of(2026, 11, 1, 10, 0), LocalDateTime.of(2026, 11, 1, 18, 0), - APPLY_START_AT, APPLY_END_AT, recruitType, capacity, true, List.of(31L, 32L), List.of() + EventPeriod.of(LocalDateTime.of(2026, 11, 1, 10, 0), LocalDateTime.of(2026, 11, 1, 18, 0)), + EventPeriod.of(APPLY_START_AT, APPLY_END_AT), RecruitPolicy.of(recruitType, capacity), true, + List.of(31L, 32L), List.of() ); } @@ -224,9 +225,9 @@ class Update { private EventUpdateCommand command(RecruitType recruitType, Integer capacity) { return new EventUpdateCommand( "체육대회(변경)", "장소가 바뀌었습니다", "미래관 대강당", - LocalDateTime.of(2026, 11, 2, 10, 0), LocalDateTime.of(2026, 11, 2, 18, 0), - APPLY_START_AT.plusDays(1), APPLY_END_AT.plusDays(1), recruitType, capacity, true, - List.of(40L), List.of(), LocalDateTime.now() + EventPeriod.of(LocalDateTime.of(2026, 11, 2, 10, 0), LocalDateTime.of(2026, 11, 2, 18, 0)), + EventPeriod.of(APPLY_START_AT.plusDays(1), APPLY_END_AT.plusDays(1)), + RecruitPolicy.of(recruitType, capacity), true, List.of(40L), List.of(), LocalDateTime.now() ); } diff --git a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/RecruitPolicyTest.java b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/RecruitPolicyTest.java new file mode 100644 index 00000000..cd0bf8ba --- /dev/null +++ b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/RecruitPolicyTest.java @@ -0,0 +1,42 @@ +package kr.ac.kookmin.stream.event.domain.event.domain; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import kr.ac.kookmin.stream.common.BusinessException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class RecruitPolicyTest { + + private static void assertRejected(Runnable creation) { + BusinessException e = assertThrows(BusinessException.class, creation::run); + assertEquals(EventErrorCode.EVENT_INVALID_CAPACITY, e.getErrorCode()); + } + + @Test + @DisplayName("선착순인데 정원이 없으면 거부한다") + void firstComeWithoutCapacity() { + assertRejected(() -> RecruitPolicy.of(RecruitType.FIRST_COME, null)); + } + + @Test + @DisplayName("선착순인데 정원이 0명이면 거부한다") + void firstComeWithZeroCapacity() { + assertRejected(() -> RecruitPolicy.of(RecruitType.FIRST_COME, 0)); + } + + @Test + @DisplayName("선착순 정원은 1명부터 허용한다") + void firstComeWithOneCapacity() { + assertDoesNotThrow(() -> RecruitPolicy.of(RecruitType.FIRST_COME, 1)); + } + + @Test + @DisplayName("상시 모집은 정원을 보지 않는다") + void openRecruitIgnoresCapacity() { + assertDoesNotThrow(() -> RecruitPolicy.of(RecruitType.OPEN, null)); + assertDoesNotThrow(() -> RecruitPolicy.of(RecruitType.OPEN, -1)); + } +} diff --git a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/AdminEventServiceImplTest.java b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/AdminEventServiceImplTest.java index 6623dea1..5e6d1e67 100644 --- a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/AdminEventServiceImplTest.java +++ b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/AdminEventServiceImplTest.java @@ -25,10 +25,12 @@ import kr.ac.kookmin.stream.event.domain.event.domain.EventCursor; import kr.ac.kookmin.stream.event.domain.event.domain.EventErrorCode; import kr.ac.kookmin.stream.event.domain.event.domain.EventManageDetail; +import kr.ac.kookmin.stream.event.domain.event.domain.EventPeriod; import kr.ac.kookmin.stream.event.domain.event.domain.EventQuestion; import kr.ac.kookmin.stream.event.domain.event.domain.EventQuestionCommand; import kr.ac.kookmin.stream.event.domain.event.domain.EventUpdateCommand; import kr.ac.kookmin.stream.event.domain.event.domain.QuestionType; +import kr.ac.kookmin.stream.event.domain.event.domain.RecruitPolicy; import kr.ac.kookmin.stream.event.domain.event.domain.RecruitStatus; import kr.ac.kookmin.stream.event.domain.event.domain.RecruitType; import kr.ac.kookmin.stream.event.domain.event.repository.EventRepository; @@ -37,7 +39,8 @@ import org.junit.jupiter.api.Test; /** - * 운영진 행사 등록·조회·수정. 형식 규칙 자체는 {@code EventWriteValidatorTest}가 덮고, + * 운영진 행사 등록·조회·수정. 형식 규칙 자체는 {@code EventWriteValidatorTest}·{@code EventPeriodTest}· + * {@code RecruitPolicyTest}가 덮고, * 여기서는 저장 흐름과 수정 시의 규칙(동시 수정·정원 하한·질문 잠금)을 본다. */ class AdminEventServiceImplTest { @@ -67,16 +70,18 @@ private static Event savedEvent(RecruitType recruitType, int capacity) { private static EventCreateCommand createCommand( RecruitType recruitType, Integer capacity, List questions) { return new EventCreateCommand( - "체육대회", "설명", "종합운동장", EVENT_START_AT, EVENT_END_AT, APPLY_START_AT, APPLY_END_AT, - recruitType, capacity, false, List.of(31L), questions + "체육대회", "설명", "종합운동장", EventPeriod.of(EVENT_START_AT, EVENT_END_AT), + EventPeriod.of(APPLY_START_AT, APPLY_END_AT), RecruitPolicy.of(recruitType, capacity), false, List.of(31L), + questions ); } private static EventUpdateCommand updateCommand( RecruitType recruitType, Integer capacity, List questions, LocalDateTime expectedUpdatedAt) { return new EventUpdateCommand( - "체육대회(변경)", "설명", "미래관", EVENT_START_AT, EVENT_END_AT, APPLY_START_AT, APPLY_END_AT, - recruitType, capacity, true, List.of(40L), questions, expectedUpdatedAt + "체육대회(변경)", "설명", "미래관", EventPeriod.of(EVENT_START_AT, EVENT_END_AT), + EventPeriod.of(APPLY_START_AT, APPLY_END_AT), RecruitPolicy.of(recruitType, capacity), true, List.of(40L), + questions, expectedUpdatedAt ); } @@ -139,8 +144,11 @@ void savesEmptyForm() { @Test @DisplayName("규칙을 어기면 아무것도 저장하지 않는다") void savesNothingWhenInvalid() { - assertRejected(EventErrorCode.EVENT_INVALID_CAPACITY, - () -> service.create(7L, createCommand(RecruitType.FIRST_COME, 0, List.of(SPORTS)))); + EventQuestionCommand choiceWithoutOptions = new EventQuestionCommand( + "참여 종목", QuestionType.MULTIPLE_CHOICE, true, List.of(), true); + + assertRejected(EventErrorCode.EVENT_INVALID_QUESTION, + () -> service.create(7L, createCommand(RecruitType.FIRST_COME, 100, List.of(choiceWithoutOptions)))); assertTrue(repository.events.isEmpty()); assertTrue(repository.questions.isEmpty()); @@ -225,15 +233,6 @@ void ignoresSubSecondDifference() { assertDoesNotThrow(() -> service.update(EVENT_ID, updateCommand(RecruitType.FIRST_COME, 100, List.of(SPORTS), UPDATED_AT.plusNanos(999_000_000)))); } - - @Test - @DisplayName("동시 수정 검사는 형식 검증보다 먼저 한다") - void checkedBeforeFormat() { - givenEvent(RecruitType.FIRST_COME, 100, savedQuestions(SPORTS)); - - assertRejected(EventErrorCode.EVENT_UPDATE_CONFLICT, () -> service.update(EVENT_ID, - updateCommand(RecruitType.FIRST_COME, 0, List.of(SPORTS), UPDATED_AT.minusDays(1)))); - } } @Nested diff --git a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventWriteValidatorTest.java b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventWriteValidatorTest.java index 37ff1230..9830f017 100644 --- a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventWriteValidatorTest.java +++ b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventWriteValidatorTest.java @@ -9,43 +9,35 @@ import kr.ac.kookmin.stream.common.BusinessException; import kr.ac.kookmin.stream.event.domain.event.domain.EventCreateCommand; import kr.ac.kookmin.stream.event.domain.event.domain.EventErrorCode; +import kr.ac.kookmin.stream.event.domain.event.domain.EventPeriod; import kr.ac.kookmin.stream.event.domain.event.domain.EventQuestionCommand; import kr.ac.kookmin.stream.event.domain.event.domain.EventUpdateCommand; import kr.ac.kookmin.stream.event.domain.event.domain.QuestionType; +import kr.ac.kookmin.stream.event.domain.event.domain.RecruitPolicy; import kr.ac.kookmin.stream.event.domain.event.domain.RecruitType; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +/** + * 기간 순서와 선착순 정원은 {@code EventPeriodTest}·{@code RecruitPolicyTest}가 덮고, 여기서는 신청서 질문 규칙을 본다. + */ class EventWriteValidatorTest { - private static final LocalDateTime APPLY_START_AT = LocalDateTime.of(2026, 10, 14, 10, 0); - private static final LocalDateTime APPLY_END_AT = LocalDateTime.of(2026, 10, 25, 23, 59, 59); - private static final LocalDateTime EVENT_START_AT = LocalDateTime.of(2026, 11, 1, 10, 0); - private static final LocalDateTime EVENT_END_AT = LocalDateTime.of(2026, 11, 1, 18, 0); + private static final EventPeriod APPLY_PERIOD = EventPeriod.of( + LocalDateTime.of(2026, 10, 14, 10, 0), LocalDateTime.of(2026, 10, 25, 23, 59, 59)); + private static final EventPeriod EVENT_PERIOD = EventPeriod.of( + LocalDateTime.of(2026, 11, 1, 10, 0), LocalDateTime.of(2026, 11, 1, 18, 0)); private final EventWriteValidator validator = new EventWriteValidator(); - private static EventCreateCommand createCommand( - LocalDateTime eventStartAt, - LocalDateTime eventEndAt, - LocalDateTime applyStartAt, - LocalDateTime applyEndAt, - RecruitType recruitType, - Integer capacity, - List questions - ) { + private static EventCreateCommand createCommand(List questions) { return new EventCreateCommand( - "체육대회", "설명", "종합운동장", eventStartAt, eventEndAt, applyStartAt, applyEndAt, - recruitType, capacity, false, List.of(), questions + "체육대회", "설명", "종합운동장", EVENT_PERIOD, APPLY_PERIOD, RecruitPolicy.of(RecruitType.FIRST_COME, 100), + false, List.of(), questions ); } - private static EventCreateCommand validCommand() { - return createCommand( - EVENT_START_AT, EVENT_END_AT, APPLY_START_AT, APPLY_END_AT, RecruitType.FIRST_COME, 100, List.of()); - } - private static EventQuestionCommand question(QuestionType type, List options) { return new EventQuestionCommand("질문", type, true, options, false); } @@ -58,78 +50,7 @@ private static void assertRejected(EventErrorCode expected, Runnable validation) @Test @DisplayName("규칙을 모두 지키면 통과한다") void validCommandPasses() { - assertDoesNotThrow(() -> validator.validate(validCommand())); - } - - @Nested - @DisplayName("기간") - class Period { - - @Test - @DisplayName("행사 종료가 시작보다 빠르면 거부한다") - void eventEndsBeforeStart() { - assertRejected(EventErrorCode.EVENT_INVALID_PERIOD, () -> validator.validate(createCommand( - EVENT_START_AT, EVENT_START_AT.minusMinutes(1), APPLY_START_AT, APPLY_END_AT, - RecruitType.OPEN, null, List.of()))); - } - - @Test - @DisplayName("행사 시작과 종료가 같으면 거부한다") - void eventEndsAtStart() { - assertRejected(EventErrorCode.EVENT_INVALID_PERIOD, () -> validator.validate(createCommand( - EVENT_START_AT, EVENT_START_AT, APPLY_START_AT, APPLY_END_AT, RecruitType.OPEN, null, List.of()))); - } - - @Test - @DisplayName("신청 마감이 시작보다 빠르면 거부한다") - void applyEndsBeforeStart() { - assertRejected(EventErrorCode.EVENT_INVALID_PERIOD, () -> validator.validate(createCommand( - EVENT_START_AT, EVENT_END_AT, APPLY_START_AT, APPLY_START_AT.minusSeconds(1), - RecruitType.OPEN, null, List.of()))); - } - - @Test - @DisplayName("신청 기간과 행사 기간이 겹쳐도 각각 순서만 맞으면 통과한다") - void overlappingPeriodsAreAllowed() { - assertDoesNotThrow(() -> validator.validate(createCommand( - APPLY_START_AT.plusDays(1), APPLY_END_AT.plusDays(1), APPLY_START_AT, APPLY_END_AT, - RecruitType.OPEN, null, List.of()))); - } - } - - @Nested - @DisplayName("정원") - class Capacity { - - @Test - @DisplayName("선착순인데 정원이 없으면 거부한다") - void firstComeWithoutCapacity() { - assertRejected(EventErrorCode.EVENT_INVALID_CAPACITY, () -> validator.validate(createCommand( - EVENT_START_AT, EVENT_END_AT, APPLY_START_AT, APPLY_END_AT, RecruitType.FIRST_COME, null, List.of()))); - } - - @Test - @DisplayName("선착순인데 정원이 0명이면 거부한다") - void firstComeWithZeroCapacity() { - assertRejected(EventErrorCode.EVENT_INVALID_CAPACITY, () -> validator.validate(createCommand( - EVENT_START_AT, EVENT_END_AT, APPLY_START_AT, APPLY_END_AT, RecruitType.FIRST_COME, 0, List.of()))); - } - - @Test - @DisplayName("선착순 정원은 1명부터 허용한다") - void firstComeWithOneCapacity() { - assertDoesNotThrow(() -> validator.validate(createCommand( - EVENT_START_AT, EVENT_END_AT, APPLY_START_AT, APPLY_END_AT, RecruitType.FIRST_COME, 1, List.of()))); - } - - @Test - @DisplayName("상시 모집은 정원을 보지 않는다") - void openRecruitIgnoresCapacity() { - assertDoesNotThrow(() -> validator.validate(createCommand( - EVENT_START_AT, EVENT_END_AT, APPLY_START_AT, APPLY_END_AT, RecruitType.OPEN, null, List.of()))); - assertDoesNotThrow(() -> validator.validate(createCommand( - EVENT_START_AT, EVENT_END_AT, APPLY_START_AT, APPLY_END_AT, RecruitType.OPEN, -1, List.of()))); - } + assertDoesNotThrow(() -> validator.validate(createCommand(List.of()))); } @Nested @@ -140,32 +61,28 @@ class Questions { @DisplayName("선택형 질문에 선택지가 없으면 거부한다") void choiceWithoutOptions() { for (QuestionType type : List.of(QuestionType.SINGLE_CHOICE, QuestionType.MULTIPLE_CHOICE)) { - assertRejected(EventErrorCode.EVENT_INVALID_QUESTION, () -> validator.validate(createCommand( - EVENT_START_AT, EVENT_END_AT, APPLY_START_AT, APPLY_END_AT, RecruitType.OPEN, null, - List.of(question(type, List.of()))))); - assertRejected(EventErrorCode.EVENT_INVALID_QUESTION, () -> validator.validate(createCommand( - EVENT_START_AT, EVENT_END_AT, APPLY_START_AT, APPLY_END_AT, RecruitType.OPEN, null, - List.of(question(type, null))))); + assertRejected(EventErrorCode.EVENT_INVALID_QUESTION, + () -> validator.validate(createCommand(List.of(question(type, List.of()))))); + assertRejected(EventErrorCode.EVENT_INVALID_QUESTION, + () -> validator.validate(createCommand(List.of(question(type, null))))); } } @Test @DisplayName("여러 질문 중 하나라도 규칙을 어기면 거부한다") void oneInvalidAmongMany() { - assertRejected(EventErrorCode.EVENT_INVALID_QUESTION, () -> validator.validate(createCommand( - EVENT_START_AT, EVENT_END_AT, APPLY_START_AT, APPLY_END_AT, RecruitType.OPEN, null, List.of( - question(QuestionType.SHORT_TEXT, List.of()), - question(QuestionType.SINGLE_CHOICE, List.of("참석", "불참")), - question(QuestionType.MULTIPLE_CHOICE, List.of()))))); + assertRejected(EventErrorCode.EVENT_INVALID_QUESTION, () -> validator.validate(createCommand(List.of( + question(QuestionType.SHORT_TEXT, List.of()), + question(QuestionType.SINGLE_CHOICE, List.of("참석", "불참")), + question(QuestionType.MULTIPLE_CHOICE, List.of()))))); } @Test @DisplayName("텍스트형 질문은 선택지가 없어도 통과한다") void textWithoutOptions() { - assertDoesNotThrow(() -> validator.validate(createCommand( - EVENT_START_AT, EVENT_END_AT, APPLY_START_AT, APPLY_END_AT, RecruitType.OPEN, null, List.of( - question(QuestionType.SHORT_TEXT, null), - question(QuestionType.LONG_TEXT, List.of()))))); + assertDoesNotThrow(() -> validator.validate(createCommand(List.of( + question(QuestionType.SHORT_TEXT, null), + question(QuestionType.LONG_TEXT, List.of()))))); } } @@ -176,19 +93,10 @@ class UpdateCommand { @Test @DisplayName("등록과 같은 규칙으로 검증한다") void sameRulesAsCreate() { - EventUpdateCommand invalidPeriod = new EventUpdateCommand( - "체육대회", "설명", null, EVENT_START_AT, EVENT_START_AT, APPLY_START_AT, APPLY_END_AT, - RecruitType.OPEN, null, false, List.of(), List.of(), LocalDateTime.now()); - EventUpdateCommand invalidCapacity = new EventUpdateCommand( - "체육대회", "설명", null, EVENT_START_AT, EVENT_END_AT, APPLY_START_AT, APPLY_END_AT, - RecruitType.FIRST_COME, 0, false, List.of(), List.of(), LocalDateTime.now()); EventUpdateCommand invalidQuestion = new EventUpdateCommand( - "체육대회", "설명", null, EVENT_START_AT, EVENT_END_AT, APPLY_START_AT, APPLY_END_AT, - RecruitType.OPEN, null, false, List.of(), List.of(question(QuestionType.SINGLE_CHOICE, List.of())), - LocalDateTime.now()); + "체육대회", "설명", null, EVENT_PERIOD, APPLY_PERIOD, RecruitPolicy.of(RecruitType.OPEN, null), false, + List.of(), List.of(question(QuestionType.SINGLE_CHOICE, List.of())), LocalDateTime.now()); - assertRejected(EventErrorCode.EVENT_INVALID_PERIOD, () -> validator.validate(invalidPeriod)); - assertRejected(EventErrorCode.EVENT_INVALID_CAPACITY, () -> validator.validate(invalidCapacity)); assertRejected(EventErrorCode.EVENT_INVALID_QUESTION, () -> validator.validate(invalidQuestion)); } } From 3eba330e613e17b88fbd5dba5f307f5811b55d59 Mon Sep 17 00:00:00 2001 From: gain <163835628+leegain1@users.noreply.github.com> Date: Wed, 28 Oct 2026 19:07:35 +0900 Subject: [PATCH 13/19] =?UTF-8?q?refactor:=20=EC=9A=B4=EC=98=81=EC=A7=84?= =?UTF-8?q?=20=ED=96=89=EC=82=AC=20=EC=A1=B0=ED=9A=8C=20=EB=A9=94=EC=84=9C?= =?UTF-8?q?=EB=93=9C=EC=9D=98=20readOnly=20=ED=8A=B8=EB=9E=9C=EC=9E=AD?= =?UTF-8?q?=EC=85=98=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../event/domain/event/service/impl/AdminEventServiceImpl.java | 3 --- 1 file changed, 3 deletions(-) diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/AdminEventServiceImpl.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/AdminEventServiceImpl.java index 3f97de82..c8ac0674 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/AdminEventServiceImpl.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/AdminEventServiceImpl.java @@ -40,19 +40,16 @@ public Event create(Long createdBy, EventCreateCommand command) { } @Override - @Transactional(readOnly = true) public PageResult getEvents(String keyword, PageOffset pageOffset) { return eventRepository.findPage(keyword, pageOffset); } @Override - @Transactional(readOnly = true) public Map getApplicantCounts(List eventIds) { return eventRepository.countAppliedByEventIds(eventIds); } @Override - @Transactional(readOnly = true) public EventManageDetail getEvent(Long eventId) { Event event = getEventOrThrow(eventId); return new EventManageDetail( From 499c64b16416aa34cf04f1a7269ee1d433bc7101 Mon Sep 17 00:00:00 2001 From: gain <163835628+leegain1@users.noreply.github.com> Date: Wed, 28 Oct 2026 19:07:41 +0900 Subject: [PATCH 14/19] =?UTF-8?q?refactor:=20=ED=96=89=EC=82=AC=20?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=A1=B4=EC=9E=AC=20=ED=99=95?= =?UTF-8?q?=EC=9D=B8=20=EB=A9=94=EC=84=9C=EB=93=9C=20=EC=9D=B4=EB=A6=84?= =?UTF-8?q?=EC=9D=84=20validateImagesExist=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/event/event/usecase/AdminEventWriteUseCase.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/usecase/AdminEventWriteUseCase.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/usecase/AdminEventWriteUseCase.java index fb01f479..bde557f7 100644 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/usecase/AdminEventWriteUseCase.java +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/usecase/AdminEventWriteUseCase.java @@ -26,17 +26,17 @@ public class AdminEventWriteUseCase { private final FileService fileService; public Event create(Long createdBy, EventCreateCommand command) { - requireImagesExist(command.imageIds()); + validateImagesExist(command.imageIds()); return adminEventService.create(createdBy, command); } public Event update(Long eventId, EventUpdateCommand command) { - requireImagesExist(command.imageIds()); + validateImagesExist(command.imageIds()); return adminEventService.update(eventId, command); } // 요청 DTO가 중복을 걸러 넘기므로 조회 결과 개수와 바로 비교할 수 있다 - private void requireImagesExist(List imageIds) { + private void validateImagesExist(List imageIds) { if (fileService.findAllByIdIn(imageIds).size() != imageIds.size()) { throw new BusinessException(FileErrorCode.FILE_NOT_FOUND); } From a05854dbe880415e2a556d0f505416194d7767aa Mon Sep 17 00:00:00 2001 From: gain <163835628+leegain1@users.noreply.github.com> Date: Wed, 28 Oct 2026 19:07:55 +0900 Subject: [PATCH 15/19] =?UTF-8?q?refactor:=20=ED=96=89=EC=82=AC=20?= =?UTF-8?q?=EC=A7=88=EB=AC=B8=20=EC=9E=A0=EA=B8=88=20=EA=B2=80=EC=82=AC?= =?UTF-8?q?=EB=A5=BC=20validateQuestionsEditable=EB=A1=9C=20=EC=B6=94?= =?UTF-8?q?=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../event/service/impl/AdminEventServiceImpl.java | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/AdminEventServiceImpl.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/AdminEventServiceImpl.java index c8ac0674..cf5f1407 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/AdminEventServiceImpl.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/AdminEventServiceImpl.java @@ -69,10 +69,7 @@ public Event update(Long eventId, EventUpdateCommand command) { List questions = toQuestions(eventId, command.questions()); if (questionsChanged(eventRepository.findQuestionsByEventId(eventId), questions)) { - // 답변이 질문 id·선택지 순서로 저장돼 있어 신청이 생긴 뒤 바꾸면 기존 답변의 뜻이 달라진다 - if (eventRepository.existsApplicationByEventId(eventId)) { - throw new BusinessException(EventErrorCode.EVENT_QUESTIONS_LOCKED); - } + validateQuestionsEditable(eventId); eventRepository.replaceQuestions(eventId, questions); eventRepository.touchUpdatedAt(eventId); } @@ -101,6 +98,13 @@ private void validateCapacityNotBelowApplicants(Long eventId, RecruitPolicy recr } } + // 답변이 질문 id·선택지 순서로 저장돼 있어 신청이 생긴 뒤 바꾸면 기존 답변의 뜻이 달라진다 + private void validateQuestionsEditable(Long eventId) { + if (eventRepository.existsApplicationByEventId(eventId)) { + throw new BusinessException(EventErrorCode.EVENT_QUESTIONS_LOCKED); + } + } + private static boolean questionsChanged(List current, List requested) { if (current.size() != requested.size()) { return true; From deaf1dd5e320efa43c240dcc98ff05a18fdae12b Mon Sep 17 00:00:00 2001 From: gain <163835628+leegain1@users.noreply.github.com> Date: Wed, 28 Oct 2026 19:08:37 +0900 Subject: [PATCH 16/19] =?UTF-8?q?refactor:=20=EC=9A=B4=EC=98=81=EC=A7=84?= =?UTF-8?q?=20=ED=96=89=EC=82=AC=20=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=20=ED=8F=AC=ED=8A=B8=20=EC=9D=B4=EB=A6=84=EC=9D=84=20search?= =?UTF-8?q?=EB=A1=9C=20=ED=86=B5=EC=9D=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../stream/event/domain/event/repository/EventRepository.java | 2 +- .../event/domain/event/service/impl/AdminEventServiceImpl.java | 2 +- .../domain/event/service/impl/AdminEventServiceImplTest.java | 2 +- .../event/domain/event/service/impl/EventServiceImplTest.java | 2 +- .../java/kr/ac/kookmin/stream/db/event/EventRepositoryImpl.java | 2 +- 5 files changed, 5 insertions(+), 5 deletions(-) diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/repository/EventRepository.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/repository/EventRepository.java index cf64f4cd..506b1249 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/repository/EventRepository.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/repository/EventRepository.java @@ -42,7 +42,7 @@ public interface EventRepository { * @param keyword 제목 부분 일치 검색어. null이거나 공백이면 거르지 않는다. 앞뒤 공백은 지우고 {@code %}·{@code _}는 글자 * 그대로 검색한다 */ - PageResult findPage(String keyword, PageOffset pageOffset); + PageResult search(String keyword, PageOffset pageOffset); /** * 주어진 식별자의 행사를 조회한다. 삭제 여부로 거르지 않는다. diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/AdminEventServiceImpl.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/AdminEventServiceImpl.java index cf5f1407..60a121d6 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/AdminEventServiceImpl.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/AdminEventServiceImpl.java @@ -41,7 +41,7 @@ public Event create(Long createdBy, EventCreateCommand command) { @Override public PageResult getEvents(String keyword, PageOffset pageOffset) { - return eventRepository.findPage(keyword, pageOffset); + return eventRepository.search(keyword, pageOffset); } @Override diff --git a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/AdminEventServiceImplTest.java b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/AdminEventServiceImplTest.java index 5e6d1e67..21133867 100644 --- a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/AdminEventServiceImplTest.java +++ b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/AdminEventServiceImplTest.java @@ -441,7 +441,7 @@ public void touchUpdatedAt(Long eventId) { } @Override - public PageResult findPage(String keyword, PageOffset pageOffset) { + public PageResult search(String keyword, PageOffset pageOffset) { requestedKeyword = keyword; requestedPageOffset = pageOffset; List content = List.copyOf(events.values()); diff --git a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImplTest.java b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImplTest.java index c7bb8b70..cf4a6a89 100644 --- a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImplTest.java +++ b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImplTest.java @@ -175,7 +175,7 @@ public List saveQuestions(List questions) { } @Override - public PageResult findPage(String keyword, PageOffset pageOffset) { + public PageResult search(String keyword, PageOffset pageOffset) { throw new UnsupportedOperationException(); } diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventRepositoryImpl.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventRepositoryImpl.java index a9228b1a..5c69c2b2 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventRepositoryImpl.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventRepositoryImpl.java @@ -65,7 +65,7 @@ public void touchUpdatedAt(Long eventId) { } @Override - public PageResult findPage(String keyword, PageOffset pageOffset) { + public PageResult search(String keyword, PageOffset pageOffset) { Page result = eventJpaRepository.search( LikePatterns.escape(keyword), PageRequest.of(pageOffset.page(), pageOffset.size()) ); From f37ea0030035890270881ada25e68294ef3c903b Mon Sep 17 00:00:00 2001 From: gain <163835628+leegain1@users.noreply.github.com> Date: Thu, 29 Oct 2026 15:18:29 +0900 Subject: [PATCH 17/19] =?UTF-8?q?refactor:=20=ED=96=89=EC=82=AC=20?= =?UTF-8?q?=EC=A7=88=EB=AC=B8=20=EC=84=A0=ED=83=9D=EC=A7=80=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=EC=9D=84=20EventQuestion=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=98=AE=EA=B8=B0=EA=B3=A0=20EventWriteValidator=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/event/domain/EventQuestion.java | 11 +- .../service/impl/AdminEventServiceImpl.java | 8 +- .../service/impl/EventWriteValidator.java | 35 ------ .../event/domain/EventQuestionTest.java | 24 ++++ .../impl/AdminEventServiceImplTest.java | 17 +-- .../service/impl/EventWriteValidatorTest.java | 103 ------------------ 6 files changed, 39 insertions(+), 159 deletions(-) delete mode 100644 core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventWriteValidator.java delete mode 100644 core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventWriteValidatorTest.java diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventQuestion.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventQuestion.java index 34dcbd72..31b168b0 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventQuestion.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventQuestion.java @@ -2,6 +2,7 @@ import java.util.List; import java.util.Objects; +import kr.ac.kookmin.stream.common.BusinessException; import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.EqualsAndHashCode; @@ -38,11 +39,13 @@ public static EventQuestion of( } /** - * 새 질문을 만든다. 텍스트형 질문에 딸려 온 선택지·'기타' 여부는 의미가 없으므로 버린다. + * 새 질문을 만든다. 선택형 질문은 고를 선택지가 있어야 한다. 텍스트형 질문에 딸려 온 선택지·'기타' 여부는 의미가 + * 없으므로 버린다. * * @param displayOrder 신청서에서 몇 번째로 보일지. 1부터 센다 */ public static EventQuestion create(Long eventId, int displayOrder, EventQuestionCommand command) { + validateOptions(command); boolean choice = command.questionType().isChoice(); return new EventQuestion( null, @@ -67,4 +70,10 @@ public boolean hasSameContentAs(EventQuestion other) { && Objects.equals(questionText, other.questionText) && Objects.equals(options, other.options); } + + private static void validateOptions(EventQuestionCommand command) { + if (command.questionType().isChoice() && (command.options() == null || command.options().isEmpty())) { + throw new BusinessException(EventErrorCode.EVENT_INVALID_QUESTION); + } + } } diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/AdminEventServiceImpl.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/AdminEventServiceImpl.java index 60a121d6..e632d632 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/AdminEventServiceImpl.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/AdminEventServiceImpl.java @@ -27,13 +27,11 @@ class AdminEventServiceImpl implements AdminEventService { private final EventRepository eventRepository; - private final EventWriteValidator eventWriteValidator; + // 질문 규칙은 행사를 저장한 뒤 질문을 만들 때 검사한다. 어기면 트랜잭션이 행사 저장까지 되돌린다 @Override @Transactional public Event create(Long createdBy, EventCreateCommand command) { - eventWriteValidator.validate(command); - Event event = eventRepository.save(Event.create(createdBy, command)); eventRepository.saveQuestions(toQuestions(event.getId(), command.questions())); return event; @@ -64,10 +62,10 @@ public EventManageDetail getEvent(Long eventId) { public Event update(Long eventId, EventUpdateCommand command) { Event event = getEventOrThrow(eventId); validateNotModified(event, command.expectedUpdatedAt()); - eventWriteValidator.validate(command); + // 질문 규칙(입력 형식)은 정원 하한처럼 DB 상태를 보는 검사보다 먼저 건다 + List questions = toQuestions(eventId, command.questions()); validateCapacityNotBelowApplicants(eventId, command.recruitPolicy()); - List questions = toQuestions(eventId, command.questions()); if (questionsChanged(eventRepository.findQuestionsByEventId(eventId), questions)) { validateQuestionsEditable(eventId); eventRepository.replaceQuestions(eventId, questions); diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventWriteValidator.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventWriteValidator.java deleted file mode 100644 index b91b1b2d..00000000 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventWriteValidator.java +++ /dev/null @@ -1,35 +0,0 @@ -package kr.ac.kookmin.stream.event.domain.event.service.impl; - -import java.util.List; -import kr.ac.kookmin.stream.common.BusinessException; -import kr.ac.kookmin.stream.event.domain.event.domain.EventCreateCommand; -import kr.ac.kookmin.stream.event.domain.event.domain.EventErrorCode; -import kr.ac.kookmin.stream.event.domain.event.domain.EventQuestionCommand; -import kr.ac.kookmin.stream.event.domain.event.domain.EventUpdateCommand; -import org.springframework.stereotype.Component; - -/** - * 운영진이 행사를 등록·수정할 때 요청 형식(Bean Validation)만으로는 걸러지지 않는 규칙을 확인한다. - * 필드 하나로 판단할 수 없는 조합(질문 유형에 따른 선택지)을 본다. 기간 순서와 모집 방식에 따른 정원은 - * {@code EventPeriod}·{@code RecruitPolicy}를 만들 때 검증한다. - */ -@Component -class EventWriteValidator { - - void validate(EventCreateCommand command) { - validateQuestions(command.questions()); - } - - void validate(EventUpdateCommand command) { - validateQuestions(command.questions()); - } - - private void validateQuestions(List questions) { - boolean choiceWithoutOptions = questions.stream() - .anyMatch(question -> question.questionType().isChoice() - && (question.options() == null || question.options().isEmpty())); - if (choiceWithoutOptions) { - throw new BusinessException(EventErrorCode.EVENT_INVALID_QUESTION); - } - } -} diff --git a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventQuestionTest.java b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventQuestionTest.java index 2b0a1cd2..24971040 100644 --- a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventQuestionTest.java +++ b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventQuestionTest.java @@ -1,12 +1,15 @@ package kr.ac.kookmin.stream.event.domain.event.domain; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; import static org.junit.jupiter.api.Assertions.assertTrue; import java.util.ArrayList; import java.util.List; +import kr.ac.kookmin.stream.common.BusinessException; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; @@ -21,6 +24,11 @@ private static EventQuestion saved(Long id, QuestionType type, List opti return EventQuestion.of(id, 15L, "참여 종목", type, true, 1, options, otherOptionAllowed); } + private static void assertRejected(Runnable creation) { + BusinessException e = assertThrows(BusinessException.class, creation::run); + assertEquals(EventErrorCode.EVENT_INVALID_QUESTION, e.getErrorCode()); + } + @Nested @DisplayName("새 질문 만들기") class Create { @@ -62,6 +70,22 @@ void copiesOptions() { assertEquals(List.of("축구"), question.getOptions()); } + + @Test + @DisplayName("선택형 질문에 선택지가 없으면 거부한다") + void choiceWithoutOptions() { + for (QuestionType type : List.of(QuestionType.SINGLE_CHOICE, QuestionType.MULTIPLE_CHOICE)) { + assertRejected(() -> EventQuestion.create(15L, 1, command(type, List.of(), false))); + assertRejected(() -> EventQuestion.create(15L, 1, command(type, null, false))); + } + } + + @Test + @DisplayName("텍스트형 질문은 선택지가 없어도 만든다") + void textWithoutOptions() { + assertDoesNotThrow(() -> EventQuestion.create(15L, 1, command(QuestionType.SHORT_TEXT, null, false))); + assertDoesNotThrow(() -> EventQuestion.create(15L, 1, command(QuestionType.LONG_TEXT, List.of(), false))); + } } @Nested diff --git a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/AdminEventServiceImplTest.java b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/AdminEventServiceImplTest.java index 21133867..a7095f95 100644 --- a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/AdminEventServiceImplTest.java +++ b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/AdminEventServiceImplTest.java @@ -39,7 +39,7 @@ import org.junit.jupiter.api.Test; /** - * 운영진 행사 등록·조회·수정. 형식 규칙 자체는 {@code EventWriteValidatorTest}·{@code EventPeriodTest}· + * 운영진 행사 등록·조회·수정. 형식 규칙 자체는 {@code EventQuestionTest}·{@code EventPeriodTest}· * {@code RecruitPolicyTest}가 덮고, * 여기서는 저장 흐름과 수정 시의 규칙(동시 수정·정원 하한·질문 잠금)을 본다. */ @@ -58,7 +58,7 @@ class AdminEventServiceImplTest { "전화번호", QuestionType.SHORT_TEXT, true, List.of(), false); private final InMemoryEventRepository repository = new InMemoryEventRepository(); - private final AdminEventServiceImpl service = new AdminEventServiceImpl(repository, new EventWriteValidator()); + private final AdminEventServiceImpl service = new AdminEventServiceImpl(repository); private static Event savedEvent(RecruitType recruitType, int capacity) { return Event.of( @@ -140,19 +140,6 @@ void savesEmptyForm() { assertTrue(repository.findQuestionsByEventId(created.getId()).isEmpty()); } - - @Test - @DisplayName("규칙을 어기면 아무것도 저장하지 않는다") - void savesNothingWhenInvalid() { - EventQuestionCommand choiceWithoutOptions = new EventQuestionCommand( - "참여 종목", QuestionType.MULTIPLE_CHOICE, true, List.of(), true); - - assertRejected(EventErrorCode.EVENT_INVALID_QUESTION, - () -> service.create(7L, createCommand(RecruitType.FIRST_COME, 100, List.of(choiceWithoutOptions)))); - - assertTrue(repository.events.isEmpty()); - assertTrue(repository.questions.isEmpty()); - } } @Nested diff --git a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventWriteValidatorTest.java b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventWriteValidatorTest.java deleted file mode 100644 index 9830f017..00000000 --- a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventWriteValidatorTest.java +++ /dev/null @@ -1,103 +0,0 @@ -package kr.ac.kookmin.stream.event.domain.event.service.impl; - -import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertThrows; - -import java.time.LocalDateTime; -import java.util.List; -import kr.ac.kookmin.stream.common.BusinessException; -import kr.ac.kookmin.stream.event.domain.event.domain.EventCreateCommand; -import kr.ac.kookmin.stream.event.domain.event.domain.EventErrorCode; -import kr.ac.kookmin.stream.event.domain.event.domain.EventPeriod; -import kr.ac.kookmin.stream.event.domain.event.domain.EventQuestionCommand; -import kr.ac.kookmin.stream.event.domain.event.domain.EventUpdateCommand; -import kr.ac.kookmin.stream.event.domain.event.domain.QuestionType; -import kr.ac.kookmin.stream.event.domain.event.domain.RecruitPolicy; -import kr.ac.kookmin.stream.event.domain.event.domain.RecruitType; -import org.junit.jupiter.api.DisplayName; -import org.junit.jupiter.api.Nested; -import org.junit.jupiter.api.Test; - -/** - * 기간 순서와 선착순 정원은 {@code EventPeriodTest}·{@code RecruitPolicyTest}가 덮고, 여기서는 신청서 질문 규칙을 본다. - */ -class EventWriteValidatorTest { - - private static final EventPeriod APPLY_PERIOD = EventPeriod.of( - LocalDateTime.of(2026, 10, 14, 10, 0), LocalDateTime.of(2026, 10, 25, 23, 59, 59)); - private static final EventPeriod EVENT_PERIOD = EventPeriod.of( - LocalDateTime.of(2026, 11, 1, 10, 0), LocalDateTime.of(2026, 11, 1, 18, 0)); - - private final EventWriteValidator validator = new EventWriteValidator(); - - private static EventCreateCommand createCommand(List questions) { - return new EventCreateCommand( - "체육대회", "설명", "종합운동장", EVENT_PERIOD, APPLY_PERIOD, RecruitPolicy.of(RecruitType.FIRST_COME, 100), - false, List.of(), questions - ); - } - - private static EventQuestionCommand question(QuestionType type, List options) { - return new EventQuestionCommand("질문", type, true, options, false); - } - - private static void assertRejected(EventErrorCode expected, Runnable validation) { - BusinessException e = assertThrows(BusinessException.class, validation::run); - assertEquals(expected, e.getErrorCode()); - } - - @Test - @DisplayName("규칙을 모두 지키면 통과한다") - void validCommandPasses() { - assertDoesNotThrow(() -> validator.validate(createCommand(List.of()))); - } - - @Nested - @DisplayName("신청서 질문") - class Questions { - - @Test - @DisplayName("선택형 질문에 선택지가 없으면 거부한다") - void choiceWithoutOptions() { - for (QuestionType type : List.of(QuestionType.SINGLE_CHOICE, QuestionType.MULTIPLE_CHOICE)) { - assertRejected(EventErrorCode.EVENT_INVALID_QUESTION, - () -> validator.validate(createCommand(List.of(question(type, List.of()))))); - assertRejected(EventErrorCode.EVENT_INVALID_QUESTION, - () -> validator.validate(createCommand(List.of(question(type, null))))); - } - } - - @Test - @DisplayName("여러 질문 중 하나라도 규칙을 어기면 거부한다") - void oneInvalidAmongMany() { - assertRejected(EventErrorCode.EVENT_INVALID_QUESTION, () -> validator.validate(createCommand(List.of( - question(QuestionType.SHORT_TEXT, List.of()), - question(QuestionType.SINGLE_CHOICE, List.of("참석", "불참")), - question(QuestionType.MULTIPLE_CHOICE, List.of()))))); - } - - @Test - @DisplayName("텍스트형 질문은 선택지가 없어도 통과한다") - void textWithoutOptions() { - assertDoesNotThrow(() -> validator.validate(createCommand(List.of( - question(QuestionType.SHORT_TEXT, null), - question(QuestionType.LONG_TEXT, List.of()))))); - } - } - - @Nested - @DisplayName("수정 요청") - class UpdateCommand { - - @Test - @DisplayName("등록과 같은 규칙으로 검증한다") - void sameRulesAsCreate() { - EventUpdateCommand invalidQuestion = new EventUpdateCommand( - "체육대회", "설명", null, EVENT_PERIOD, APPLY_PERIOD, RecruitPolicy.of(RecruitType.OPEN, null), false, - List.of(), List.of(question(QuestionType.SINGLE_CHOICE, List.of())), LocalDateTime.now()); - - assertRejected(EventErrorCode.EVENT_INVALID_QUESTION, () -> validator.validate(invalidQuestion)); - } - } -} From fd16eb5a854704995b72a01f91cd8ef456860b32 Mon Sep 17 00:00:00 2001 From: gain <163835628+leegain1@users.noreply.github.com> Date: Thu, 29 Oct 2026 17:06:26 +0900 Subject: [PATCH 18/19] =?UTF-8?q?refactor:=20=ED=96=89=EC=82=AC=20?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=C2=B7=EC=84=A0=ED=83=9D=EC=A7=80=20?= =?UTF-8?q?=EB=AA=A9=EB=A1=9D=EC=9D=98=20null=20=EC=B2=98=EB=A6=AC?= =?UTF-8?q?=EB=A5=BC=20=EB=B3=B5=EC=9B=90=20=EC=8B=9C=EC=A0=90=EC=9C=BC?= =?UTF-8?q?=EB=A1=9C=20=EB=AA=A8=EC=9D=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/admin/event/event/AdminEventController.java | 2 +- .../event/event/response/AdminEventDetailResponse.java | 4 ++-- .../event/response/EventApplicationDetailResponse.java | 2 +- .../event/event/response/EventFormQuestionResponse.java | 2 +- .../kookmin/stream/event/domain/event/domain/Event.java | 8 +++++--- .../stream/event/domain/event/domain/EventDetail.java | 2 +- .../event/domain/event/domain/EventManageDetail.java | 5 ----- .../stream/event/domain/event/domain/EventQuestion.java | 7 ++++++- .../event/service/impl/EventApplyAnswerValidator.java | 2 +- 9 files changed, 18 insertions(+), 16 deletions(-) diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/AdminEventController.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/AdminEventController.java index be2248b3..292c128b 100644 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/AdminEventController.java +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/AdminEventController.java @@ -70,7 +70,7 @@ public ApiResponse> getEvents( @GetMapping("/{eventId}") public ApiResponse getEvent(AdminApiUser apiUser, @PathVariable Long eventId) { EventManageDetail detail = adminEventService.getEvent(eventId); - Map imagesById = fileService.findAllByIdIn(detail.imageIds()); + Map imagesById = fileService.findAllByIdIn(detail.event().getImageIds()); return ApiResponse.success(AdminEventDetailResponse.of(detail, imagesById, LocalDateTime.now())); } diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/response/AdminEventDetailResponse.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/response/AdminEventDetailResponse.java index 7b9fc9a1..cf10859c 100644 --- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/response/AdminEventDetailResponse.java +++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/response/AdminEventDetailResponse.java @@ -60,7 +60,7 @@ public static AdminEventDetailResponse of(EventManageDetail detail, Map Image.of(fileId, imagesById.get(fileId))) .toList(), @@ -93,7 +93,7 @@ public static Question from(EventQuestion question) { question.getQuestionText(), question.isRequired(), question.getDisplayOrder(), - question.getOptions() == null ? List.of() : question.getOptions(), + question.getOptions(), question.isOtherOptionAllowed() ); } diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/event/response/EventApplicationDetailResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/event/response/EventApplicationDetailResponse.java index 6a2ce886..a2c656ed 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/event/response/EventApplicationDetailResponse.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/event/response/EventApplicationDetailResponse.java @@ -56,7 +56,7 @@ public static AnsweredQuestion from(EventApplicationDetail.AnsweredQuestion answ question.getQuestionType(), question.isRequired(), question.getDisplayOrder(), - question.getOptions() == null ? List.of() : question.getOptions(), + question.getOptions(), answeredQuestion.answerText(), answeredQuestion.selectedOptions() ); diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/event/response/EventFormQuestionResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/event/response/EventFormQuestionResponse.java index 0cb6a9e0..5510dba5 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/event/response/EventFormQuestionResponse.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/api/app/event/event/response/EventFormQuestionResponse.java @@ -22,7 +22,7 @@ public static EventFormQuestionResponse from(EventQuestion question) { questionType, question.isRequired(), question.getDisplayOrder(), - question.getOptions() == null ? List.of() : question.getOptions(), + question.getOptions(), questionType.maxLength() ); } diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/Event.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/Event.java index 332b94be..6440a1e0 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/Event.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/Event.java @@ -31,6 +31,8 @@ public class Event { /** * DB에 저장된 값으로 행사를 복원한다. 기간·모집 값은 표준 생성자로 만들어 검증하지 않는다. 저장값은 등록·수정 때 이미 * 검증했고, {@code event_end_at}이 NULL인 기존 행이 있어 검증하면 조회가 깨진다. + *

+ * 이미지 목록이 없으면 빈 목록으로 바꿔 둔다. 도메인 안에서는 목록이 항상 있다고 보고 null을 확인하지 않는다. */ public static Event of( Long id, @@ -51,8 +53,8 @@ public static Event of( ) { return new Event( id, title, description, place, new EventPeriod(eventStartAt, eventEndAt), - new EventPeriod(applyStartAt, applyEndAt), new RecruitPolicy(recruitType, capacity), imageIds, - feePaidOnly, published, createdBy, updatedAt + new EventPeriod(applyStartAt, applyEndAt), new RecruitPolicy(recruitType, capacity), + imageIds == null ? List.of() : imageIds, feePaidOnly, published, createdBy, updatedAt ); } @@ -156,7 +158,7 @@ public Integer daysUntilDeadline(LocalDateTime now, RecruitStatus recruitStatus) * 대표 이미지 파일 id. 이미지 목록의 첫 장을 대표로 쓴다. 목록·신청 내역이 같은 장을 보여야 하므로 도메인에 둔다. */ public Long thumbnailFileId() { - return imageIds == null || imageIds.isEmpty() ? null : imageIds.getFirst(); + return imageIds.isEmpty() ? null : imageIds.getFirst(); } /** diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventDetail.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventDetail.java index a585cf09..e2151b4e 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventDetail.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventDetail.java @@ -32,7 +32,7 @@ public static EventDetail of(Event event, long applicantCount, LocalDateTime now event.getEventEndAt(), event.getApplyStartAt(), event.getApplyEndAt(), - event.getImageIds() == null ? List.of() : event.getImageIds(), + event.getImageIds(), recruitStatus, event.daysUntilDeadline(now, recruitStatus) ); diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventManageDetail.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventManageDetail.java index 164c5619..5f587282 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventManageDetail.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventManageDetail.java @@ -14,9 +14,4 @@ public record EventManageDetail( List questions, long applicantCount ) { - - /** 행사 이미지 id. 이미지가 없으면 {@code null}이 아니라 빈 목록이다. */ - public List imageIds() { - return event.getImageIds() == null ? List.of() : event.getImageIds(); - } } diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventQuestion.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventQuestion.java index 31b168b0..1b173fc6 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventQuestion.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventQuestion.java @@ -23,6 +23,10 @@ public class EventQuestion { /** 선택형 질문에 '기타'(직접 입력) 선택지를 덧붙이는지. 학생 앱 노출·답변 저장은 아직 없다(후속 작업) */ private boolean otherOptionAllowed; + /** + * DB에 저장된 값으로 질문을 복원한다. 선택지 목록이 없으면 빈 목록으로 바꿔 둔다. 도메인 안에서는 목록이 항상 있다고 보고 + * null을 확인하지 않는다. + */ public static EventQuestion of( Long id, Long eventId, @@ -34,7 +38,8 @@ public static EventQuestion of( boolean otherOptionAllowed ) { return new EventQuestion( - id, eventId, questionText, questionType, required, displayOrder, options, otherOptionAllowed + id, eventId, questionText, questionType, required, displayOrder, options == null ? List.of() : options, + otherOptionAllowed ); } diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventApplyAnswerValidator.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventApplyAnswerValidator.java index 8c4ff052..6c93d282 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventApplyAnswerValidator.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventApplyAnswerValidator.java @@ -90,7 +90,7 @@ private void validateAnswer(EventQuestion question, EventApplyCommand.AnswerComm /** selectedOptions는 질문 options 배열의 0-based 인덱스다. */ private void validateSelectedOptionRange(EventQuestion question, List selectedOptions) { - int optionSize = question.getOptions() == null ? 0 : question.getOptions().size(); + int optionSize = question.getOptions().size(); boolean outOfRange = selectedOptions.stream() .anyMatch(index -> index == null || index < 0 || index >= optionSize); if (outOfRange) { From f9d218ac3fa442e52ae6157f3ad7d3ca120bb037 Mon Sep 17 00:00:00 2001 From: gain <163835628+leegain1@users.noreply.github.com> Date: Sat, 31 Oct 2026 02:23:55 +0900 Subject: [PATCH 19/19] =?UTF-8?q?chore:=20Flyway=20=EB=A7=88=EC=9D=B4?= =?UTF-8?q?=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98=20=EB=B2=84=EC=A0=84=20?= =?UTF-8?q?=EC=A1=B0=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ... => V26__add_fee_paid_only_and_has_other_option_to_events.sql} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename infrastructure/db/src/main/resources/db/migration/{V22__add_fee_paid_only_and_has_other_option_to_events.sql => V26__add_fee_paid_only_and_has_other_option_to_events.sql} (100%) diff --git a/infrastructure/db/src/main/resources/db/migration/V22__add_fee_paid_only_and_has_other_option_to_events.sql b/infrastructure/db/src/main/resources/db/migration/V26__add_fee_paid_only_and_has_other_option_to_events.sql similarity index 100% rename from infrastructure/db/src/main/resources/db/migration/V22__add_fee_paid_only_and_has_other_option_to_events.sql rename to infrastructure/db/src/main/resources/db/migration/V26__add_fee_paid_only_and_has_other_option_to_events.sql