From f2384fe535d1166f1c405abe526ab37ec5861a68 Mon Sep 17 00:00:00 2001 From: sangrae Date: Fri, 11 Sep 2026 12:31:26 +0900 Subject: [PATCH 1/8] =?UTF-8?q?feat:=20=ED=96=89=EC=82=AC=20=EB=AA=A8?= =?UTF-8?q?=EC=A7=91=20=EC=83=81=ED=83=9C=20=EA=B3=84=EC=82=B0=EA=B3=BC=20?= =?UTF-8?q?=EC=A7=88=EB=AC=B8=20=EC=9C=A0=ED=98=95=EB=B3=84=20=EB=8B=B5?= =?UTF-8?q?=EB=B3=80=20=EA=B8=B8=EC=9D=B4=20=EC=A0=9C=ED=95=9C=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/event/domain/Event.java | 24 +++++++++++++++++++ .../domain/event/domain/EventErrorCode.java | 19 +++++++++++++++ .../domain/event/domain/QuestionType.java | 20 ++++++++++++---- 3 files changed, 59 insertions(+), 4 deletions(-) create mode 100644 core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventErrorCode.java 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 9c7c6e8..b357888 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 @@ -48,4 +48,28 @@ public static Event of( applyEndAt, recruitType, imageIds, capacity, recruitStatus, createdBy ); } + + /** + * 현재 시각과 신청자 수를 반영한 모집 상태를 계산한다. + *

+ * 저장된 recruitStatus는 운영진의 강제 마감만을 뜻하므로, 신청 기간과 잔여 정원을 함께 봐야 실제 상태가 나온다. + * 행사 상세 조회·신청서 폼 조회·행사 신청이 같은 기준을 써야 하므로 도메인에 둔다. + * + * @param appliedCount status가 APPLIED인 신청 수. 선착순 모집이 아니면 쓰이지 않는다 + */ + public RecruitStatus calculateRecruitStatus(LocalDateTime now, long appliedCount) { + if (recruitStatus == RecruitStatus.CLOSED) { + return RecruitStatus.CLOSED; + } + if (now.isBefore(applyStartAt)) { + return RecruitStatus.BEFORE_OPEN; + } + if (now.isAfter(applyEndAt)) { + return RecruitStatus.CLOSED; + } + if (recruitType == RecruitType.FIRST_COME && appliedCount >= capacity) { + return RecruitStatus.CLOSED; + } + return RecruitStatus.OPEN; + } } 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 new file mode 100644 index 0000000..8c0b500 --- /dev/null +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventErrorCode.java @@ -0,0 +1,19 @@ +package kr.ac.kookmin.stream.event.domain.event.domain; + +import kr.ac.kookmin.stream.common.ErrorCode; +import kr.ac.kookmin.stream.common.ErrorStatus; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.experimental.Accessors; + +@Getter +@Accessors(fluent = true) +@AllArgsConstructor +public enum EventErrorCode implements ErrorCode { + + EVENT_NOT_FOUND(ErrorStatus.NOT_FOUND, "행사를 찾을 수 없습니다."), + ALREADY_CLOSED(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/QuestionType.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/QuestionType.java index bb1f51f..a599053 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 @@ -1,8 +1,20 @@ package kr.ac.kookmin.stream.event.domain.event.domain; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.experimental.Accessors; + +@Getter +@Accessors(fluent = true) +@AllArgsConstructor(access = AccessLevel.PRIVATE) public enum QuestionType { - SHORT_TEXT, - LONG_TEXT, - SINGLE_CHOICE, - MULTIPLE_CHOICE + + SHORT_TEXT(50), + LONG_TEXT(500), + SINGLE_CHOICE(null), + MULTIPLE_CHOICE(null); + + /** 답변 길이 제한. 선택형 질문은 답변이 텍스트가 아니라 null이다. */ + private final Integer maxLength; } From 23eb79b73c48799fb6d3e17ac2b4f586f2da2e04 Mon Sep 17 00:00:00 2001 From: sangrae Date: Fri, 11 Sep 2026 12:31:26 +0900 Subject: [PATCH 2/8] =?UTF-8?q?feat:=20event=20=EB=8F=84=EB=A9=94=EC=9D=B8?= =?UTF-8?q?=20Repository=C2=B7Service=20=EC=9D=B8=ED=84=B0=ED=8E=98?= =?UTF-8?q?=EC=9D=B4=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../event/domain/EventApplicationForm.java | 23 +++++++++++++++++++ .../event/repository/EventRepository.java | 15 ++++++++++++ .../domain/event/service/EventService.java | 8 +++++++ 3 files changed, 46 insertions(+) create mode 100644 core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventApplicationForm.java create mode 100644 core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/repository/EventRepository.java create mode 100644 core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/EventService.java diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventApplicationForm.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventApplicationForm.java new file mode 100644 index 0000000..dd943f9 --- /dev/null +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventApplicationForm.java @@ -0,0 +1,23 @@ +package kr.ac.kookmin.stream.event.domain.event.domain; + +import java.util.List; +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.Getter; + +/** + * 신청서 폼 조회 결과. 행사 요약과 질문 목록을 함께 돌려주기 위한 읽기 모델이다. + */ +@Getter +@EqualsAndHashCode +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class EventApplicationForm { + + private Event event; + private List questions; + + public static EventApplicationForm of(Event event, List questions) { + return new EventApplicationForm(event, questions); + } +} 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 new file mode 100644 index 0000000..c642f6a --- /dev/null +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/repository/EventRepository.java @@ -0,0 +1,15 @@ +package kr.ac.kookmin.stream.event.domain.event.repository; + +import java.util.List; +import java.util.Optional; +import kr.ac.kookmin.stream.event.domain.event.domain.Event; +import kr.ac.kookmin.stream.event.domain.event.domain.EventQuestion; + +public interface EventRepository { + + Optional findById(Long id); + + List findQuestionsByEventId(Long eventId); + + long countAppliedByEventId(Long 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 new file mode 100644 index 0000000..acc7576 --- /dev/null +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/EventService.java @@ -0,0 +1,8 @@ +package kr.ac.kookmin.stream.event.domain.event.service; + +import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationForm; + +public interface EventService { + + EventApplicationForm getApplicationForm(Long eventId); +} From 78b828440f02d6eb6d79a2024e8a56673b12088d Mon Sep 17 00:00:00 2001 From: sangrae Date: Fri, 11 Sep 2026 12:31:26 +0900 Subject: [PATCH 3/8] =?UTF-8?q?feat:=20event=20=EB=8F=84=EB=A9=94=EC=9D=B8?= =?UTF-8?q?=20Repository=20=EA=B5=AC=ED=98=84=EC=B2=B4=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/EventApplicationJpaRepository.java | 9 +++++ .../stream/db/event/EventJpaRepository.java | 9 +++++ .../db/event/EventQuestionJpaRepository.java | 9 +++++ .../stream/db/event/EventRepositoryImpl.java | 36 +++++++++++++++++++ 4 files changed, 63 insertions(+) create mode 100644 infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventApplicationJpaRepository.java create mode 100644 infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventJpaRepository.java create mode 100644 infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventQuestionJpaRepository.java create mode 100644 infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventRepositoryImpl.java 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 new file mode 100644 index 0000000..8023373 --- /dev/null +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventApplicationJpaRepository.java @@ -0,0 +1,9 @@ +package kr.ac.kookmin.stream.db.event; + +import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationStatus; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface EventApplicationJpaRepository extends JpaRepository { + + long countByEventIdAndStatus(Long eventId, EventApplicationStatus status); +} 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 new file mode 100644 index 0000000..71c6628 --- /dev/null +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventJpaRepository.java @@ -0,0 +1,9 @@ +package kr.ac.kookmin.stream.db.event; + +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface EventJpaRepository extends JpaRepository { + + Optional findByIdAndIsDeletedFalse(Long id); +} 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 new file mode 100644 index 0000000..5fea788 --- /dev/null +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventQuestionJpaRepository.java @@ -0,0 +1,9 @@ +package kr.ac.kookmin.stream.db.event; + +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface EventQuestionJpaRepository extends JpaRepository { + + List findAllByEventIdOrderByDisplayOrderAsc(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 new file mode 100644 index 0000000..9b8eb54 --- /dev/null +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventRepositoryImpl.java @@ -0,0 +1,36 @@ +package kr.ac.kookmin.stream.db.event; + +import java.util.List; +import java.util.Optional; +import kr.ac.kookmin.stream.event.domain.event.domain.Event; +import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationStatus; +import kr.ac.kookmin.stream.event.domain.event.domain.EventQuestion; +import kr.ac.kookmin.stream.event.domain.event.repository.EventRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class EventRepositoryImpl implements EventRepository { + + private final EventJpaRepository eventJpaRepository; + private final EventQuestionJpaRepository eventQuestionJpaRepository; + private final EventApplicationJpaRepository eventApplicationJpaRepository; + + @Override + public Optional findById(Long id) { + return eventJpaRepository.findByIdAndIsDeletedFalse(id).map(EventJpaEntity::toDomain); + } + + @Override + public List findQuestionsByEventId(Long eventId) { + return eventQuestionJpaRepository.findAllByEventIdOrderByDisplayOrderAsc(eventId).stream() + .map(EventQuestionJpaEntity::toDomain) + .toList(); + } + + @Override + public long countAppliedByEventId(Long eventId) { + return eventApplicationJpaRepository.countByEventIdAndStatus(eventId, EventApplicationStatus.APPLIED); + } +} From 41bb3b230461f7ed64790c258f0303be7f1f41de Mon Sep 17 00:00:00 2001 From: sangrae Date: Fri, 11 Sep 2026 12:31:26 +0900 Subject: [PATCH 4/8] =?UTF-8?q?feat:=20=ED=96=89=EC=82=AC=20=EC=8B=A0?= =?UTF-8?q?=EC=B2=AD=EC=84=9C=20=ED=8F=BC=20=EC=A1=B0=ED=9A=8C=20API=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/app-api/build.gradle.kts | 1 + .../stream/event/AppEventController.java | 22 ++++++++++++ .../event/EventFormQuestionResponse.java | 29 ++++++++++++++++ .../stream/event/EventFormResponse.java | 26 ++++++++++++++ .../event/service/impl/EventServiceImpl.java | 34 +++++++++++++++++++ 5 files changed, 112 insertions(+) create mode 100644 api/app-api/src/main/java/kr/ac/kookmin/stream/event/AppEventController.java create mode 100644 api/app-api/src/main/java/kr/ac/kookmin/stream/event/EventFormQuestionResponse.java create mode 100644 api/app-api/src/main/java/kr/ac/kookmin/stream/event/EventFormResponse.java create mode 100644 core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImpl.java diff --git a/api/app-api/build.gradle.kts b/api/app-api/build.gradle.kts index 42bcd18..ab6dba2 100644 --- a/api/app-api/build.gradle.kts +++ b/api/app-api/build.gradle.kts @@ -7,6 +7,7 @@ description = "학생 앱 — STUDENT, /v1/app/**" dependencies { implementation(project(":api:common-api")) implementation(project(":core:common")) + implementation(project(":core:domain:event")) implementation(project(":gateway:auth")) implementation(project(":gateway:logging")) diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/event/AppEventController.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/event/AppEventController.java new file mode 100644 index 0000000..fcc7457 --- /dev/null +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/event/AppEventController.java @@ -0,0 +1,22 @@ +package kr.ac.kookmin.stream.event; + +import kr.ac.kookmin.stream.ApiResponse; +import kr.ac.kookmin.stream.event.domain.event.service.EventService; +import lombok.RequiredArgsConstructor; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/v1/app/events") +@RequiredArgsConstructor +public class AppEventController { + + private final EventService eventService; + + @GetMapping("/{eventId}/form") + public ApiResponse getApplicationForm(@PathVariable Long eventId) { + return ApiResponse.success(EventFormResponse.from(eventService.getApplicationForm(eventId))); + } +} diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/event/EventFormQuestionResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/event/EventFormQuestionResponse.java new file mode 100644 index 0000000..6e3b2fc --- /dev/null +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/event/EventFormQuestionResponse.java @@ -0,0 +1,29 @@ +package kr.ac.kookmin.stream.event; + +import java.util.List; +import kr.ac.kookmin.stream.event.domain.event.domain.EventQuestion; +import kr.ac.kookmin.stream.event.domain.event.domain.QuestionType; + +public record EventFormQuestionResponse( + Long questionId, + String questionText, + QuestionType questionType, + boolean isRequired, + int displayOrder, + List options, + Integer maxLength +) { + + public static EventFormQuestionResponse from(EventQuestion question) { + QuestionType questionType = question.getQuestionType(); + return new EventFormQuestionResponse( + question.getId(), + question.getQuestionText(), + questionType, + question.isRequired(), + question.getDisplayOrder(), + question.getOptions() == null ? List.of() : question.getOptions(), + questionType.maxLength() + ); + } +} diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/event/EventFormResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/event/EventFormResponse.java new file mode 100644 index 0000000..70cf750 --- /dev/null +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/event/EventFormResponse.java @@ -0,0 +1,26 @@ +package kr.ac.kookmin.stream.event; + +import java.time.LocalDateTime; +import java.util.List; +import kr.ac.kookmin.stream.event.domain.event.domain.Event; +import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationForm; + +public record EventFormResponse( + Long eventId, + String title, + LocalDateTime eventStartAt, + String place, + List questions +) { + + public static EventFormResponse from(EventApplicationForm form) { + Event event = form.getEvent(); + return new EventFormResponse( + event.getId(), + event.getTitle(), + event.getEventStartAt(), + event.getPlace(), + form.getQuestions().stream().map(EventFormQuestionResponse::from).toList() + ); + } +} diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImpl.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImpl.java new file mode 100644 index 0000000..832d16a --- /dev/null +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImpl.java @@ -0,0 +1,34 @@ +package kr.ac.kookmin.stream.event.domain.event.service.impl; + +import java.time.LocalDateTime; +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.EventApplicationForm; +import kr.ac.kookmin.stream.event.domain.event.domain.EventErrorCode; +import kr.ac.kookmin.stream.event.domain.event.domain.RecruitStatus; +import kr.ac.kookmin.stream.event.domain.event.repository.EventRepository; +import kr.ac.kookmin.stream.event.domain.event.service.EventService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +class EventServiceImpl implements EventService { + + private final EventRepository eventRepository; + + @Override + @Transactional(readOnly = true) + public EventApplicationForm getApplicationForm(Long eventId) { + Event event = eventRepository.findById(eventId) + .orElseThrow(() -> new BusinessException(EventErrorCode.EVENT_NOT_FOUND)); + + long appliedCount = eventRepository.countAppliedByEventId(eventId); + if (event.calculateRecruitStatus(LocalDateTime.now(), appliedCount) != RecruitStatus.OPEN) { + throw new BusinessException(EventErrorCode.ALREADY_CLOSED); + } + + return EventApplicationForm.of(event, eventRepository.findQuestionsByEventId(eventId)); + } +} From 8e538ddbf645dec586186d053b83225caba5c5fa Mon Sep 17 00:00:00 2001 From: sangrae Date: Fri, 11 Sep 2026 12:46:55 +0900 Subject: [PATCH 5/8] =?UTF-8?q?feat:=20=ED=96=89=EC=82=AC=20=EC=8B=A0?= =?UTF-8?q?=EC=B2=AD=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../stream/event/AppEventController.java | 15 +++ .../stream/event/EventApplyRequest.java | 32 ++++++ .../stream/event/EventApplyResponse.java | 23 +++++ .../event/domain/event/domain/Event.java | 11 ++- .../event/domain/EventApplicationResult.java | 22 +++++ .../event/domain/EventApplyCommand.java | 26 +++++ .../domain/event/domain/EventErrorCode.java | 5 +- .../event/repository/EventRepository.java | 8 ++ .../domain/event/service/EventService.java | 4 + .../impl/EventApplyAnswerValidator.java | 97 +++++++++++++++++++ .../event/service/impl/EventServiceImpl.java | 55 ++++++++++- .../EventApplicationAnswerJpaRepository.java | 7 ++ .../event/EventApplicationJpaRepository.java | 2 + .../stream/db/event/EventRepositoryImpl.java | 25 +++++ 14 files changed, 329 insertions(+), 3 deletions(-) create mode 100644 api/app-api/src/main/java/kr/ac/kookmin/stream/event/EventApplyRequest.java create mode 100644 api/app-api/src/main/java/kr/ac/kookmin/stream/event/EventApplyResponse.java create mode 100644 core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventApplicationResult.java create mode 100644 core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventApplyCommand.java create mode 100644 core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventApplyAnswerValidator.java create mode 100644 infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventApplicationAnswerJpaRepository.java diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/event/AppEventController.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/event/AppEventController.java index fcc7457..7de5744 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/event/AppEventController.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/event/AppEventController.java @@ -1,10 +1,14 @@ package kr.ac.kookmin.stream.event; +import jakarta.validation.Valid; import kr.ac.kookmin.stream.ApiResponse; +import kr.ac.kookmin.stream.app.AppApiUser; import kr.ac.kookmin.stream.event.domain.event.service.EventService; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; 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; import org.springframework.web.bind.annotation.RestController; @@ -19,4 +23,15 @@ public class AppEventController { public ApiResponse getApplicationForm(@PathVariable Long eventId) { return ApiResponse.success(EventFormResponse.from(eventService.getApplicationForm(eventId))); } + + @PostMapping("/{eventId}/applications") + public ApiResponse apply( + AppApiUser apiUser, + @PathVariable Long eventId, + @Valid @RequestBody EventApplyRequest request + ) { + return ApiResponse.success( + EventApplyResponse.from(eventService.apply(eventId, apiUser.userId(), request.toCommand())) + ); + } } diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/event/EventApplyRequest.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/event/EventApplyRequest.java new file mode 100644 index 0000000..0ac8e6a --- /dev/null +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/event/EventApplyRequest.java @@ -0,0 +1,32 @@ +package kr.ac.kookmin.stream.event; + +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; +import java.util.List; +import kr.ac.kookmin.stream.event.domain.event.domain.EventApplyCommand; + +public record EventApplyRequest( + @NotNull(message = "신청서 답변 목록을 입력해 주세요.") + @Valid + List answers +) { + + public EventApplyCommand toCommand() { + return new EventApplyCommand(answers.stream().map(AnswerRequest::toCommand).toList()); + } + + /** + * @param selectedOptions 선택형 질문에서 고른 선택지의 0-based 인덱스 + */ + public record AnswerRequest( + @NotNull(message = "답변 대상 질문을 입력해 주세요.") + Long questionId, + String answerText, + List selectedOptions + ) { + + public EventApplyCommand.AnswerCommand toCommand() { + return new EventApplyCommand.AnswerCommand(questionId, answerText, selectedOptions); + } + } +} diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/event/EventApplyResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/event/EventApplyResponse.java new file mode 100644 index 0000000..9f3b22c --- /dev/null +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/event/EventApplyResponse.java @@ -0,0 +1,23 @@ +package kr.ac.kookmin.stream.event; + +import java.time.LocalDateTime; +import kr.ac.kookmin.stream.event.domain.event.domain.Event; +import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationResult; + +public record EventApplyResponse( + Long applicationId, + String title, + LocalDateTime eventStartAt, + String place +) { + + public static EventApplyResponse from(EventApplicationResult result) { + Event event = result.getEvent(); + return new EventApplyResponse( + result.getApplicationId(), + event.getTitle(), + event.getEventStartAt(), + event.getPlace() + ); + } +} 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 b357888..91932a5 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 @@ -67,9 +67,18 @@ public RecruitStatus calculateRecruitStatus(LocalDateTime now, long appliedCount if (now.isAfter(applyEndAt)) { return RecruitStatus.CLOSED; } - if (recruitType == RecruitType.FIRST_COME && appliedCount >= capacity) { + if (isCapacityFull(appliedCount)) { return RecruitStatus.CLOSED; } return RecruitStatus.OPEN; } + + /** + * 정원이 찼는지 판정한다. 선착순 모집에만 정원 제한이 있고, 상시 모집은 인원 제한이 없다. + *

+ * 모집 상태 계산은 정원 마감을 CLOSED로 합치지만, 신청 실패 사유는 기간 마감과 정원 마감을 구분해야 하므로 분리해 둔다. + */ + public boolean isCapacityFull(long appliedCount) { + return recruitType == RecruitType.FIRST_COME && appliedCount >= capacity; + } } diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventApplicationResult.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventApplicationResult.java new file mode 100644 index 0000000..95d7e47 --- /dev/null +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventApplicationResult.java @@ -0,0 +1,22 @@ +package kr.ac.kookmin.stream.event.domain.event.domain; + +import lombok.AccessLevel; +import lombok.AllArgsConstructor; +import lombok.EqualsAndHashCode; +import lombok.Getter; + +/** + * 행사 신청 결과. 생성된 신청 식별자와 행사 요약을 함께 돌려주기 위한 읽기 모델이다. + */ +@Getter +@EqualsAndHashCode +@AllArgsConstructor(access = AccessLevel.PRIVATE) +public class EventApplicationResult { + + private Long applicationId; + private Event event; + + public static EventApplicationResult of(Long applicationId, Event event) { + return new EventApplicationResult(applicationId, event); + } +} diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventApplyCommand.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventApplyCommand.java new file mode 100644 index 0000000..e42e6d4 --- /dev/null +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventApplyCommand.java @@ -0,0 +1,26 @@ +package kr.ac.kookmin.stream.event.domain.event.domain; + +import java.util.List; + +/** + * 행사 신청 요청. 질문별 답변 목록을 담는다. + */ +public record EventApplyCommand(List answers) { + + /** + * 질문 하나에 대한 답변. + * + * @param answerText 단답형·장문형 답변. 선택형이면 null + * @param selectedOptions 선택형 질문에서 고른 선택지의 0-based 인덱스. 주관식이면 빈 목록 + */ + public record AnswerCommand(Long questionId, String answerText, List selectedOptions) { + + /** + * 답변 내용이 비어 있는지. 선택 질문은 생략과 빈 답변을 같게 취급하므로 판정 기준을 한곳에 둔다. + */ + public boolean isEmpty() { + return (answerText == null || answerText.isBlank()) + && (selectedOptions == null || selectedOptions.isEmpty()); + } + } +} 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 8c0b500..e9e0901 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 @@ -12,7 +12,10 @@ public enum EventErrorCode implements ErrorCode { EVENT_NOT_FOUND(ErrorStatus.NOT_FOUND, "행사를 찾을 수 없습니다."), - ALREADY_CLOSED(ErrorStatus.CONFLICT, "행사 마감되었습니다."); + ALREADY_CLOSED(ErrorStatus.CONFLICT, "행사 마감되었습니다."), + CAPACITY_FULL(ErrorStatus.CONFLICT, "모집 정원이 마감되었습니다."), + ALREADY_APPLIED(ErrorStatus.CONFLICT, "이미 신청한 행사입니다."), + INVALID_ANSWER(ErrorStatus.BAD_REQUEST, "신청서 답변 형식이 올바르지 않습니다."); private final int status; private final String message; 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 c642f6a..3d0b2d9 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 @@ -3,6 +3,8 @@ import java.util.List; import java.util.Optional; 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.EventQuestion; public interface EventRepository { @@ -12,4 +14,10 @@ public interface EventRepository { List findQuestionsByEventId(Long eventId); long countAppliedByEventId(Long eventId); + + boolean existsAppliedByEventIdAndMemberId(Long eventId, Long memberId); + + EventApplication saveApplication(EventApplication application); + + List saveAnswers(List answers); } 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 acc7576..f9bd920 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,8 +1,12 @@ package kr.ac.kookmin.stream.event.domain.event.service; import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationForm; +import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationResult; +import kr.ac.kookmin.stream.event.domain.event.domain.EventApplyCommand; public interface EventService { EventApplicationForm getApplicationForm(Long eventId); + + EventApplicationResult apply(Long eventId, Long memberId, EventApplyCommand command); } diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventApplyAnswerValidator.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventApplyAnswerValidator.java new file mode 100644 index 0000000..c011de4 --- /dev/null +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventApplyAnswerValidator.java @@ -0,0 +1,97 @@ +package kr.ac.kookmin.stream.event.domain.event.service.impl; + +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.function.Function; +import java.util.stream.Collectors; +import kr.ac.kookmin.stream.common.BusinessException; +import kr.ac.kookmin.stream.event.domain.event.domain.EventApplyCommand; +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.QuestionType; +import org.springframework.stereotype.Component; + +/** + * 신청서 답변이 질문 구성에 맞는지 검증한다. + *

+ * 같은 answerText라도 질문 유형에 따라 길이 한도가 달라 Bean Validation으로는 처리할 수 없다. + * 질문을 조회한 뒤에야 판정할 수 있는 규칙을 모아 둔다. + */ +@Component +class EventApplyAnswerValidator { + + void validate(List questions, EventApplyCommand command) { + Map questionById = questions.stream() + .collect(Collectors.toMap(EventQuestion::getId, Function.identity())); + + Set answeredQuestionIds = new HashSet<>(); + for (EventApplyCommand.AnswerCommand answer : command.answers()) { + EventQuestion question = questionById.get(answer.questionId()); + if (question == null) { + throw new BusinessException(EventErrorCode.INVALID_ANSWER); + } + if (!answeredQuestionIds.add(answer.questionId())) { + throw new BusinessException(EventErrorCode.INVALID_ANSWER); + } + if (answer.isEmpty()) { + // 선택 질문은 생략과 빈 답변을 같게 본다. + // 이미 답변한 질문으로 기록됐으므로 아래 누락 검사가 못 잡는다. 필수면 여기서 걸러야 한다 + if (question.isRequired()) { + throw new BusinessException(EventErrorCode.INVALID_ANSWER); + } + continue; + } + validateAnswer(question, answer); + } + + boolean requiredMissing = questions.stream() + .anyMatch(question -> question.isRequired() && !answeredQuestionIds.contains(question.getId())); + if (requiredMissing) { + throw new BusinessException(EventErrorCode.INVALID_ANSWER); + } + } + + private void validateAnswer(EventQuestion question, EventApplyCommand.AnswerCommand answer) { + String answerText = answer.answerText(); + List selectedOptions = answer.selectedOptions() == null ? List.of() : answer.selectedOptions(); + QuestionType questionType = question.getQuestionType(); + + switch (questionType) { + case SHORT_TEXT, LONG_TEXT -> { + if (answerText == null || answerText.isBlank() || !selectedOptions.isEmpty()) { + throw new BusinessException(EventErrorCode.INVALID_ANSWER); + } + if (answerText.length() > questionType.maxLength()) { + throw new BusinessException(EventErrorCode.INVALID_ANSWER); + } + } + case SINGLE_CHOICE -> { + if (answerText != null || selectedOptions.size() != 1) { + throw new BusinessException(EventErrorCode.INVALID_ANSWER); + } + validateSelectedOptionRange(question, selectedOptions); + } + case MULTIPLE_CHOICE -> { + if (answerText != null || selectedOptions.isEmpty()) { + throw new BusinessException(EventErrorCode.INVALID_ANSWER); + } + if (new HashSet<>(selectedOptions).size() != selectedOptions.size()) { + throw new BusinessException(EventErrorCode.INVALID_ANSWER); + } + validateSelectedOptionRange(question, selectedOptions); + } + } + } + + /** selectedOptions는 질문 options 배열의 0-based 인덱스다. */ + private void validateSelectedOptionRange(EventQuestion question, List selectedOptions) { + int optionSize = question.getOptions() == null ? 0 : question.getOptions().size(); + boolean outOfRange = selectedOptions.stream() + .anyMatch(index -> index == null || index < 0 || index >= optionSize); + if (outOfRange) { + throw new BusinessException(EventErrorCode.INVALID_ANSWER); + } + } +} 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 832d16a..caa4400 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,10 +1,17 @@ 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.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.EventApplicationForm; +import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationResult; +import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationStatus; +import kr.ac.kookmin.stream.event.domain.event.domain.EventApplyCommand; 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.RecruitStatus; import kr.ac.kookmin.stream.event.domain.event.repository.EventRepository; import kr.ac.kookmin.stream.event.domain.event.service.EventService; @@ -17,18 +24,64 @@ class EventServiceImpl implements EventService { private final EventRepository eventRepository; + private final EventApplyAnswerValidator eventApplyAnswerValidator; @Override @Transactional(readOnly = true) public EventApplicationForm getApplicationForm(Long eventId) { + Event event = getOpenEvent(eventId); + return EventApplicationForm.of(event, eventRepository.findQuestionsByEventId(eventId)); + } + + @Override + @Transactional + public EventApplicationResult apply(Long eventId, Long memberId, EventApplyCommand command) { + Event event = getOpenEvent(eventId); + + if (eventRepository.existsAppliedByEventIdAndMemberId(eventId, memberId)) { + throw new BusinessException(EventErrorCode.ALREADY_APPLIED); + } + + List questions = eventRepository.findQuestionsByEventId(eventId); + eventApplyAnswerValidator.validate(questions, command); + + EventApplication application = eventRepository.saveApplication(EventApplication.of( + null, eventId, memberId, EventApplicationStatus.APPLIED, LocalDateTime.now(), null + )); + eventRepository.saveAnswers(toAnswers(application.getId(), command)); + + return EventApplicationResult.of(application.getId(), event); + } + + /** + * 모집 중인 행사를 가져온다. 폼 조회와 신청이 같은 기준으로 열려 있어야 하므로 한곳에 둔다. + *

+ * 정원 마감은 기간 마감과 사유를 구분해야 해서 모집 상태 계산보다 먼저 본다. + */ + private Event getOpenEvent(Long eventId) { Event event = eventRepository.findById(eventId) .orElseThrow(() -> new BusinessException(EventErrorCode.EVENT_NOT_FOUND)); long appliedCount = eventRepository.countAppliedByEventId(eventId); + if (event.isCapacityFull(appliedCount)) { + throw new BusinessException(EventErrorCode.CAPACITY_FULL); + } if (event.calculateRecruitStatus(LocalDateTime.now(), appliedCount) != RecruitStatus.OPEN) { throw new BusinessException(EventErrorCode.ALREADY_CLOSED); } + return event; + } - return EventApplicationForm.of(event, eventRepository.findQuestionsByEventId(eventId)); + private List toAnswers(Long applicationId, EventApplyCommand command) { + return command.answers().stream() + .filter(answer -> !answer.isEmpty()) + .map(answer -> EventApplicationAnswer.of( + null, + applicationId, + answer.questionId(), + answer.answerText(), + answer.selectedOptions() == null ? List.of() : answer.selectedOptions() + )) + .toList(); } } diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventApplicationAnswerJpaRepository.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventApplicationAnswerJpaRepository.java new file mode 100644 index 0000000..6069800 --- /dev/null +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventApplicationAnswerJpaRepository.java @@ -0,0 +1,7 @@ +package kr.ac.kookmin.stream.db.event; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface EventApplicationAnswerJpaRepository + extends JpaRepository { +} 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 8023373..6ee5c74 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 @@ -6,4 +6,6 @@ public interface EventApplicationJpaRepository extends JpaRepository { long countByEventIdAndStatus(Long eventId, EventApplicationStatus status); + + boolean existsByEventIdAndMemberIdAndStatus(Long eventId, Long memberId, EventApplicationStatus status); } 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 9b8eb54..bf182ad 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 @@ -3,6 +3,8 @@ import java.util.List; import java.util.Optional; 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.EventApplicationStatus; import kr.ac.kookmin.stream.event.domain.event.domain.EventQuestion; import kr.ac.kookmin.stream.event.domain.event.repository.EventRepository; @@ -16,6 +18,7 @@ public class EventRepositoryImpl implements EventRepository { private final EventJpaRepository eventJpaRepository; private final EventQuestionJpaRepository eventQuestionJpaRepository; private final EventApplicationJpaRepository eventApplicationJpaRepository; + private final EventApplicationAnswerJpaRepository eventApplicationAnswerJpaRepository; @Override public Optional findById(Long id) { @@ -33,4 +36,26 @@ public List findQuestionsByEventId(Long eventId) { public long countAppliedByEventId(Long eventId) { return eventApplicationJpaRepository.countByEventIdAndStatus(eventId, EventApplicationStatus.APPLIED); } + + @Override + public boolean existsAppliedByEventIdAndMemberId(Long eventId, Long memberId) { + return eventApplicationJpaRepository.existsByEventIdAndMemberIdAndStatus( + eventId, memberId, EventApplicationStatus.APPLIED + ); + } + + @Override + public EventApplication saveApplication(EventApplication application) { + return eventApplicationJpaRepository.save(EventApplicationJpaEntity.from(application)).toDomain(); + } + + @Override + public List saveAnswers(List answers) { + List entities = answers.stream() + .map(EventApplicationAnswerJpaEntity::from) + .toList(); + return eventApplicationAnswerJpaRepository.saveAll(entities).stream() + .map(EventApplicationAnswerJpaEntity::toDomain) + .toList(); + } } From 53eac7b60e52912ea63c3025a6c3285b124936c4 Mon Sep 17 00:00:00 2001 From: sangrae Date: Fri, 11 Sep 2026 13:08:31 +0900 Subject: [PATCH 6/8] =?UTF-8?q?fix:=20=EC=8B=A0=EC=B2=AD=EC=84=9C=20?= =?UTF-8?q?=EB=8B=B5=EB=B3=80=20=EA=B2=80=EC=A6=9D=EC=9D=98=20null=20?= =?UTF-8?q?=EC=B2=98=EB=A6=AC=EC=99=80=20=EB=A7=88=EA=B0=90=20=EC=82=AC?= =?UTF-8?q?=EC=9C=A0=20=ED=8C=90=EC=A0=95=20=EB=B3=B4=EC=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../stream/event/EventApplyRequest.java | 2 +- .../event/domain/event/domain/Event.java | 33 +++++++++++++++---- .../impl/EventApplyAnswerValidator.java | 9 +++-- .../event/service/impl/EventServiceImpl.java | 12 +++---- 4 files changed, 39 insertions(+), 17 deletions(-) diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/event/EventApplyRequest.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/event/EventApplyRequest.java index 0ac8e6a..6ca1dc0 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/event/EventApplyRequest.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/event/EventApplyRequest.java @@ -8,7 +8,7 @@ public record EventApplyRequest( @NotNull(message = "신청서 답변 목록을 입력해 주세요.") @Valid - List answers + List<@NotNull(message = "답변 항목이 비어 있습니다.") AnswerRequest> answers ) { public EventApplyCommand toCommand() { 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 91932a5..d786e3d 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 @@ -58,15 +58,12 @@ public static Event of( * @param appliedCount status가 APPLIED인 신청 수. 선착순 모집이 아니면 쓰이지 않는다 */ public RecruitStatus calculateRecruitStatus(LocalDateTime now, long appliedCount) { - if (recruitStatus == RecruitStatus.CLOSED) { + if (isForceClosed() || isAfterApplyPeriod(now)) { return RecruitStatus.CLOSED; } - if (now.isBefore(applyStartAt)) { + if (isBeforeApplyPeriod(now)) { return RecruitStatus.BEFORE_OPEN; } - if (now.isAfter(applyEndAt)) { - return RecruitStatus.CLOSED; - } if (isCapacityFull(appliedCount)) { return RecruitStatus.CLOSED; } @@ -75,10 +72,32 @@ public RecruitStatus calculateRecruitStatus(LocalDateTime now, long appliedCount /** * 정원이 찼는지 판정한다. 선착순 모집에만 정원 제한이 있고, 상시 모집은 인원 제한이 없다. - *

- * 모집 상태 계산은 정원 마감을 CLOSED로 합치지만, 신청 실패 사유는 기간 마감과 정원 마감을 구분해야 하므로 분리해 둔다. */ public boolean isCapacityFull(long appliedCount) { return recruitType == RecruitType.FIRST_COME && appliedCount >= capacity; } + + /** + * 정원 때문에만 닫힌 상태인지. 모집 상태 계산은 강제 마감·기간 종료·정원 마감을 모두 CLOSED로 합치지만, + * 신청 실패 사유는 이 둘을 구분해야 하므로 정원이 유일한 사유일 때를 따로 판정한다. + */ + public boolean isClosedByCapacityOnly(LocalDateTime now, long appliedCount) { + return !isForceClosed() + && !isBeforeApplyPeriod(now) + && !isAfterApplyPeriod(now) + && isCapacityFull(appliedCount); + } + + /** 운영진이 강제로 마감했는지. 저장된 recruitStatus는 이 뜻만 갖는다. */ + private boolean isForceClosed() { + return recruitStatus == RecruitStatus.CLOSED; + } + + private boolean isBeforeApplyPeriod(LocalDateTime now) { + return now.isBefore(applyStartAt); + } + + private boolean isAfterApplyPeriod(LocalDateTime now) { + return now.isAfter(applyEndAt); + } } diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventApplyAnswerValidator.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventApplyAnswerValidator.java index c011de4..2ffeb0a 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventApplyAnswerValidator.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventApplyAnswerValidator.java @@ -57,10 +57,13 @@ private void validateAnswer(EventQuestion question, EventApplyCommand.AnswerComm String answerText = answer.answerText(); List selectedOptions = answer.selectedOptions() == null ? List.of() : answer.selectedOptions(); QuestionType questionType = question.getQuestionType(); + // 선택형은 answerText를 쓰지 않는다. 미사용 필드를 빈 문자열로 채워 보내는 클라이언트도 있어 + // AnswerCommand.isEmpty()와 같은 기준(null 또는 blank)으로 "값 없음"을 판정한다 + boolean hasAnswerText = answerText != null && !answerText.isBlank(); switch (questionType) { case SHORT_TEXT, LONG_TEXT -> { - if (answerText == null || answerText.isBlank() || !selectedOptions.isEmpty()) { + if (!hasAnswerText || !selectedOptions.isEmpty()) { throw new BusinessException(EventErrorCode.INVALID_ANSWER); } if (answerText.length() > questionType.maxLength()) { @@ -68,13 +71,13 @@ private void validateAnswer(EventQuestion question, EventApplyCommand.AnswerComm } } case SINGLE_CHOICE -> { - if (answerText != null || selectedOptions.size() != 1) { + if (hasAnswerText || selectedOptions.size() != 1) { throw new BusinessException(EventErrorCode.INVALID_ANSWER); } validateSelectedOptionRange(question, selectedOptions); } case MULTIPLE_CHOICE -> { - if (answerText != null || selectedOptions.isEmpty()) { + if (hasAnswerText || selectedOptions.isEmpty()) { throw new BusinessException(EventErrorCode.INVALID_ANSWER); } if (new HashSet<>(selectedOptions).size() != selectedOptions.size()) { 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 caa4400..758d836 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 @@ -56,18 +56,18 @@ public EventApplicationResult apply(Long eventId, Long memberId, EventApplyComma /** * 모집 중인 행사를 가져온다. 폼 조회와 신청이 같은 기준으로 열려 있어야 하므로 한곳에 둔다. *

- * 정원 마감은 기간 마감과 사유를 구분해야 해서 모집 상태 계산보다 먼저 본다. + * 닫혀 있으면 사유를 가른다. 강제 마감·기간 종료가 정원 마감보다 앞선 사유다. */ private Event getOpenEvent(Long eventId) { Event event = eventRepository.findById(eventId) .orElseThrow(() -> new BusinessException(EventErrorCode.EVENT_NOT_FOUND)); + LocalDateTime now = LocalDateTime.now(); long appliedCount = eventRepository.countAppliedByEventId(eventId); - if (event.isCapacityFull(appliedCount)) { - throw new BusinessException(EventErrorCode.CAPACITY_FULL); - } - if (event.calculateRecruitStatus(LocalDateTime.now(), appliedCount) != RecruitStatus.OPEN) { - throw new BusinessException(EventErrorCode.ALREADY_CLOSED); + if (event.calculateRecruitStatus(now, appliedCount) != RecruitStatus.OPEN) { + throw new BusinessException(event.isClosedByCapacityOnly(now, appliedCount) + ? EventErrorCode.CAPACITY_FULL + : EventErrorCode.ALREADY_CLOSED); } return event; } From f7c344946f7640e89be28bdde11ce49201c09f57 Mon Sep 17 00:00:00 2001 From: sangrae Date: Sat, 12 Sep 2026 16:29:01 +0900 Subject: [PATCH 7/8] =?UTF-8?q?refactor:=20=EC=8B=A0=EC=B2=AD=20=EC=83=9D?= =?UTF-8?q?=EC=84=B1=20=EC=A0=95=EC=A0=81=20=ED=8C=A9=ED=86=A0=EB=A6=AC=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=EC=99=80=20=EC=9D=BD=EA=B8=B0=20=EB=AA=A8?= =?UTF-8?q?=EB=8D=B8=20record=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../stream/event/EventApplyResponse.java | 4 ++-- .../kookmin/stream/event/EventFormResponse.java | 4 ++-- .../domain/event/domain/EventApplication.java | 7 +++++++ .../event/domain/EventApplicationAnswer.java | 12 ++++++++++++ .../event/domain/EventApplicationForm.java | 16 +--------------- .../event/domain/EventApplicationResult.java | 17 +---------------- .../service/impl/EventApplyAnswerValidator.java | 2 +- .../event/service/impl/EventServiceImpl.java | 14 ++++++-------- 8 files changed, 32 insertions(+), 44 deletions(-) diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/event/EventApplyResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/event/EventApplyResponse.java index 9f3b22c..89ac506 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/event/EventApplyResponse.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/event/EventApplyResponse.java @@ -12,9 +12,9 @@ public record EventApplyResponse( ) { public static EventApplyResponse from(EventApplicationResult result) { - Event event = result.getEvent(); + Event event = result.event(); return new EventApplyResponse( - result.getApplicationId(), + result.applicationId(), event.getTitle(), event.getEventStartAt(), event.getPlace() diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/event/EventFormResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/event/EventFormResponse.java index 70cf750..731cfcf 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/event/EventFormResponse.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/event/EventFormResponse.java @@ -14,13 +14,13 @@ public record EventFormResponse( ) { public static EventFormResponse from(EventApplicationForm form) { - Event event = form.getEvent(); + Event event = form.event(); return new EventFormResponse( event.getId(), event.getTitle(), event.getEventStartAt(), event.getPlace(), - form.getQuestions().stream().map(EventFormQuestionResponse::from).toList() + form.questions().stream().map(EventFormQuestionResponse::from).toList() ); } } diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventApplication.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventApplication.java index 6e56c65..9f8c49f 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventApplication.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventApplication.java @@ -28,4 +28,11 @@ public static EventApplication of( ) { return new EventApplication(id, eventId, memberId, status, appliedAt, canceledAt); } + + /** + * 새 신청을 만든다. 식별자는 저장 시 부여되고, 신청 직후 상태는 항상 APPLIED다. + */ + public static EventApplication create(Long eventId, Long memberId, LocalDateTime appliedAt) { + return new EventApplication(null, eventId, memberId, EventApplicationStatus.APPLIED, appliedAt, null); + } } diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventApplicationAnswer.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventApplicationAnswer.java index bb97c06..97b3240 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventApplicationAnswer.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventApplicationAnswer.java @@ -26,4 +26,16 @@ public static EventApplicationAnswer of( ) { return new EventApplicationAnswer(id, eventApplicationId, eventQuestionId, answerText, selectedOptions); } + + /** + * 새 답변을 만든다. 식별자는 저장 시 부여된다. + */ + public static EventApplicationAnswer create( + Long eventApplicationId, + Long eventQuestionId, + String answerText, + List selectedOptions + ) { + return new EventApplicationAnswer(null, eventApplicationId, eventQuestionId, answerText, selectedOptions); + } } diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventApplicationForm.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventApplicationForm.java index dd943f9..7575de9 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventApplicationForm.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventApplicationForm.java @@ -1,23 +1,9 @@ package kr.ac.kookmin.stream.event.domain.event.domain; import java.util.List; -import lombok.AccessLevel; -import lombok.AllArgsConstructor; -import lombok.EqualsAndHashCode; -import lombok.Getter; /** * 신청서 폼 조회 결과. 행사 요약과 질문 목록을 함께 돌려주기 위한 읽기 모델이다. */ -@Getter -@EqualsAndHashCode -@AllArgsConstructor(access = AccessLevel.PRIVATE) -public class EventApplicationForm { - - private Event event; - private List questions; - - public static EventApplicationForm of(Event event, List questions) { - return new EventApplicationForm(event, questions); - } +public record EventApplicationForm(Event event, List questions) { } diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventApplicationResult.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventApplicationResult.java index 95d7e47..fc7ea08 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventApplicationResult.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventApplicationResult.java @@ -1,22 +1,7 @@ package kr.ac.kookmin.stream.event.domain.event.domain; -import lombok.AccessLevel; -import lombok.AllArgsConstructor; -import lombok.EqualsAndHashCode; -import lombok.Getter; - /** * 행사 신청 결과. 생성된 신청 식별자와 행사 요약을 함께 돌려주기 위한 읽기 모델이다. */ -@Getter -@EqualsAndHashCode -@AllArgsConstructor(access = AccessLevel.PRIVATE) -public class EventApplicationResult { - - private Long applicationId; - private Event event; - - public static EventApplicationResult of(Long applicationId, Event event) { - return new EventApplicationResult(applicationId, event); - } +public record EventApplicationResult(Long applicationId, Event event) { } diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventApplyAnswerValidator.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventApplyAnswerValidator.java index 2ffeb0a..8c4ff05 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventApplyAnswerValidator.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventApplyAnswerValidator.java @@ -80,7 +80,7 @@ private void validateAnswer(EventQuestion question, EventApplyCommand.AnswerComm if (hasAnswerText || selectedOptions.isEmpty()) { throw new BusinessException(EventErrorCode.INVALID_ANSWER); } - if (new HashSet<>(selectedOptions).size() != selectedOptions.size()) { + if (selectedOptions.stream().distinct().count() != selectedOptions.size()) { throw new BusinessException(EventErrorCode.INVALID_ANSWER); } validateSelectedOptionRange(question, selectedOptions); 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 758d836..286f737 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 @@ -8,7 +8,6 @@ import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationAnswer; import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationForm; import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationResult; -import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationStatus; import kr.ac.kookmin.stream.event.domain.event.domain.EventApplyCommand; import kr.ac.kookmin.stream.event.domain.event.domain.EventErrorCode; import kr.ac.kookmin.stream.event.domain.event.domain.EventQuestion; @@ -30,7 +29,7 @@ class EventServiceImpl implements EventService { @Transactional(readOnly = true) public EventApplicationForm getApplicationForm(Long eventId) { Event event = getOpenEvent(eventId); - return EventApplicationForm.of(event, eventRepository.findQuestionsByEventId(eventId)); + return new EventApplicationForm(event, eventRepository.findQuestionsByEventId(eventId)); } @Override @@ -45,12 +44,12 @@ public EventApplicationResult apply(Long eventId, Long memberId, EventApplyComma List questions = eventRepository.findQuestionsByEventId(eventId); eventApplyAnswerValidator.validate(questions, command); - EventApplication application = eventRepository.saveApplication(EventApplication.of( - null, eventId, memberId, EventApplicationStatus.APPLIED, LocalDateTime.now(), null - )); + EventApplication application = eventRepository.saveApplication( + EventApplication.create(eventId, memberId, LocalDateTime.now()) + ); eventRepository.saveAnswers(toAnswers(application.getId(), command)); - return EventApplicationResult.of(application.getId(), event); + return new EventApplicationResult(application.getId(), event); } /** @@ -75,8 +74,7 @@ private Event getOpenEvent(Long eventId) { private List toAnswers(Long applicationId, EventApplyCommand command) { return command.answers().stream() .filter(answer -> !answer.isEmpty()) - .map(answer -> EventApplicationAnswer.of( - null, + .map(answer -> EventApplicationAnswer.create( applicationId, answer.questionId(), answer.answerText(), From fc4208f82daa7dad4d0e1269e7953157c6b3df27 Mon Sep 17 00:00:00 2001 From: sangrae Date: Sat, 12 Sep 2026 16:29:02 +0900 Subject: [PATCH 8/8] =?UTF-8?q?test:=20=ED=96=89=EC=82=AC=20=EB=AA=A8?= =?UTF-8?q?=EC=A7=91=20=EC=83=81=ED=83=9C=C2=B7=EC=8B=A0=EC=B2=AD=EC=84=9C?= =?UTF-8?q?=20=EB=8B=B5=EB=B3=80=20=EA=B2=80=EC=A6=9D=20=EB=8B=A8=EC=9C=84?= =?UTF-8?q?=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../event/domain/event/domain/EventTest.java | 147 +++++++++++++++ .../impl/EventApplyAnswerValidatorTest.java | 174 ++++++++++++++++++ 2 files changed, 321 insertions(+) create mode 100644 core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventTest.java create mode 100644 core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventApplyAnswerValidatorTest.java 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 new file mode 100644 index 0000000..541122d --- /dev/null +++ b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventTest.java @@ -0,0 +1,147 @@ +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.assertTrue; + +import java.time.LocalDateTime; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class EventTest { + + private static final LocalDateTime APPLY_START_AT = LocalDateTime.of(2026, 9, 1, 10, 0); + private static final LocalDateTime APPLY_END_AT = LocalDateTime.of(2026, 9, 10, 18, 0); + private static final int CAPACITY = 30; + + private static Event event(RecruitType recruitType, RecruitStatus recruitStatus) { + return Event.of( + 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, recruitStatus, 1L + ); + } + + private static Event firstComeEvent() { + return event(RecruitType.FIRST_COME, RecruitStatus.OPEN); + } + + @Nested + @DisplayName("모집 상태 계산") + class CalculateRecruitStatus { + + @Test + @DisplayName("신청 기간 안이고 정원이 남았으면 OPEN이다") + void openWithinPeriod() { + assertEquals( + RecruitStatus.OPEN, + firstComeEvent().calculateRecruitStatus(APPLY_START_AT.plusDays(1), CAPACITY - 1) + ); + } + + @Test + @DisplayName("신청 시작 전이면 BEFORE_OPEN이다") + void beforeApplyStart() { + assertEquals( + RecruitStatus.BEFORE_OPEN, + firstComeEvent().calculateRecruitStatus(APPLY_START_AT.minusSeconds(1), 0) + ); + } + + @Test + @DisplayName("신청 종료 후면 CLOSED다") + void afterApplyEnd() { + assertEquals( + RecruitStatus.CLOSED, + firstComeEvent().calculateRecruitStatus(APPLY_END_AT.plusSeconds(1), 0) + ); + } + + @Test + @DisplayName("신청 시작·종료 시각 정각은 모집 중으로 본다") + void boundaryInstantsAreOpen() { + assertEquals(RecruitStatus.OPEN, firstComeEvent().calculateRecruitStatus(APPLY_START_AT, 0)); + assertEquals(RecruitStatus.OPEN, firstComeEvent().calculateRecruitStatus(APPLY_END_AT, 0)); + } + + @Test + @DisplayName("운영진이 강제 마감했으면 기간 안이어도 CLOSED다") + void forceClosedBeatsPeriod() { + Event forceClosed = event(RecruitType.FIRST_COME, RecruitStatus.CLOSED); + assertEquals( + RecruitStatus.CLOSED, + forceClosed.calculateRecruitStatus(APPLY_START_AT.plusDays(1), 0) + ); + } + + @Test + @DisplayName("선착순 모집은 정원이 차면 CLOSED다") + void firstComeClosesWhenFull() { + assertEquals( + RecruitStatus.CLOSED, + firstComeEvent().calculateRecruitStatus(APPLY_START_AT.plusDays(1), CAPACITY) + ); + } + + @Test + @DisplayName("상시 모집은 정원을 넘겨도 OPEN이다") + void openRecruitIgnoresCapacity() { + Event openRecruit = event(RecruitType.OPEN, RecruitStatus.OPEN); + assertEquals( + RecruitStatus.OPEN, + openRecruit.calculateRecruitStatus(APPLY_START_AT.plusDays(1), CAPACITY + 100) + ); + } + } + + @Nested + @DisplayName("정원 마감 여부") + class CapacityFull { + + @Test + @DisplayName("선착순 모집은 신청자가 정원 이상이면 마감이다") + void firstComeIsFull() { + assertTrue(firstComeEvent().isCapacityFull(CAPACITY)); + assertFalse(firstComeEvent().isCapacityFull(CAPACITY - 1)); + } + + @Test + @DisplayName("상시 모집은 정원 제한이 없다") + void openRecruitIsNeverFull() { + assertFalse(event(RecruitType.OPEN, RecruitStatus.OPEN).isCapacityFull(CAPACITY + 100)); + } + } + + @Nested + @DisplayName("정원이 유일한 마감 사유인지") + class ClosedByCapacityOnly { + + @Test + @DisplayName("기간 안에서 정원만 찼으면 참이다") + void capacityIsOnlyReason() { + assertTrue(firstComeEvent().isClosedByCapacityOnly(APPLY_START_AT.plusDays(1), CAPACITY)); + } + + @Test + @DisplayName("신청 기간이 끝났으면 정원이 차도 거짓이다") + void periodEndedTakesPrecedence() { + assertFalse(firstComeEvent().isClosedByCapacityOnly(APPLY_END_AT.plusSeconds(1), CAPACITY)); + } + + @Test + @DisplayName("강제 마감이면 정원이 차도 거짓이다") + void forceClosedTakesPrecedence() { + Event forceClosed = event(RecruitType.FIRST_COME, RecruitStatus.CLOSED); + assertFalse(forceClosed.isClosedByCapacityOnly(APPLY_START_AT.plusDays(1), CAPACITY)); + } + + @Test + @DisplayName("정원이 남았으면 거짓이다") + void notFull() { + assertFalse(firstComeEvent().isClosedByCapacityOnly(APPLY_START_AT.plusDays(1), CAPACITY - 1)); + } + } +} 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 new file mode 100644 index 0000000..115de48 --- /dev/null +++ b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventApplyAnswerValidatorTest.java @@ -0,0 +1,174 @@ +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.util.List; +import kr.ac.kookmin.stream.common.BusinessException; +import kr.ac.kookmin.stream.event.domain.event.domain.EventApplyCommand; +import kr.ac.kookmin.stream.event.domain.event.domain.EventApplyCommand.AnswerCommand; +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.QuestionType; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +class EventApplyAnswerValidatorTest { + + private static final Long SHORT_TEXT_ID = 1L; + private static final Long LONG_TEXT_ID = 2L; + private static final Long SINGLE_CHOICE_ID = 3L; + private static final Long MULTIPLE_CHOICE_ID = 4L; + + 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); + } + + /** 단답(필수) / 장문(선택) / 객관식 3지(필수) / 체크박스 2지(선택) */ + private static List questions() { + return List.of( + question(SHORT_TEXT_ID, QuestionType.SHORT_TEXT, true, null), + question(LONG_TEXT_ID, QuestionType.LONG_TEXT, false, null), + question(SINGLE_CHOICE_ID, QuestionType.SINGLE_CHOICE, true, List.of("13시", "14시", "15시")), + question(MULTIPLE_CHOICE_ID, QuestionType.MULTIPLE_CHOICE, false, List.of("레크리에이션", "네트워킹")) + ); + } + + private static EventApplyCommand command(AnswerCommand... answers) { + return new EventApplyCommand(List.of(answers)); + } + + private static AnswerCommand text(Long questionId, String answerText) { + return new AnswerCommand(questionId, answerText, List.of()); + } + + private static AnswerCommand choice(Long questionId, Integer... selectedOptions) { + return new AnswerCommand(questionId, null, List.of(selectedOptions)); + } + + private void assertInvalidAnswer(EventApplyCommand command) { + BusinessException exception = assertThrows( + BusinessException.class, () -> validator.validate(questions(), command) + ); + assertEquals(EventErrorCode.INVALID_ANSWER, exception.getErrorCode()); + } + + @Test + @DisplayName("필수 질문만 채우고 선택 질문을 생략하면 통과한다") + void passWithRequiredOnly() { + assertDoesNotThrow(() -> validator.validate( + questions(), + command(text(SHORT_TEXT_ID, "소프트웨어학부"), choice(SINGLE_CHOICE_ID, 0)) + )); + } + + @Test + @DisplayName("선택 질문을 빈 값으로 보내도 생략과 같게 통과한다") + void passWithEmptyOptionalAnswer() { + assertDoesNotThrow(() -> validator.validate( + questions(), + command( + text(SHORT_TEXT_ID, "소프트웨어학부"), + choice(SINGLE_CHOICE_ID, 0), + text(LONG_TEXT_ID, " "), + new AnswerCommand(MULTIPLE_CHOICE_ID, null, List.of()) + ) + )); + } + + @Test + @DisplayName("선택형에 빈 문자열 answerText가 섞여 와도 통과한다") + void passWhenChoiceCarriesBlankAnswerText() { + assertDoesNotThrow(() -> validator.validate( + questions(), + command( + text(SHORT_TEXT_ID, "소프트웨어학부"), + new AnswerCommand(SINGLE_CHOICE_ID, "", List.of(0)) + ) + )); + } + + @Test + @DisplayName("필수 질문을 생략하면 실패한다") + void rejectMissingRequired() { + assertInvalidAnswer(command(text(SHORT_TEXT_ID, "소프트웨어학부"))); + } + + @Test + @DisplayName("필수 질문을 빈 값으로 보내면 실패한다") + void rejectBlankRequired() { + assertInvalidAnswer(command(text(SHORT_TEXT_ID, " "), choice(SINGLE_CHOICE_ID, 0))); + } + + @Test + @DisplayName("해당 행사의 질문이 아니면 실패한다") + void rejectUnknownQuestion() { + assertInvalidAnswer(command( + text(SHORT_TEXT_ID, "소프트웨어학부"), choice(SINGLE_CHOICE_ID, 0), text(999L, "값") + )); + } + + @Test + @DisplayName("같은 질문에 두 번 답하면 실패한다") + void rejectDuplicateQuestion() { + assertInvalidAnswer(command( + text(SHORT_TEXT_ID, "소프트웨어학부"), text(SHORT_TEXT_ID, "인공지능학부"), choice(SINGLE_CHOICE_ID, 0) + )); + } + + @Test + @DisplayName("단답형이 50자를 넘으면 실패한다") + void rejectShortTextOverMaxLength() { + assertInvalidAnswer(command(text(SHORT_TEXT_ID, "가".repeat(51)), choice(SINGLE_CHOICE_ID, 0))); + } + + @Test + @DisplayName("장문형은 500자까지 통과하고 501자는 실패한다") + void longTextMaxLengthBoundary() { + assertDoesNotThrow(() -> validator.validate( + questions(), + command(text(SHORT_TEXT_ID, "학부"), choice(SINGLE_CHOICE_ID, 0), text(LONG_TEXT_ID, "가".repeat(500))) + )); + assertInvalidAnswer(command( + text(SHORT_TEXT_ID, "학부"), choice(SINGLE_CHOICE_ID, 0), text(LONG_TEXT_ID, "가".repeat(501)) + )); + } + + @Test + @DisplayName("주관식에 선택지가 함께 오면 실패한다") + void rejectTextAnswerWithSelectedOptions() { + assertInvalidAnswer(command( + new AnswerCommand(SHORT_TEXT_ID, "학부", List.of(0)), choice(SINGLE_CHOICE_ID, 0) + )); + } + + @Test + @DisplayName("객관식에 선택지를 두 개 고르면 실패한다") + void rejectSingleChoiceWithMultipleOptions() { + assertInvalidAnswer(command(text(SHORT_TEXT_ID, "학부"), choice(SINGLE_CHOICE_ID, 0, 1))); + } + + @Test + @DisplayName("체크박스에서 같은 선택지를 중복으로 고르면 실패한다") + void rejectDuplicateSelectedOptions() { + assertInvalidAnswer(command( + text(SHORT_TEXT_ID, "학부"), choice(SINGLE_CHOICE_ID, 0), choice(MULTIPLE_CHOICE_ID, 1, 1) + )); + } + + @Test + @DisplayName("선택지 인덱스가 범위를 벗어나면 실패한다") + void rejectOutOfRangeSelectedOption() { + assertInvalidAnswer(command(text(SHORT_TEXT_ID, "학부"), choice(SINGLE_CHOICE_ID, 3))); + assertInvalidAnswer(command(text(SHORT_TEXT_ID, "학부"), choice(SINGLE_CHOICE_ID, -1))); + } + + @Test + @DisplayName("질문이 없는 행사는 빈 답변으로 통과한다") + void passWhenNoQuestions() { + assertDoesNotThrow(() -> validator.validate(List.of(), new EventApplyCommand(List.of()))); + } +}