Skip to content
1 change: 1 addition & 0 deletions api/app-api/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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"))

Expand Down
Original file line number Diff line number Diff line change
@@ -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<EventFormResponse> getApplicationForm(@PathVariable Long eventId) {
return ApiResponse.success(EventFormResponse.from(eventService.getApplicationForm(eventId)));
}

@PostMapping("/{eventId}/applications")
public ApiResponse<EventApplyResponse> apply(
AppApiUser apiUser,
@PathVariable Long eventId,
@Valid @RequestBody EventApplyRequest request
) {
return ApiResponse.success(
EventApplyResponse.from(eventService.apply(eventId, apiUser.userId(), request.toCommand()))
);
}
}
Original file line number Diff line number Diff line change
@@ -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<Integer> selectedOptions
) {

public EventApplyCommand.AnswerCommand toCommand() {
return new EventApplyCommand.AnswerCommand(questionId, answerText, selectedOptions);
}
}
}
Original file line number Diff line number Diff line change
@@ -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()
);
}
}
Original file line number Diff line number Diff line change
@@ -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<String> 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()
);
}
}
Original file line number Diff line number Diff line change
@@ -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<EventFormQuestionResponse> 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()
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -48,4 +48,56 @@ public static Event of(
applyEndAt, recruitType, imageIds, capacity, recruitStatus, createdBy
);
}

/**
* 현재 시각과 신청자 수를 반영한 모집 상태를 계산한다.
* <p>
* 저장된 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) {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

이렇게 도메인 안에서 도메인 자체의 메서드를 만들어서 검증하는 방식 너무 좋습니다!!
아주 좋아요~

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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<Integer> selectedOptions
) {
return new EventApplicationAnswer(null, eventApplicationId, eventQuestionId, answerText, selectedOptions);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package kr.ac.kookmin.stream.event.domain.event.domain;

import java.util.List;

/**
* 신청서 폼 조회 결과. 행사 요약과 질문 목록을 함께 돌려주기 위한 읽기 모델이다.
*/
public record EventApplicationForm(Event event, List<EventQuestion> questions) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package kr.ac.kookmin.stream.event.domain.event.domain;

/**
* 행사 신청 결과. 생성된 신청 식별자와 행사 요약을 함께 돌려주기 위한 읽기 모델이다.
*/
public record EventApplicationResult(Long applicationId, Event event) {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
package kr.ac.kookmin.stream.event.domain.event.domain;

import java.util.List;

/**
* 행사 신청 요청. 질문별 답변 목록을 담는다.
*/
public record EventApplyCommand(List<AnswerCommand> answers) {

/**
* 질문 하나에 대한 답변.
*
* @param answerText 단답형·장문형 답변. 선택형이면 null
* @param selectedOptions 선택형 질문에서 고른 선택지의 0-based 인덱스. 주관식이면 빈 목록
*/
public record AnswerCommand(Long questionId, String answerText, List<Integer> selectedOptions) {

/**
* 답변 내용이 비어 있는지. 선택 질문은 생략과 빈 답변을 같게 취급하므로 판정 기준을 한곳에 둔다.
*/
public boolean isEmpty() {
return (answerText == null || answerText.isBlank())
&& (selectedOptions == null || selectedOptions.isEmpty());
}
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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<Event> findById(Long id);

List<EventQuestion> findQuestionsByEventId(Long eventId);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

다른 엔티티를 조회하는 레퍼지토리지만 같은 도메인이라 이렇게 묶어놓은 거 너무 좋네요


long countAppliedByEventId(Long eventId);

boolean existsAppliedByEventIdAndMemberId(Long eventId, Long memberId);

EventApplication saveApplication(EventApplication application);

List<EventApplicationAnswer> saveAnswers(List<EventApplicationAnswer> answers);
}
Original file line number Diff line number Diff line change
@@ -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);
}
Loading