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..7de5744 --- /dev/null +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/event/AppEventController.java @@ -0,0 +1,37 @@ +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; + +@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))); + } + + @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..6ca1dc0 --- /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<@NotNull(message = "답변 항목이 비어 있습니다.") AnswerRequest> 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..89ac506 --- /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.event(); + return new EventApplyResponse( + result.applicationId(), + event.getTitle(), + event.getEventStartAt(), + event.getPlace() + ); + } +} 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..731cfcf --- /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.event(); + return new EventFormResponse( + event.getId(), + event.getTitle(), + event.getEventStartAt(), + event.getPlace(), + form.questions().stream().map(EventFormQuestionResponse::from).toList() + ); + } +} diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/Event.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/Event.java index 9c7c6e8..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 @@ -48,4 +48,56 @@ public static Event of( applyEndAt, recruitType, imageIds, capacity, recruitStatus, createdBy ); } + + /** + * 현재 시각과 신청자 수를 반영한 모집 상태를 계산한다. + *

+ * 저장된 recruitStatus는 운영진의 강제 마감만을 뜻하므로, 신청 기간과 잔여 정원을 함께 봐야 실제 상태가 나온다. + * 행사 상세 조회·신청서 폼 조회·행사 신청이 같은 기준을 써야 하므로 도메인에 둔다. + * + * @param appliedCount status가 APPLIED인 신청 수. 선착순 모집이 아니면 쓰이지 않는다 + */ + public RecruitStatus calculateRecruitStatus(LocalDateTime now, long appliedCount) { + if (isForceClosed() || isAfterApplyPeriod(now)) { + return RecruitStatus.CLOSED; + } + if (isBeforeApplyPeriod(now)) { + return RecruitStatus.BEFORE_OPEN; + } + if (isCapacityFull(appliedCount)) { + return RecruitStatus.CLOSED; + } + return RecruitStatus.OPEN; + } + + /** + * 정원이 찼는지 판정한다. 선착순 모집에만 정원 제한이 있고, 상시 모집은 인원 제한이 없다. + */ + 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/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 new file mode 100644 index 0000000..7575de9 --- /dev/null +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventApplicationForm.java @@ -0,0 +1,9 @@ +package kr.ac.kookmin.stream.event.domain.event.domain; + +import java.util.List; + +/** + * 신청서 폼 조회 결과. 행사 요약과 질문 목록을 함께 돌려주기 위한 읽기 모델이다. + */ +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 new file mode 100644 index 0000000..fc7ea08 --- /dev/null +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventApplicationResult.java @@ -0,0 +1,7 @@ +package kr.ac.kookmin.stream.event.domain.event.domain; + +/** + * 행사 신청 결과. 생성된 신청 식별자와 행사 요약을 함께 돌려주기 위한 읽기 모델이다. + */ +public record EventApplicationResult(Long applicationId, Event 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 new file mode 100644 index 0000000..e9e0901 --- /dev/null +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventErrorCode.java @@ -0,0 +1,22 @@ +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, "행사 마감되었습니다."), + 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/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; } 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..3d0b2d9 --- /dev/null +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/repository/EventRepository.java @@ -0,0 +1,23 @@ +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.EventApplication; +import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationAnswer; +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); + + 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 new file mode 100644 index 0000000..f9bd920 --- /dev/null +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/EventService.java @@ -0,0 +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..8c4ff05 --- /dev/null +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventApplyAnswerValidator.java @@ -0,0 +1,100 @@ +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(); + // 선택형은 answerText를 쓰지 않는다. 미사용 필드를 빈 문자열로 채워 보내는 클라이언트도 있어 + // AnswerCommand.isEmpty()와 같은 기준(null 또는 blank)으로 "값 없음"을 판정한다 + boolean hasAnswerText = answerText != null && !answerText.isBlank(); + + switch (questionType) { + case SHORT_TEXT, LONG_TEXT -> { + if (!hasAnswerText || !selectedOptions.isEmpty()) { + throw new BusinessException(EventErrorCode.INVALID_ANSWER); + } + if (answerText.length() > questionType.maxLength()) { + throw new BusinessException(EventErrorCode.INVALID_ANSWER); + } + } + case SINGLE_CHOICE -> { + if (hasAnswerText || selectedOptions.size() != 1) { + throw new BusinessException(EventErrorCode.INVALID_ANSWER); + } + validateSelectedOptionRange(question, selectedOptions); + } + case MULTIPLE_CHOICE -> { + if (hasAnswerText || selectedOptions.isEmpty()) { + throw new BusinessException(EventErrorCode.INVALID_ANSWER); + } + if (selectedOptions.stream().distinct().count() != 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 new file mode 100644 index 0000000..286f737 --- /dev/null +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImpl.java @@ -0,0 +1,85 @@ +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.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; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +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 new EventApplicationForm(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.create(eventId, memberId, LocalDateTime.now()) + ); + eventRepository.saveAnswers(toAnswers(application.getId(), command)); + + return new EventApplicationResult(application.getId(), event); + } + + /** + * 모집 중인 행사를 가져온다. 폼 조회와 신청이 같은 기준으로 열려 있어야 하므로 한곳에 둔다. + *

+ * 닫혀 있으면 사유를 가른다. 강제 마감·기간 종료가 정원 마감보다 앞선 사유다. + */ + 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.calculateRecruitStatus(now, appliedCount) != RecruitStatus.OPEN) { + throw new BusinessException(event.isClosedByCapacityOnly(now, appliedCount) + ? EventErrorCode.CAPACITY_FULL + : EventErrorCode.ALREADY_CLOSED); + } + return event; + } + + private List toAnswers(Long applicationId, EventApplyCommand command) { + return command.answers().stream() + .filter(answer -> !answer.isEmpty()) + .map(answer -> EventApplicationAnswer.create( + applicationId, + answer.questionId(), + answer.answerText(), + answer.selectedOptions() == null ? List.of() : answer.selectedOptions() + )) + .toList(); + } +} 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()))); + } +} 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 new file mode 100644 index 0000000..6ee5c74 --- /dev/null +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventApplicationJpaRepository.java @@ -0,0 +1,11 @@ +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); + + boolean existsByEventIdAndMemberIdAndStatus(Long eventId, Long memberId, 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..bf182ad --- /dev/null +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventRepositoryImpl.java @@ -0,0 +1,61 @@ +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.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; +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; + private final EventApplicationAnswerJpaRepository eventApplicationAnswerJpaRepository; + + @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); + } + + @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(); + } +}