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 1/7] =?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 2/7] =?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 3/7] =?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 4/7] =?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 5/7] =?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 6/7] =?UTF-8?q?refactor:=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=EC=97=90=EC=84=9C=20Eve?= =?UTF-8?q?ntManageSummary=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 7/7] =?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)); + } + } +}