diff --git a/api/admin-api/build.gradle.kts b/api/admin-api/build.gradle.kts
index 983679fe..3d452814 100644
--- a/api/admin-api/build.gradle.kts
+++ b/api/admin-api/build.gradle.kts
@@ -7,6 +7,7 @@ description = "운영진 콘솔 — ADMIN, /v1/admin/**"
dependencies {
implementation(project(":api:common-api"))
implementation(project(":core:common"))
+ implementation(project(":core:domain:event"))
implementation(project(":core:domain:file"))
implementation(project(":core:domain:internal"))
implementation(project(":core:domain:member"))
diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/AdminEventApi.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/AdminEventApi.java
new file mode 100644
index 00000000..b2368aef
--- /dev/null
+++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/AdminEventApi.java
@@ -0,0 +1,74 @@
+package kr.ac.kookmin.stream.api.admin.event.event;
+
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import kr.ac.kookmin.stream.api.admin.AdminApiUser;
+import kr.ac.kookmin.stream.api.admin.event.event.request.EventCreateRequest;
+import kr.ac.kookmin.stream.api.admin.event.event.request.EventSearchParams;
+import kr.ac.kookmin.stream.api.admin.event.event.request.EventUpdateRequest;
+import kr.ac.kookmin.stream.api.admin.event.event.response.AdminEventDetailResponse;
+import kr.ac.kookmin.stream.api.admin.event.event.response.AdminEventListItemResponse;
+import kr.ac.kookmin.stream.api.admin.event.event.response.EventCreateResponse;
+import kr.ac.kookmin.stream.api.admin.event.event.response.EventUpdateResponse;
+import kr.ac.kookmin.stream.api.common.dto.ApiResponse;
+import kr.ac.kookmin.stream.api.common.dto.PageParams;
+import kr.ac.kookmin.stream.api.common.dto.PageResponse;
+import kr.ac.kookmin.stream.api.common.openapi.ApiErrorCode;
+import kr.ac.kookmin.stream.common.CommonErrorCode;
+import kr.ac.kookmin.stream.event.domain.event.domain.EventErrorCode;
+import kr.ac.kookmin.stream.file.domain.FileErrorCode;
+import org.springdoc.core.annotations.ParameterObject;
+
+/**
+ * 운영진 행사 API의 문서 명세. 구현은 {@link AdminEventController}가 맡는다.
+ *
+ * 접근은 {@code ADMIN} role이면 된다. 부서 제한은 두지 않았다.
+ */
+@Tag(name = "행사 관리", description = "운영진 행사 등록·조회·수정")
+public interface AdminEventApi {
+
+ /** 행사 등록. 작성자는 토큰의 관리자로 정해진다. */
+ @Operation(summary = "행사 등록",
+ description = "행사와 신청서 질문을 등록한다. 이미지는 업로드를 마친 파일 ID로 넘기며, 존재하지 않는 파일이 하나라도 "
+ + "있으면 등록하지 않는다. 새 행사는 비공개로 만들어진다.")
+ @ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"})
+ @ApiErrorCode(
+ type = EventErrorCode.class,
+ codes = {"EVENT_INVALID_PERIOD", "EVENT_INVALID_CAPACITY", "EVENT_INVALID_QUESTION"}
+ )
+ @ApiErrorCode(type = FileErrorCode.class, codes = {"FILE_NOT_FOUND"})
+ ApiResponse createEvent(AdminApiUser apiUser, EventCreateRequest request);
+
+ /** 행사 목록. 비공개 행사도 함께 내려준다. */
+ @Operation(summary = "행사 목록 조회",
+ description = "삭제되지 않은 행사를 공개 여부와 상관없이 최근 등록 순으로 페이지 단위 조회한다. keyword로 제목을 검색한다. "
+ + "모집 상태는 조회 시점의 신청 기간·잔여 정원으로 계산한다.")
+ @ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"})
+ ApiResponse> getEvents(
+ AdminApiUser apiUser,
+ @ParameterObject EventSearchParams params,
+ @ParameterObject PageParams pageParams
+ );
+
+ /** 행사 상세. 수정 화면의 초기값으로 쓴다. */
+ @Operation(summary = "행사 상세 조회",
+ description = "행사 정보와 신청서 질문 전체를 조회한다. 비공개 행사도 조회할 수 있다.")
+ @ApiErrorCode(type = EventErrorCode.class, codes = {"EVENT_NOT_FOUND"})
+ ApiResponse getEvent(AdminApiUser apiUser, Long eventId);
+
+ /** 행사 수정. 요청의 수정 시각이 저장된 값과 다르면 거부한다. */
+ @Operation(summary = "행사 수정",
+ description = "행사 내용과 신청서 질문을 통째로 바꾼다. 수정 화면이 행사를 읽은 뒤 다른 관리자가 먼저 고쳤다면"
+ + "(수정 시각 불일치) 409로 거부한다. 신청 내역(취소 포함)이 있는 행사는 질문을 바꿀 수 없고, "
+ + "선착순 정원은 현재 신청자 수보다 줄일 수 없다. 공개 여부는 바꾸지 않는다.")
+ @ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"})
+ @ApiErrorCode(
+ type = EventErrorCode.class,
+ codes = {
+ "EVENT_NOT_FOUND", "EVENT_INVALID_PERIOD", "EVENT_INVALID_CAPACITY", "EVENT_INVALID_QUESTION",
+ "EVENT_QUESTIONS_LOCKED", "EVENT_CAPACITY_BELOW_APPLICANTS", "EVENT_UPDATE_CONFLICT"
+ }
+ )
+ @ApiErrorCode(type = FileErrorCode.class, codes = {"FILE_NOT_FOUND"})
+ ApiResponse updateEvent(AdminApiUser apiUser, Long eventId, EventUpdateRequest request);
+}
diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/AdminEventController.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/AdminEventController.java
new file mode 100644
index 00000000..e6399076
--- /dev/null
+++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/AdminEventController.java
@@ -0,0 +1,88 @@
+package kr.ac.kookmin.stream.api.admin.event.event;
+
+import jakarta.validation.Valid;
+import java.time.LocalDateTime;
+import java.util.Map;
+import kr.ac.kookmin.stream.api.admin.AdminApiUser;
+import kr.ac.kookmin.stream.api.admin.event.event.request.EventCreateRequest;
+import kr.ac.kookmin.stream.api.admin.event.event.request.EventSearchParams;
+import kr.ac.kookmin.stream.api.admin.event.event.request.EventUpdateRequest;
+import kr.ac.kookmin.stream.api.admin.event.event.response.AdminEventDetailResponse;
+import kr.ac.kookmin.stream.api.admin.event.event.response.AdminEventListItemResponse;
+import kr.ac.kookmin.stream.api.admin.event.event.response.EventCreateResponse;
+import kr.ac.kookmin.stream.api.admin.event.event.response.EventUpdateResponse;
+import kr.ac.kookmin.stream.api.admin.event.event.usecase.AdminEventWriteUseCase;
+import kr.ac.kookmin.stream.api.common.dto.ApiResponse;
+import kr.ac.kookmin.stream.api.common.dto.PageParams;
+import kr.ac.kookmin.stream.api.common.dto.PageResponse;
+import kr.ac.kookmin.stream.event.domain.event.domain.Event;
+import kr.ac.kookmin.stream.common.PageResult;
+import kr.ac.kookmin.stream.event.domain.event.domain.EventManageDetail;
+import kr.ac.kookmin.stream.event.domain.event.service.EventService;
+import kr.ac.kookmin.stream.file.domain.File;
+import kr.ac.kookmin.stream.file.service.FileService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.web.bind.annotation.GetMapping;
+import org.springframework.web.bind.annotation.ModelAttribute;
+import org.springframework.web.bind.annotation.PathVariable;
+import org.springframework.web.bind.annotation.PostMapping;
+import org.springframework.web.bind.annotation.PutMapping;
+import org.springframework.web.bind.annotation.RequestBody;
+import org.springframework.web.bind.annotation.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@RequestMapping("/v1/admin/events")
+@RequiredArgsConstructor
+public class AdminEventController implements AdminEventApi {
+
+ private final EventService eventService;
+ private final FileService fileService;
+ private final AdminEventWriteUseCase adminEventWriteUseCase;
+
+ @Override
+ @PostMapping
+ public ApiResponse createEvent(
+ AdminApiUser apiUser,
+ @Valid @RequestBody EventCreateRequest request
+ ) {
+ Event event = adminEventWriteUseCase.create(apiUser.userId(), request.toCommand());
+ return ApiResponse.success(EventCreateResponse.from(event));
+ }
+
+ @Override
+ @GetMapping
+ public ApiResponse> getEvents(
+ AdminApiUser apiUser,
+ @Valid @ModelAttribute EventSearchParams params,
+ @Valid @ModelAttribute PageParams pageParams
+ ) {
+ PageResult events = eventService.getEvents(params.toKeyword(), pageParams.toOffset());
+ Map applicantCounts = eventService.getApplicantCounts(
+ events.content().stream().map(Event::getId).toList());
+ LocalDateTime now = LocalDateTime.now();
+
+ return ApiResponse.success(PageResponse.from(events, event ->
+ AdminEventListItemResponse.of(event, applicantCounts.getOrDefault(event.getId(), 0L), now)));
+ }
+
+ @Override
+ @GetMapping("/{eventId}")
+ public ApiResponse getEvent(AdminApiUser apiUser, @PathVariable Long eventId) {
+ EventManageDetail detail = eventService.getEvent(eventId);
+ Map imagesById = fileService.findAllByIdIn(detail.imageIds());
+
+ return ApiResponse.success(AdminEventDetailResponse.of(detail, imagesById, LocalDateTime.now()));
+ }
+
+ @Override
+ @PutMapping("/{eventId}")
+ public ApiResponse updateEvent(
+ AdminApiUser apiUser,
+ @PathVariable Long eventId,
+ @Valid @RequestBody EventUpdateRequest request
+ ) {
+ Event event = adminEventWriteUseCase.update(eventId, request.toCommand());
+ return ApiResponse.success(EventUpdateResponse.from(event));
+ }
+}
diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/request/EventCreateRequest.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/request/EventCreateRequest.java
new file mode 100644
index 00000000..d8509ffc
--- /dev/null
+++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/request/EventCreateRequest.java
@@ -0,0 +1,86 @@
+package kr.ac.kookmin.stream.api.admin.event.event.request;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Size;
+import java.time.LocalDateTime;
+import java.util.List;
+import kr.ac.kookmin.stream.event.domain.event.domain.EventCreateCommand;
+import kr.ac.kookmin.stream.event.domain.event.domain.RecruitType;
+
+/**
+ * 작성자는 요청 본문이 아니라 토큰의 관리자 ID로 정한다. 공개 여부는 받지 않고 비공개로 시작한다.
+ */
+public record EventCreateRequest(
+ @Schema(description = "행사 제목", example = "체육대회")
+ @NotBlank(message = "행사 제목을 입력해 주세요.")
+ @Size(max = 200, message = "행사 제목은 200자 이하로 입력해 주세요.")
+ String title,
+
+ // description은 TEXT 컬럼(최대 65,535바이트)이다. 글자당 최대 4바이트로 잡아도 넘치지 않는 길이로 막는다
+ @Schema(description = "행사 소개글", example = "학과 대항 체육대회입니다.")
+ @NotBlank(message = "행사 소개글을 입력해 주세요.")
+ @Size(max = 16000, message = "행사 소개글은 16000자 이하로 입력해 주세요.")
+ String description,
+
+ @Schema(description = "행사 장소", example = "종합운동장")
+ @Size(max = 255, message = "행사 장소는 255자 이하로 입력해 주세요.")
+ String place,
+
+ @Schema(description = "행사 시작 일시", example = "2026-11-01T10:00:00")
+ @NotNull(message = "행사 시작 일시를 입력해 주세요.")
+ LocalDateTime eventStartAt,
+
+ @Schema(description = "행사 종료 일시. 시작 일시보다 늦어야 한다", example = "2026-11-01T18:00:00")
+ @NotNull(message = "행사 종료 일시를 입력해 주세요.")
+ LocalDateTime eventEndAt,
+
+ @Schema(description = "신청 시작 일시", example = "2026-10-14T10:00:00")
+ @NotNull(message = "신청 시작 일시를 입력해 주세요.")
+ LocalDateTime applyStartAt,
+
+ @Schema(description = "신청 마감 일시. 시작 일시보다 늦어야 한다", example = "2026-10-25T23:59:59")
+ @NotNull(message = "신청 마감 일시를 입력해 주세요.")
+ LocalDateTime applyEndAt,
+
+ @Schema(description = "모집 방식. 선착순이면 FIRST_COME, 인원 제한이 없으면 OPEN")
+ @NotNull(message = "모집 방식을 입력해 주세요.")
+ RecruitType recruitType,
+
+ @Schema(description = "모집 정원. FIRST_COME이면 1 이상 필요하고, OPEN이면 무시한다", example = "100")
+ Integer capacity,
+
+ @Schema(description = "학생회비 납부자만 신청 가능 여부")
+ @NotNull(message = "학생회비 납부자 전용 여부를 입력해 주세요.")
+ Boolean feePaidOnly,
+
+ @Schema(description = "행사 이미지 파일 ID 목록. 첫 번째가 대표 이미지가 된다", example = "[31, 32]")
+ @NotNull(message = "이미지 파일 목록을 입력해 주세요.")
+ @Size(max = 10, message = "이미지는 10장까지 등록할 수 있습니다.")
+ List<@NotNull(message = "이미지 파일 ID는 비워 둘 수 없습니다.") Long> imageIds,
+
+ @Schema(description = "신청서 질문 목록. 목록 순서대로 신청서에 노출된다")
+ @NotNull(message = "신청서 질문 목록을 입력해 주세요.")
+ @Valid
+ List<@NotNull(message = "신청서 질문이 비어 있습니다.") EventQuestionRequest> questions
+) {
+
+ public EventCreateCommand toCommand() {
+ return new EventCreateCommand(
+ title,
+ description,
+ place,
+ eventStartAt,
+ eventEndAt,
+ applyStartAt,
+ applyEndAt,
+ recruitType,
+ capacity,
+ feePaidOnly,
+ imageIds.stream().distinct().toList(),
+ questions.stream().map(EventQuestionRequest::toCommand).toList()
+ );
+ }
+}
diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/request/EventQuestionRequest.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/request/EventQuestionRequest.java
new file mode 100644
index 00000000..a507a69a
--- /dev/null
+++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/request/EventQuestionRequest.java
@@ -0,0 +1,44 @@
+package kr.ac.kookmin.stream.api.admin.event.event.request;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Size;
+import java.util.List;
+import kr.ac.kookmin.stream.event.domain.event.domain.EventQuestionCommand;
+import kr.ac.kookmin.stream.event.domain.event.domain.QuestionType;
+
+/**
+ * 신청서 질문 한 건. 행사 등록·수정 요청이 함께 쓴다. 노출 순서는 목록에서의 위치로 정하므로 따로 받지 않는다.
+ */
+public record EventQuestionRequest(
+ @Schema(description = "질문 유형")
+ @NotNull(message = "질문 유형을 입력해 주세요.")
+ QuestionType questionType,
+
+ @Schema(description = "질문 내용", example = "참여 종목")
+ @NotBlank(message = "질문 내용을 입력해 주세요.")
+ @Size(max = 500, message = "질문 내용은 500자 이하로 입력해 주세요.")
+ String questionText,
+
+ @Schema(description = "필수 응답 여부")
+ @NotNull(message = "필수 응답 여부를 입력해 주세요.")
+ Boolean isRequired,
+
+ @Schema(description = "선택지 목록. 선택형이면 1개 이상 필요하고, 텍스트형이면 무시한다", example = "[\"축구\", \"농구\"]")
+ List<@NotBlank(message = "선택지는 비워 둘 수 없습니다.") String> options,
+
+ @Schema(description = "'기타'(직접 입력) 선택지 추가 여부. 선택형에서만 쓰며 기본값은 false")
+ Boolean hasOtherOption
+) {
+
+ public EventQuestionCommand toCommand() {
+ return new EventQuestionCommand(
+ questionText,
+ questionType,
+ isRequired,
+ options == null ? List.of() : options,
+ Boolean.TRUE.equals(hasOtherOption)
+ );
+ }
+}
diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/request/EventSearchParams.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/request/EventSearchParams.java
new file mode 100644
index 00000000..86cfeb9d
--- /dev/null
+++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/request/EventSearchParams.java
@@ -0,0 +1,14 @@
+package kr.ac.kookmin.stream.api.admin.event.event.request;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+
+public record EventSearchParams(
+ @Schema(description = "행사 제목 검색어", example = "체육대회")
+ String keyword
+) {
+
+ // 빈 검색어는 검색하지 않는 것으로 본다
+ public String toKeyword() {
+ return keyword == null || keyword.isBlank() ? null : keyword.strip();
+ }
+}
diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/request/EventUpdateRequest.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/request/EventUpdateRequest.java
new file mode 100644
index 00000000..8a0722a9
--- /dev/null
+++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/request/EventUpdateRequest.java
@@ -0,0 +1,94 @@
+package kr.ac.kookmin.stream.api.admin.event.event.request;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.Valid;
+import jakarta.validation.constraints.NotBlank;
+import jakarta.validation.constraints.NotNull;
+import jakarta.validation.constraints.Size;
+import java.time.Instant;
+import java.time.LocalDateTime;
+import java.util.List;
+import kr.ac.kookmin.stream.common.DateUtil;
+import kr.ac.kookmin.stream.event.domain.event.domain.EventUpdateCommand;
+import kr.ac.kookmin.stream.event.domain.event.domain.RecruitType;
+
+/**
+ * 공개 여부는 받지 않는다. 공개 전환은 별도 API가 맡는다.
+ */
+public record EventUpdateRequest(
+ @Schema(description = "행사 제목", example = "체육대회")
+ @NotBlank(message = "행사 제목을 입력해 주세요.")
+ @Size(max = 200, message = "행사 제목은 200자 이하로 입력해 주세요.")
+ String title,
+
+ // description은 TEXT 컬럼(최대 65,535바이트)이다. 글자당 최대 4바이트로 잡아도 넘치지 않는 길이로 막는다
+ @Schema(description = "행사 소개글", example = "학과 대항 체육대회입니다.")
+ @NotBlank(message = "행사 소개글을 입력해 주세요.")
+ @Size(max = 16000, message = "행사 소개글은 16000자 이하로 입력해 주세요.")
+ String description,
+
+ @Schema(description = "행사 장소", example = "종합운동장")
+ @Size(max = 255, message = "행사 장소는 255자 이하로 입력해 주세요.")
+ String place,
+
+ @Schema(description = "행사 시작 일시", example = "2026-11-01T10:00:00")
+ @NotNull(message = "행사 시작 일시를 입력해 주세요.")
+ LocalDateTime eventStartAt,
+
+ @Schema(description = "행사 종료 일시. 시작 일시보다 늦어야 한다", example = "2026-11-01T18:00:00")
+ @NotNull(message = "행사 종료 일시를 입력해 주세요.")
+ LocalDateTime eventEndAt,
+
+ @Schema(description = "신청 시작 일시", example = "2026-10-14T10:00:00")
+ @NotNull(message = "신청 시작 일시를 입력해 주세요.")
+ LocalDateTime applyStartAt,
+
+ @Schema(description = "신청 마감 일시. 시작 일시보다 늦어야 한다", example = "2026-10-25T23:59:59")
+ @NotNull(message = "신청 마감 일시를 입력해 주세요.")
+ LocalDateTime applyEndAt,
+
+ @Schema(description = "모집 방식. 선착순이면 FIRST_COME, 인원 제한이 없으면 OPEN")
+ @NotNull(message = "모집 방식을 입력해 주세요.")
+ RecruitType recruitType,
+
+ @Schema(description = "모집 정원. FIRST_COME이면 1 이상 필요하고, OPEN이면 무시한다", example = "100")
+ Integer capacity,
+
+ @Schema(description = "학생회비 납부자만 신청 가능 여부")
+ @NotNull(message = "학생회비 납부자 전용 여부를 입력해 주세요.")
+ Boolean feePaidOnly,
+
+ @Schema(description = "행사 이미지 파일 ID 목록. 첫 번째가 대표 이미지가 된다", example = "[31, 32]")
+ @NotNull(message = "이미지 파일 목록을 입력해 주세요.")
+ @Size(max = 10, message = "이미지는 10장까지 등록할 수 있습니다.")
+ List<@NotNull(message = "이미지 파일 ID는 비워 둘 수 없습니다.") Long> imageIds,
+
+ @Schema(description = "신청서 질문 목록. 목록 순서대로 신청서에 노출된다")
+ @NotNull(message = "신청서 질문 목록을 입력해 주세요.")
+ @Valid
+ List<@NotNull(message = "신청서 질문이 비어 있습니다.") EventQuestionRequest> questions,
+
+ @Schema(description = "수정 화면이 행사를 읽었을 때의 수정 시각(KST 기준 epoch 밀리초). 그 사이 다른 관리자가 고쳤다면 409",
+ example = "1756339200000")
+ @NotNull(message = "수정 시각을 입력해 주세요.")
+ Long updatedAt
+) {
+
+ public EventUpdateCommand toCommand() {
+ return new EventUpdateCommand(
+ title,
+ description,
+ place,
+ eventStartAt,
+ eventEndAt,
+ applyStartAt,
+ applyEndAt,
+ recruitType,
+ capacity,
+ feePaidOnly,
+ imageIds.stream().distinct().toList(),
+ questions.stream().map(EventQuestionRequest::toCommand).toList(),
+ LocalDateTime.ofInstant(Instant.ofEpochMilli(updatedAt), DateUtil.KST)
+ );
+ }
+}
diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/response/AdminEventDetailResponse.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/response/AdminEventDetailResponse.java
new file mode 100644
index 00000000..7b9fc9a1
--- /dev/null
+++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/response/AdminEventDetailResponse.java
@@ -0,0 +1,101 @@
+package kr.ac.kookmin.stream.api.admin.event.event.response;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Map;
+import kr.ac.kookmin.stream.api.common.StorageUrlBuilder;
+import kr.ac.kookmin.stream.common.DateUtil;
+import kr.ac.kookmin.stream.event.domain.event.domain.Event;
+import kr.ac.kookmin.stream.event.domain.event.domain.EventManageDetail;
+import kr.ac.kookmin.stream.event.domain.event.domain.EventQuestion;
+import kr.ac.kookmin.stream.event.domain.event.domain.QuestionType;
+import kr.ac.kookmin.stream.event.domain.event.domain.RecruitStatus;
+import kr.ac.kookmin.stream.event.domain.event.domain.RecruitType;
+import kr.ac.kookmin.stream.file.domain.File;
+
+/**
+ * 수정 화면의 초기값. 필드 이름은 등록·수정 요청 본문과 맞춘다.
+ *
+ * @param capacity 선착순 모집의 정원. 상시 모집이면 {@code null}
+ * @param updatedAt 마지막 수정 시각(KST 기준 epoch 밀리초). 수정 요청에 그대로 돌려보내 동시 수정을 감지한다
+ */
+public record AdminEventDetailResponse(
+ Long eventId,
+ String title,
+ String description,
+ String place,
+ LocalDateTime eventStartAt,
+ LocalDateTime eventEndAt,
+ LocalDateTime applyStartAt,
+ LocalDateTime applyEndAt,
+ RecruitType recruitType,
+ Integer capacity,
+ boolean feePaidOnly,
+ long applicantCount,
+ RecruitStatus recruitStatus,
+ boolean isPublished,
+ List images,
+ List questions,
+ long updatedAt
+) {
+
+ /**
+ * 존재하지 않는(삭제된) fileId는 imagesById에 키가 없다 — 그런 항목은 목록에서 조용히 제외한다.
+ * 모집 상태는 저장값이 아니라 {@code now} 기준으로 행사가 계산한다.
+ */
+ public static AdminEventDetailResponse of(EventManageDetail detail, Map imagesById, LocalDateTime now) {
+ Event event = detail.event();
+ return new AdminEventDetailResponse(
+ event.getId(),
+ event.getTitle(),
+ event.getDescription(),
+ event.getPlace(),
+ event.getEventStartAt(),
+ event.getEventEndAt(),
+ event.getApplyStartAt(),
+ event.getApplyEndAt(),
+ event.getRecruitType(),
+ event.capacityLimit(),
+ event.isFeePaidOnly(),
+ detail.applicantCount(),
+ event.calculateRecruitStatus(now, detail.applicantCount()),
+ event.isPublished(),
+ detail.imageIds().stream()
+ .filter(imagesById::containsKey)
+ .map(fileId -> Image.of(fileId, imagesById.get(fileId)))
+ .toList(),
+ detail.questions().stream().map(Question::from).toList(),
+ event.getUpdatedAt().atZone(DateUtil.KST).toInstant().toEpochMilli()
+ );
+ }
+
+ public record Image(Long fileId, String fileUrl) {
+
+ public static Image of(Long fileId, File file) {
+ return new Image(fileId, StorageUrlBuilder.build(file.getFileKey()));
+ }
+ }
+
+ public record Question(
+ Long questionId,
+ QuestionType questionType,
+ String questionText,
+ boolean isRequired,
+ int displayOrder,
+ List options,
+ boolean hasOtherOption
+ ) {
+
+ public static Question from(EventQuestion question) {
+ return new Question(
+ question.getId(),
+ question.getQuestionType(),
+ question.getQuestionText(),
+ question.isRequired(),
+ question.getDisplayOrder(),
+ question.getOptions() == null ? List.of() : question.getOptions(),
+ question.isOtherOptionAllowed()
+ );
+ }
+ }
+}
diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/response/AdminEventListItemResponse.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/response/AdminEventListItemResponse.java
new file mode 100644
index 00000000..b1ce3b47
--- /dev/null
+++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/response/AdminEventListItemResponse.java
@@ -0,0 +1,39 @@
+package kr.ac.kookmin.stream.api.admin.event.event.response;
+
+import java.time.LocalDateTime;
+import kr.ac.kookmin.stream.event.domain.event.domain.Event;
+import kr.ac.kookmin.stream.event.domain.event.domain.RecruitStatus;
+import kr.ac.kookmin.stream.event.domain.event.domain.RecruitType;
+
+/**
+ * @param capacity 선착순 모집의 정원. 상시 모집이면 {@code null}이고 화면에 "인원 제한 없음"으로 보인다
+ */
+public record AdminEventListItemResponse(
+ Long eventId,
+ String title,
+ long applicantCount,
+ RecruitType recruitType,
+ Integer capacity,
+ RecruitStatus recruitStatus,
+ boolean isPublished,
+ LocalDateTime applyStartAt,
+ LocalDateTime applyEndAt
+) {
+
+ /**
+ * 모집 상태는 저장값이 아니라 {@code now} 기준으로 행사가 계산한다. 목록 전체가 같은 시각을 봐야 하므로 호출부가 넘긴다.
+ */
+ public static AdminEventListItemResponse of(Event event, long applicantCount, LocalDateTime now) {
+ return new AdminEventListItemResponse(
+ event.getId(),
+ event.getTitle(),
+ applicantCount,
+ event.getRecruitType(),
+ event.capacityLimit(),
+ event.calculateRecruitStatus(now, applicantCount),
+ event.isPublished(),
+ event.getApplyStartAt(),
+ event.getApplyEndAt()
+ );
+ }
+}
diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/response/EventCreateResponse.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/response/EventCreateResponse.java
new file mode 100644
index 00000000..2feb1408
--- /dev/null
+++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/response/EventCreateResponse.java
@@ -0,0 +1,10 @@
+package kr.ac.kookmin.stream.api.admin.event.event.response;
+
+import kr.ac.kookmin.stream.event.domain.event.domain.Event;
+
+public record EventCreateResponse(Long eventId) {
+
+ public static EventCreateResponse from(Event event) {
+ return new EventCreateResponse(event.getId());
+ }
+}
diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/response/EventUpdateResponse.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/response/EventUpdateResponse.java
new file mode 100644
index 00000000..93cc991d
--- /dev/null
+++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/response/EventUpdateResponse.java
@@ -0,0 +1,10 @@
+package kr.ac.kookmin.stream.api.admin.event.event.response;
+
+import kr.ac.kookmin.stream.event.domain.event.domain.Event;
+
+public record EventUpdateResponse(Long eventId) {
+
+ public static EventUpdateResponse from(Event event) {
+ return new EventUpdateResponse(event.getId());
+ }
+}
diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/usecase/AdminEventWriteUseCase.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/usecase/AdminEventWriteUseCase.java
new file mode 100644
index 00000000..0ca98572
--- /dev/null
+++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/usecase/AdminEventWriteUseCase.java
@@ -0,0 +1,44 @@
+package kr.ac.kookmin.stream.api.admin.event.event.usecase;
+
+import java.util.List;
+import kr.ac.kookmin.stream.common.BusinessException;
+import kr.ac.kookmin.stream.event.domain.event.domain.Event;
+import kr.ac.kookmin.stream.event.domain.event.domain.EventCreateCommand;
+import kr.ac.kookmin.stream.event.domain.event.domain.EventUpdateCommand;
+import kr.ac.kookmin.stream.event.domain.event.service.EventService;
+import kr.ac.kookmin.stream.file.domain.FileErrorCode;
+import kr.ac.kookmin.stream.file.service.FileService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+
+/**
+ * 행사를 쓰기 전에 연결할 이미지 파일이 실제로 있는지 확인한다. 행사(event)와 파일(file)은 서로 다른 도메인이라
+ * 도메인끼리 직접 의존하지 않고 이 UseCase가 두 Service를 조합한다.
+ *
+ * 파일 확인과 행사 저장은 한 트랜잭션으로 묶지 않는다. 확인 직후 파일이 지워져도 행사 조회가 삭제된 파일을
+ * 목록에서 조용히 제외하므로 원자성이 필요하지 않다.
+ */
+@Component
+@RequiredArgsConstructor
+public class AdminEventWriteUseCase {
+
+ private final EventService eventService;
+ private final FileService fileService;
+
+ public Event create(Long createdBy, EventCreateCommand command) {
+ requireImagesExist(command.imageIds());
+ return eventService.create(createdBy, command);
+ }
+
+ public Event update(Long eventId, EventUpdateCommand command) {
+ requireImagesExist(command.imageIds());
+ return eventService.update(eventId, command);
+ }
+
+ // 요청 DTO가 중복을 걸러 넘기므로 조회 결과 개수와 바로 비교할 수 있다
+ private void requireImagesExist(List imageIds) {
+ if (fileService.findAllByIdIn(imageIds).size() != imageIds.size()) {
+ throw new BusinessException(FileErrorCode.FILE_NOT_FOUND);
+ }
+ }
+}
diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/Event.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/Event.java
index e548e6f9..6ab5f3e1 100644
--- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/Event.java
+++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/Event.java
@@ -24,8 +24,12 @@ public class Event {
private RecruitType recruitType;
private List imageIds;
private int capacity;
+ /** 학생회비 납부자만 신청할 수 있는지. 신청 시 검증은 아직 없다(후속 작업) */
+ private boolean feePaidOnly;
private boolean published;
private Long createdBy;
+ /** 마지막 수정 시각. DB가 채우며, 운영진 동시 수정 감지에 쓴다 */
+ private LocalDateTime updatedAt;
public static Event of(
Long id,
@@ -39,15 +43,58 @@ public static Event of(
RecruitType recruitType,
List imageIds,
int capacity,
+ boolean feePaidOnly,
boolean published,
- Long createdBy
+ Long createdBy,
+ LocalDateTime updatedAt
) {
return new Event(
id, title, description, place, eventStartAt, eventEndAt, applyStartAt,
- applyEndAt, recruitType, imageIds, capacity, published, createdBy
+ applyEndAt, recruitType, imageIds, capacity, feePaidOnly, published, createdBy, updatedAt
);
}
+ /**
+ * 새 행사를 만든다. 학생에게 바로 보이지 않도록 비공개로 시작한다. 상시 모집은 정원이 의미 없어 0으로 둔다.
+ * 수정 시각은 DB가 채운다.
+ */
+ public static Event create(Long createdBy, EventCreateCommand command) {
+ return new Event(
+ null,
+ command.title(),
+ command.description(),
+ command.place(),
+ command.eventStartAt(),
+ command.eventEndAt(),
+ command.applyStartAt(),
+ command.applyEndAt(),
+ command.recruitType(),
+ command.imageIds(),
+ capacityOf(command.recruitType(), command.capacity()),
+ command.feePaidOnly(),
+ false,
+ createdBy,
+ null
+ );
+ }
+
+ /**
+ * 운영진이 입력한 값으로 행사 내용을 통째로 바꾼다. 공개 여부·작성자는 그대로 둔다.
+ */
+ public void update(EventUpdateCommand command) {
+ this.title = command.title();
+ this.description = command.description();
+ this.place = command.place();
+ this.eventStartAt = command.eventStartAt();
+ this.eventEndAt = command.eventEndAt();
+ this.applyStartAt = command.applyStartAt();
+ this.applyEndAt = command.applyEndAt();
+ this.recruitType = command.recruitType();
+ this.imageIds = command.imageIds();
+ this.capacity = capacityOf(command.recruitType(), command.capacity());
+ this.feePaidOnly = command.feePaidOnly();
+ }
+
/**
* 현재 시각과 신청자 수를 반영한 모집 상태를 계산한다.
*
@@ -92,6 +139,13 @@ public Long thumbnailFileId() {
return imageIds == null || imageIds.isEmpty() ? null : imageIds.getFirst();
}
+ /**
+ * 운영진 화면에 보여줄 정원. 상시 모집은 인원 제한이 없어 {@code null}이다(저장값 0은 의미가 없다).
+ */
+ public Integer capacityLimit() {
+ return recruitType == RecruitType.FIRST_COME ? capacity : null;
+ }
+
/**
* 정원이 찼는지 판정한다. 선착순 모집에만 정원 제한이 있고, 상시 모집은 인원 제한이 없다.
*/
@@ -109,6 +163,10 @@ public boolean isClosedByCapacityOnly(LocalDateTime now, long appliedCount) {
&& isCapacityFull(appliedCount);
}
+ private static int capacityOf(RecruitType recruitType, Integer capacity) {
+ return recruitType == RecruitType.FIRST_COME ? capacity : 0;
+ }
+
private boolean isBeforeApplyPeriod(LocalDateTime now) {
return now.isBefore(applyStartAt);
}
diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventCreateCommand.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventCreateCommand.java
new file mode 100644
index 00000000..2d63c3c1
--- /dev/null
+++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventCreateCommand.java
@@ -0,0 +1,23 @@
+package kr.ac.kookmin.stream.event.domain.event.domain;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+/**
+ * @param capacity 선착순 모집의 정원. 상시 모집이면 쓰이지 않는다
+ */
+public record EventCreateCommand(
+ String title,
+ String description,
+ String place,
+ LocalDateTime eventStartAt,
+ LocalDateTime eventEndAt,
+ LocalDateTime applyStartAt,
+ LocalDateTime applyEndAt,
+ RecruitType recruitType,
+ Integer capacity,
+ boolean feePaidOnly,
+ List imageIds,
+ List questions
+) {
+}
diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventErrorCode.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventErrorCode.java
index 65f1b728..f978529e 100644
--- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventErrorCode.java
+++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventErrorCode.java
@@ -20,7 +20,13 @@ public enum EventErrorCode implements ErrorCode {
CANCEL_DEADLINE_PASSED(ErrorStatus.CONFLICT, "신청이 마감되어 취소할 수 없습니다."),
INVALID_ANSWER(ErrorStatus.BAD_REQUEST, "신청서 답변 형식이 올바르지 않습니다."),
EVENT_INVALID_CURSOR(ErrorStatus.BAD_REQUEST, "유효하지 않은 커서입니다."),
- EVENT_INVALID_RECRUIT_STATUS(ErrorStatus.BAD_REQUEST, "유효하지 않은 모집 상태입니다.");
+ EVENT_INVALID_RECRUIT_STATUS(ErrorStatus.BAD_REQUEST, "유효하지 않은 모집 상태입니다."),
+ EVENT_INVALID_PERIOD(ErrorStatus.BAD_REQUEST, "종료 일시는 시작 일시보다 늦어야 합니다."),
+ EVENT_INVALID_CAPACITY(ErrorStatus.BAD_REQUEST, "선착순 모집은 1명 이상의 정원을 입력해야 합니다."),
+ EVENT_INVALID_QUESTION(ErrorStatus.BAD_REQUEST, "선택형 질문에는 선택지를 1개 이상 입력해야 합니다."),
+ EVENT_QUESTIONS_LOCKED(ErrorStatus.CONFLICT, "신청 내역이 있는 행사는 신청서 질문을 수정할 수 없습니다."),
+ EVENT_CAPACITY_BELOW_APPLICANTS(ErrorStatus.CONFLICT, "정원을 현재 신청자 수보다 적게 줄일 수 없습니다."),
+ EVENT_UPDATE_CONFLICT(ErrorStatus.CONFLICT, "다른 관리자가 먼저 행사를 수정했습니다. 새로고침한 뒤 다시 수정해 주세요.");
private final int status;
private final String message;
diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventManageDetail.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventManageDetail.java
new file mode 100644
index 00000000..164c5619
--- /dev/null
+++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventManageDetail.java
@@ -0,0 +1,22 @@
+package kr.ac.kookmin.stream.event.domain.event.domain;
+
+import java.util.List;
+
+/**
+ * 운영진 행사 상세에 필요한 도메인 객체 묶음. 화면에 맞춘 값(모집 상태 등)은 담지 않고,
+ * 응답을 만드는 쪽이 행사의 계산 메서드로 꺼낸다.
+ *
+ * @param questions 노출 순서대로 정렬된 신청서 질문
+ * @param applicantCount 유효(APPLIED) 신청자 수
+ */
+public record EventManageDetail(
+ Event event,
+ List questions,
+ long applicantCount
+) {
+
+ /** 행사 이미지 id. 이미지가 없으면 {@code null}이 아니라 빈 목록이다. */
+ public List imageIds() {
+ return event.getImageIds() == null ? List.of() : event.getImageIds();
+ }
+}
diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventQuestion.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventQuestion.java
index 1efc0f04..34dcbd72 100644
--- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventQuestion.java
+++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventQuestion.java
@@ -1,6 +1,7 @@
package kr.ac.kookmin.stream.event.domain.event.domain;
import java.util.List;
+import java.util.Objects;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.EqualsAndHashCode;
@@ -18,6 +19,8 @@ public class EventQuestion {
private boolean required;
private int displayOrder;
private List options;
+ /** 선택형 질문에 '기타'(직접 입력) 선택지를 덧붙이는지. 학생 앱 노출·답변 저장은 아직 없다(후속 작업) */
+ private boolean otherOptionAllowed;
public static EventQuestion of(
Long id,
@@ -26,8 +29,42 @@ public static EventQuestion of(
QuestionType questionType,
boolean required,
int displayOrder,
- List options
+ List options,
+ boolean otherOptionAllowed
) {
- return new EventQuestion(id, eventId, questionText, questionType, required, displayOrder, options);
+ return new EventQuestion(
+ id, eventId, questionText, questionType, required, displayOrder, options, otherOptionAllowed
+ );
+ }
+
+ /**
+ * 새 질문을 만든다. 텍스트형 질문에 딸려 온 선택지·'기타' 여부는 의미가 없으므로 버린다.
+ *
+ * @param displayOrder 신청서에서 몇 번째로 보일지. 1부터 센다
+ */
+ public static EventQuestion create(Long eventId, int displayOrder, EventQuestionCommand command) {
+ boolean choice = command.questionType().isChoice();
+ return new EventQuestion(
+ null,
+ eventId,
+ command.questionText(),
+ command.questionType(),
+ command.required(),
+ displayOrder,
+ choice ? List.copyOf(command.options()) : List.of(),
+ choice && command.otherOptionAllowed()
+ );
+ }
+
+ /**
+ * 신청서에 보이는 내용이 같은지. 식별자·소속 행사·노출 순서 값은 보지 않는다(순서는 호출부가 목록 위치로 비교한다).
+ * 행사 수정 때 질문이 실제로 바뀌었는지 판단한다.
+ */
+ public boolean hasSameContentAs(EventQuestion other) {
+ return questionType == other.questionType
+ && required == other.required
+ && otherOptionAllowed == other.otherOptionAllowed
+ && Objects.equals(questionText, other.questionText)
+ && Objects.equals(options, other.options);
}
}
diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventQuestionCommand.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventQuestionCommand.java
new file mode 100644
index 00000000..e5de6e19
--- /dev/null
+++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventQuestionCommand.java
@@ -0,0 +1,15 @@
+package kr.ac.kookmin.stream.event.domain.event.domain;
+
+import java.util.List;
+
+/**
+ * 행사 등록·수정 요청에 담긴 신청서 질문 한 건. 노출 순서는 목록에서의 위치로 정한다.
+ */
+public record EventQuestionCommand(
+ String questionText,
+ QuestionType questionType,
+ boolean required,
+ List options,
+ boolean otherOptionAllowed
+) {
+}
diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventUpdateCommand.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventUpdateCommand.java
new file mode 100644
index 00000000..31ea49c9
--- /dev/null
+++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventUpdateCommand.java
@@ -0,0 +1,25 @@
+package kr.ac.kookmin.stream.event.domain.event.domain;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+/**
+ * @param capacity 선착순 모집의 정원. 상시 모집이면 쓰이지 않는다
+ * @param expectedUpdatedAt 수정 화면이 행사를 읽었을 때의 수정 시각. 그 사이 다른 운영진이 고쳤는지 확인한다
+ */
+public record EventUpdateCommand(
+ String title,
+ String description,
+ String place,
+ LocalDateTime eventStartAt,
+ LocalDateTime eventEndAt,
+ LocalDateTime applyStartAt,
+ LocalDateTime applyEndAt,
+ RecruitType recruitType,
+ Integer capacity,
+ boolean feePaidOnly,
+ List imageIds,
+ List questions,
+ LocalDateTime expectedUpdatedAt
+) {
+}
diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/QuestionType.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/QuestionType.java
index a5990530..4f936b35 100644
--- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/QuestionType.java
+++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/QuestionType.java
@@ -17,4 +17,9 @@ public enum QuestionType {
/** 답변 길이 제한. 선택형 질문은 답변이 텍스트가 아니라 null이다. */
private final Integer maxLength;
+
+ /** 선택지에서 고르는 질문인지. 선택지·'기타' 선택지는 이 유형에만 의미가 있다. */
+ public boolean isChoice() {
+ return this == SINGLE_CHOICE || this == MULTIPLE_CHOICE;
+ }
}
diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/repository/EventRepository.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/repository/EventRepository.java
index bd109be5..fb496043 100644
--- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/repository/EventRepository.java
+++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/repository/EventRepository.java
@@ -4,6 +4,8 @@
import java.util.List;
import java.util.Map;
import java.util.Optional;
+import kr.ac.kookmin.stream.common.PageOffset;
+import kr.ac.kookmin.stream.common.PageResult;
import kr.ac.kookmin.stream.event.domain.event.domain.Event;
import kr.ac.kookmin.stream.event.domain.event.domain.EventApplication;
import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationAnswer;
@@ -16,6 +18,31 @@ public interface EventRepository {
Optional findById(Long id);
+ Event save(Event event);
+
+ List saveQuestions(List questions);
+
+ /** 행사의 신청서 질문을 모두 지우고 주어진 질문으로 바꾼다. */
+ List replaceQuestions(Long eventId, List questions);
+
+ /**
+ * 행사에 신청이 한 건이라도 있는지. 취소된 신청도 센다 — 취소돼도 답변이 질문 id·선택지 순서를 참조한다.
+ */
+ boolean existsApplicationByEventId(Long eventId);
+
+ /**
+ * 행사의 수정 시각을 지금으로 갱신한다. 신청서 질문만 바뀌면 events 행의 값이 그대로라
+ * DB가 updated_at을 올리지 않으므로, 동시 수정 감지를 위해 직접 올린다.
+ */
+ void touchUpdatedAt(Long eventId);
+
+ /**
+ * 삭제되지 않은 행사를 공개 여부와 상관없이 최근 등록 순(eventId 내림차순)으로 페이지 조회한다. 운영진 목록용이다.
+ *
+ * @param keyword 제목 부분 일치 검색어. {@code null}이면 거르지 않는다
+ */
+ PageResult findPage(String keyword, PageOffset pageOffset);
+
/**
* 주어진 식별자의 행사를 조회한다. 삭제 여부로 거르지 않는다.
*
diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/EventService.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/EventService.java
index c032dee9..b6dfc05e 100644
--- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/EventService.java
+++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/EventService.java
@@ -1,15 +1,50 @@
package kr.ac.kookmin.stream.event.domain.event.service;
+import java.util.List;
+import java.util.Map;
import kr.ac.kookmin.stream.common.CursorSliceResult;
+import kr.ac.kookmin.stream.common.PageOffset;
+import kr.ac.kookmin.stream.common.PageResult;
+import kr.ac.kookmin.stream.event.domain.event.domain.Event;
+import kr.ac.kookmin.stream.event.domain.event.domain.EventCreateCommand;
import kr.ac.kookmin.stream.event.domain.event.domain.EventCursor;
import kr.ac.kookmin.stream.event.domain.event.domain.EventDetail;
+import kr.ac.kookmin.stream.event.domain.event.domain.EventManageDetail;
import kr.ac.kookmin.stream.event.domain.event.domain.EventSummary;
+import kr.ac.kookmin.stream.event.domain.event.domain.EventUpdateCommand;
import kr.ac.kookmin.stream.event.domain.event.domain.RecruitStatus;
-/** 행사 자체의 조회. 신청·취소는 {@link EventApplicationService}가 맡는다. */
+/** 행사 자체의 조회와 운영진의 등록·수정. 신청·취소는 {@link EventApplicationService}가 맡는다. */
public interface EventService {
CursorSliceResult getPublishedEvents(RecruitStatus recruitStatus, EventCursor cursor, int size);
EventDetail getPublishedEvent(Long eventId);
+
+ /**
+ * 운영진이 행사를 등록한다. 신청서 질문도 함께 저장하며, 등록한 행사는 비공개로 시작한다.
+ */
+ Event create(Long createdBy, EventCreateCommand command);
+
+ /**
+ * 운영진 행사 목록. 비공개 행사도 담는다. 신청자 수는 {@link #getApplicantCounts}로 따로 조회해 조합한다.
+ *
+ * @param keyword 제목 부분 일치 검색어. {@code null}이면 거르지 않는다
+ */
+ PageResult getEvents(String keyword, PageOffset pageOffset);
+
+ /**
+ * 행사별 유효(APPLIED) 신청자 수. 신청이 한 건도 없는 행사는 결과에 담기지 않는다.
+ */
+ Map getApplicantCounts(List eventIds);
+
+ /** 운영진 행사 상세. 비공개 행사도 조회한다. */
+ EventManageDetail getEvent(Long eventId);
+
+ /**
+ * 운영진이 행사 내용을 통째로 바꾼다. 수정 화면이 읽은 뒤 다른 운영진이 먼저 고쳤다면
+ * {@code EVENT_UPDATE_CONFLICT}로 거부한다. 신청이 있는 행사는 신청서 질문을 바꿀 수 없고,
+ * 선착순 정원을 현재 신청자 수보다 줄일 수 없다.
+ */
+ Event update(Long eventId, EventUpdateCommand command);
}
diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImpl.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImpl.java
index cc42849a..6842679e 100644
--- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImpl.java
+++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImpl.java
@@ -1,16 +1,26 @@
package kr.ac.kookmin.stream.event.domain.event.service.impl;
import java.time.LocalDateTime;
+import java.time.temporal.ChronoUnit;
import java.util.List;
import java.util.Map;
+import java.util.stream.IntStream;
import kr.ac.kookmin.stream.common.BusinessException;
import kr.ac.kookmin.stream.common.CursorSliceResult;
+import kr.ac.kookmin.stream.common.PageOffset;
+import kr.ac.kookmin.stream.common.PageResult;
import kr.ac.kookmin.stream.event.domain.event.domain.Event;
+import kr.ac.kookmin.stream.event.domain.event.domain.EventCreateCommand;
import kr.ac.kookmin.stream.event.domain.event.domain.EventCursor;
import kr.ac.kookmin.stream.event.domain.event.domain.EventDetail;
import kr.ac.kookmin.stream.event.domain.event.domain.EventErrorCode;
+import kr.ac.kookmin.stream.event.domain.event.domain.EventManageDetail;
+import kr.ac.kookmin.stream.event.domain.event.domain.EventQuestion;
+import kr.ac.kookmin.stream.event.domain.event.domain.EventQuestionCommand;
import kr.ac.kookmin.stream.event.domain.event.domain.EventSummary;
+import kr.ac.kookmin.stream.event.domain.event.domain.EventUpdateCommand;
import kr.ac.kookmin.stream.event.domain.event.domain.RecruitStatus;
+import kr.ac.kookmin.stream.event.domain.event.domain.RecruitType;
import kr.ac.kookmin.stream.event.domain.event.repository.EventRepository;
import kr.ac.kookmin.stream.event.domain.event.service.EventService;
import lombok.RequiredArgsConstructor;
@@ -22,6 +32,7 @@
class EventServiceImpl implements EventService {
private final EventRepository eventRepository;
+ private final EventWriteValidator eventWriteValidator;
@Override
@Transactional(readOnly = true)
@@ -56,4 +67,95 @@ public EventDetail getPublishedEvent(Long eventId) {
LocalDateTime now = LocalDateTime.now();
return EventDetail.of(event, eventRepository.countAppliedByEventId(eventId), now);
}
+
+ @Override
+ @Transactional
+ public Event create(Long createdBy, EventCreateCommand command) {
+ eventWriteValidator.validate(command);
+
+ Event event = eventRepository.save(Event.create(createdBy, command));
+ eventRepository.saveQuestions(toQuestions(event.getId(), command.questions()));
+ return event;
+ }
+
+ @Override
+ @Transactional(readOnly = true)
+ public PageResult getEvents(String keyword, PageOffset pageOffset) {
+ return eventRepository.findPage(keyword, pageOffset);
+ }
+
+ @Override
+ @Transactional(readOnly = true)
+ public Map getApplicantCounts(List eventIds) {
+ return eventRepository.countAppliedByEventIds(eventIds);
+ }
+
+ @Override
+ @Transactional(readOnly = true)
+ public EventManageDetail getEvent(Long eventId) {
+ Event event = getEventOrThrow(eventId);
+ return new EventManageDetail(
+ event,
+ eventRepository.findQuestionsByEventId(eventId),
+ eventRepository.countAppliedByEventId(eventId)
+ );
+ }
+
+ @Override
+ @Transactional
+ public Event update(Long eventId, EventUpdateCommand command) {
+ Event event = getEventOrThrow(eventId);
+ validateNotModified(event, command.expectedUpdatedAt());
+ eventWriteValidator.validate(command);
+ validateCapacityNotBelowApplicants(eventId, command);
+
+ List questions = toQuestions(eventId, command.questions());
+ if (questionsChanged(eventRepository.findQuestionsByEventId(eventId), questions)) {
+ // 답변이 질문 id·선택지 순서로 저장돼 있어 신청이 생긴 뒤 바꾸면 기존 답변의 뜻이 달라진다
+ if (eventRepository.existsApplicationByEventId(eventId)) {
+ throw new BusinessException(EventErrorCode.EVENT_QUESTIONS_LOCKED);
+ }
+ eventRepository.replaceQuestions(eventId, questions);
+ eventRepository.touchUpdatedAt(eventId);
+ }
+
+ event.update(command);
+ return eventRepository.save(event);
+ }
+
+ private Event getEventOrThrow(Long eventId) {
+ return eventRepository.findById(eventId)
+ .orElseThrow(() -> new BusinessException(EventErrorCode.EVENT_NOT_FOUND));
+ }
+
+ // updated_at이 초 단위 DATETIME이라 같은 초로 맞춰 비교한다. 수정 화면이 읽은 뒤 고쳐졌다면 시각이 달라진다
+ private void validateNotModified(Event event, LocalDateTime expectedUpdatedAt) {
+ LocalDateTime current = event.getUpdatedAt().truncatedTo(ChronoUnit.SECONDS);
+ if (!current.equals(expectedUpdatedAt.truncatedTo(ChronoUnit.SECONDS))) {
+ throw new BusinessException(EventErrorCode.EVENT_UPDATE_CONFLICT);
+ }
+ }
+
+ // 이미 받은 신청을 정원 밖으로 밀어낼 수는 없다. 상시 모집은 인원 제한이 없어 보지 않는다
+ private void validateCapacityNotBelowApplicants(Long eventId, EventUpdateCommand command) {
+ if (command.recruitType() == RecruitType.FIRST_COME
+ && command.capacity() < eventRepository.countAppliedByEventId(eventId)) {
+ throw new BusinessException(EventErrorCode.EVENT_CAPACITY_BELOW_APPLICANTS);
+ }
+ }
+
+ private static boolean questionsChanged(List current, List requested) {
+ if (current.size() != requested.size()) {
+ return true;
+ }
+ return IntStream.range(0, current.size())
+ .anyMatch(index -> !current.get(index).hasSameContentAs(requested.get(index)));
+ }
+
+ // 신청서에 보이는 순서는 요청 목록의 순서를 따른다
+ private static List toQuestions(Long eventId, List commands) {
+ return IntStream.range(0, commands.size())
+ .mapToObj(index -> EventQuestion.create(eventId, index + 1, commands.get(index)))
+ .toList();
+ }
}
diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventWriteValidator.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventWriteValidator.java
new file mode 100644
index 00000000..938c5547
--- /dev/null
+++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventWriteValidator.java
@@ -0,0 +1,55 @@
+package kr.ac.kookmin.stream.event.domain.event.service.impl;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import kr.ac.kookmin.stream.common.BusinessException;
+import kr.ac.kookmin.stream.event.domain.event.domain.EventCreateCommand;
+import kr.ac.kookmin.stream.event.domain.event.domain.EventErrorCode;
+import kr.ac.kookmin.stream.event.domain.event.domain.EventQuestionCommand;
+import kr.ac.kookmin.stream.event.domain.event.domain.EventUpdateCommand;
+import kr.ac.kookmin.stream.event.domain.event.domain.RecruitType;
+import org.springframework.stereotype.Component;
+
+/**
+ * 운영진이 행사를 등록·수정할 때 요청 형식(Bean Validation)만으로는 걸러지지 않는 규칙을 확인한다.
+ * 필드 하나로 판단할 수 없는 조합(시작·종료 순서, 모집 방식에 따른 정원, 질문 유형에 따른 선택지)을 본다.
+ */
+@Component
+class EventWriteValidator {
+
+ void validate(EventCreateCommand command) {
+ validatePeriod(command.eventStartAt(), command.eventEndAt());
+ validatePeriod(command.applyStartAt(), command.applyEndAt());
+ validateCapacity(command.recruitType(), command.capacity());
+ validateQuestions(command.questions());
+ }
+
+ void validate(EventUpdateCommand command) {
+ validatePeriod(command.eventStartAt(), command.eventEndAt());
+ validatePeriod(command.applyStartAt(), command.applyEndAt());
+ validateCapacity(command.recruitType(), command.capacity());
+ validateQuestions(command.questions());
+ }
+
+ private void validatePeriod(LocalDateTime startAt, LocalDateTime endAt) {
+ if (!endAt.isAfter(startAt)) {
+ throw new BusinessException(EventErrorCode.EVENT_INVALID_PERIOD);
+ }
+ }
+
+ // 상시 모집은 인원 제한이 없어 정원을 보지 않는다
+ private void validateCapacity(RecruitType recruitType, Integer capacity) {
+ if (recruitType == RecruitType.FIRST_COME && (capacity == null || capacity < 1)) {
+ throw new BusinessException(EventErrorCode.EVENT_INVALID_CAPACITY);
+ }
+ }
+
+ private void validateQuestions(List questions) {
+ boolean choiceWithoutOptions = questions.stream()
+ .anyMatch(question -> question.questionType().isChoice()
+ && (question.options() == null || question.options().isEmpty()));
+ if (choiceWithoutOptions) {
+ throw new BusinessException(EventErrorCode.EVENT_INVALID_QUESTION);
+ }
+ }
+}
diff --git a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventApplicationCursorTest.java b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventApplicationCursorTest.java
index e013b98f..3967763c 100644
--- a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventApplicationCursorTest.java
+++ b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventApplicationCursorTest.java
@@ -29,7 +29,7 @@ void fromSummary() {
Event event = Event.of(
15L, "행사", "설명", "학생회관",
APPLIED_AT.plusDays(30), APPLIED_AT.plusDays(31), APPLIED_AT.minusDays(7), APPLIED_AT.plusDays(3),
- RecruitType.OPEN, List.of(), 0, true, 100L
+ RecruitType.OPEN, List.of(), 0, false, true, 100L, null
);
EventApplication application = EventApplication.of(
102L, 15L, 1L, EventApplicationStatus.APPLIED, APPLIED_AT, null);
diff --git a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventApplicationDetailTest.java b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventApplicationDetailTest.java
index cf667f61..c2c356f8 100644
--- a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventApplicationDetailTest.java
+++ b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventApplicationDetailTest.java
@@ -15,15 +15,15 @@ class EventApplicationDetailTest {
private static final EventQuestion CHOICE_QUESTION = EventQuestion.of(
1L, 15L, "관심 분야를 선택해 주세요.", QuestionType.MULTIPLE_CHOICE, true, 0,
- List.of("텍스트1", "텍스트2", "텍스트3"));
+ List.of("텍스트1", "텍스트2", "텍스트3"), false);
private static final EventQuestion TEXT_QUESTION = EventQuestion.of(
- 2L, 15L, "궁금한 점을 남겨 주세요.", QuestionType.LONG_TEXT, false, 1, List.of());
+ 2L, 15L, "궁금한 점을 남겨 주세요.", QuestionType.LONG_TEXT, false, 1, List.of(), false);
private static Event event() {
return Event.of(
15L, "동문패널톡", "설명", "미래관 419호",
APPLIED_AT.plusDays(30), APPLIED_AT.plusDays(31), APPLIED_AT.minusDays(7), APPLIED_AT.plusDays(3),
- RecruitType.OPEN, List.of(7L), 0, true, 100L
+ RecruitType.OPEN, List.of(7L), 0, false, true, 100L, null
);
}
diff --git a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventCursorTest.java b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventCursorTest.java
index 3ca8e342..8aed3345 100644
--- a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventCursorTest.java
+++ b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventCursorTest.java
@@ -29,7 +29,7 @@ void fromEvent() {
Event event = Event.of(
42L, "행사", "설명", "학생회관",
EVENT_START_AT, EVENT_START_AT.plusHours(2), EVENT_START_AT.minusDays(7), EVENT_START_AT.minusDays(1),
- RecruitType.OPEN, List.of(), 0, true, 100L
+ RecruitType.OPEN, List.of(), 0, false, true, 100L, null
);
assertEquals(new EventCursor(EVENT_START_AT, 42L), EventCursor.of(event));
diff --git a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventDetailTest.java b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventDetailTest.java
index 4afdc859..ae397121 100644
--- a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventDetailTest.java
+++ b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventDetailTest.java
@@ -18,7 +18,7 @@ private static Event event(RecruitType recruitType, List imageIds) {
return Event.of(
15L, "2026 소융대 개강 행사", "소융대 재학생을 위한 개강 행사입니다.", "101호",
NOW.plusDays(10), NOW.plusDays(10).plusHours(2), NOW.minusDays(1), NOW.plusDays(3),
- recruitType, imageIds, 30, true, 1L
+ recruitType, imageIds, 30, false, true, 1L, null
);
}
diff --git a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventQuestionTest.java b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventQuestionTest.java
new file mode 100644
index 00000000..2b0a1cd2
--- /dev/null
+++ b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventQuestionTest.java
@@ -0,0 +1,122 @@
+package kr.ac.kookmin.stream.event.domain.event.domain;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.util.ArrayList;
+import java.util.List;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+class EventQuestionTest {
+
+ private static EventQuestionCommand command(QuestionType type, List options, boolean otherOptionAllowed) {
+ return new EventQuestionCommand("참여 종목", type, true, options, otherOptionAllowed);
+ }
+
+ private static EventQuestion saved(Long id, QuestionType type, List options, boolean otherOptionAllowed) {
+ return EventQuestion.of(id, 15L, "참여 종목", type, true, 1, options, otherOptionAllowed);
+ }
+
+ @Nested
+ @DisplayName("새 질문 만들기")
+ class Create {
+
+ @Test
+ @DisplayName("소속 행사와 노출 순서를 붙여 입력값을 담는다")
+ void copiesCommand() {
+ EventQuestion question = EventQuestion.create(
+ 15L, 2, command(QuestionType.MULTIPLE_CHOICE, List.of("축구", "농구"), true));
+
+ assertNull(question.getId());
+ assertEquals(15L, question.getEventId());
+ assertEquals(2, question.getDisplayOrder());
+ assertEquals("참여 종목", question.getQuestionText());
+ assertEquals(QuestionType.MULTIPLE_CHOICE, question.getQuestionType());
+ assertTrue(question.isRequired());
+ assertEquals(List.of("축구", "농구"), question.getOptions());
+ assertTrue(question.isOtherOptionAllowed());
+ }
+
+ @Test
+ @DisplayName("텍스트형 질문에 딸려 온 선택지와 '기타' 여부는 버린다")
+ void textQuestionDropsChoiceFields() {
+ for (QuestionType type : List.of(QuestionType.SHORT_TEXT, QuestionType.LONG_TEXT)) {
+ EventQuestion question = EventQuestion.create(15L, 1, command(type, List.of("축구"), true));
+
+ assertEquals(List.of(), question.getOptions());
+ assertFalse(question.isOtherOptionAllowed());
+ }
+ }
+
+ @Test
+ @DisplayName("요청 목록을 나중에 바꿔도 질문의 선택지는 바뀌지 않는다")
+ void copiesOptions() {
+ List options = new ArrayList<>(List.of("축구"));
+ EventQuestion question = EventQuestion.create(15L, 1, command(QuestionType.SINGLE_CHOICE, options, false));
+
+ options.add("농구");
+
+ assertEquals(List.of("축구"), question.getOptions());
+ }
+ }
+
+ @Nested
+ @DisplayName("내용 비교")
+ class SameContent {
+
+ @Test
+ @DisplayName("식별자·소속 행사·노출 순서 값이 달라도 신청서에 보이는 내용이 같으면 같다")
+ void ignoresIdentityAndOrderValue() {
+ EventQuestion saved = saved(101L, QuestionType.SINGLE_CHOICE, List.of("참석", "불참"), false);
+ EventQuestion requested = EventQuestion.create(
+ 15L, 5, command(QuestionType.SINGLE_CHOICE, List.of("참석", "불참"), false));
+
+ assertTrue(saved.hasSameContentAs(requested));
+ }
+
+ @Test
+ @DisplayName("유형이 다르면 다르다")
+ void differentType() {
+ assertFalse(saved(1L, QuestionType.SINGLE_CHOICE, List.of("참석"), false)
+ .hasSameContentAs(saved(1L, QuestionType.MULTIPLE_CHOICE, List.of("참석"), false)));
+ }
+
+ @Test
+ @DisplayName("선택지 순서가 바뀌면 다르다 — 답변이 선택지 순서로 저장되기 때문이다")
+ void differentOptionOrder() {
+ assertFalse(saved(1L, QuestionType.SINGLE_CHOICE, List.of("참석", "불참"), false)
+ .hasSameContentAs(saved(1L, QuestionType.SINGLE_CHOICE, List.of("불참", "참석"), false)));
+ }
+
+ @Test
+ @DisplayName("'기타' 여부가 다르면 다르다")
+ void differentOtherOption() {
+ assertFalse(saved(1L, QuestionType.SINGLE_CHOICE, List.of("참석"), false)
+ .hasSameContentAs(saved(1L, QuestionType.SINGLE_CHOICE, List.of("참석"), true)));
+ }
+
+ @Test
+ @DisplayName("질문 내용이나 필수 여부가 다르면 다르다")
+ void differentTextOrRequired() {
+ EventQuestion base = saved(1L, QuestionType.SHORT_TEXT, List.of(), false);
+
+ assertFalse(base.hasSameContentAs(
+ EventQuestion.of(1L, 15L, "전화번호", QuestionType.SHORT_TEXT, true, 1, List.of(), false)));
+ assertFalse(base.hasSameContentAs(
+ EventQuestion.of(1L, 15L, "참여 종목", QuestionType.SHORT_TEXT, false, 1, List.of(), false)));
+ }
+ }
+
+ @Test
+ @DisplayName("선택형 유형은 단일·복수 선택뿐이다")
+ void choiceTypes() {
+ assertTrue(QuestionType.SINGLE_CHOICE.isChoice());
+ assertTrue(QuestionType.MULTIPLE_CHOICE.isChoice());
+ assertFalse(QuestionType.SHORT_TEXT.isChoice());
+ assertFalse(QuestionType.LONG_TEXT.isChoice());
+ }
+}
diff --git a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventSummaryTest.java b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventSummaryTest.java
index ca209808..0d66b174 100644
--- a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventSummaryTest.java
+++ b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventSummaryTest.java
@@ -23,7 +23,7 @@ private static Event event(
return Event.of(
1L, "행사", "설명", "학생회관",
NOW.plusDays(30), NOW.plusDays(31), applyStartAt, applyEndAt,
- recruitType, imageIds, capacity, true, 100L
+ recruitType, imageIds, capacity, false, true, 100L, null
);
}
diff --git a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventTest.java b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventTest.java
index cf7178b6..8c327aed 100644
--- a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventTest.java
+++ b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventTest.java
@@ -22,7 +22,7 @@ private static Event event(RecruitType recruitType) {
1L, "2026 소융대 개강 행사", "설명", "101호",
LocalDateTime.of(2026, 9, 15, 18, 0), LocalDateTime.of(2026, 9, 15, 20, 0),
APPLY_START_AT, APPLY_END_AT,
- recruitType, List.of(), CAPACITY, true, 1L
+ recruitType, List.of(), CAPACITY, false, true, 1L, null
);
}
@@ -172,4 +172,121 @@ void notFull() {
assertFalse(firstComeEvent().isClosedByCapacityOnly(APPLY_START_AT.plusDays(1), CAPACITY - 1));
}
}
+
+ @Nested
+ @DisplayName("운영진 등록")
+ class Create {
+
+ private EventCreateCommand command(RecruitType recruitType, Integer capacity) {
+ return new EventCreateCommand(
+ "체육대회", "학과 대항 체육대회", "종합운동장",
+ LocalDateTime.of(2026, 11, 1, 10, 0), LocalDateTime.of(2026, 11, 1, 18, 0),
+ APPLY_START_AT, APPLY_END_AT, recruitType, capacity, true, List.of(31L, 32L), List.of()
+ );
+ }
+
+ @Test
+ @DisplayName("입력값을 그대로 담고 작성자를 기록한다")
+ void copiesCommand() {
+ Event event = Event.create(7L, command(RecruitType.FIRST_COME, 100));
+
+ assertNull(event.getId());
+ assertEquals("체육대회", event.getTitle());
+ assertEquals("학과 대항 체육대회", event.getDescription());
+ assertEquals("종합운동장", event.getPlace());
+ assertEquals(APPLY_START_AT, event.getApplyStartAt());
+ assertEquals(APPLY_END_AT, event.getApplyEndAt());
+ assertEquals(RecruitType.FIRST_COME, event.getRecruitType());
+ assertEquals(100, event.getCapacity());
+ assertTrue(event.isFeePaidOnly());
+ assertEquals(List.of(31L, 32L), event.getImageIds());
+ assertEquals(7L, event.getCreatedBy());
+ }
+
+ @Test
+ @DisplayName("학생에게 바로 보이지 않도록 비공개로 시작한다")
+ void startsUnpublished() {
+ assertFalse(Event.create(7L, command(RecruitType.FIRST_COME, 100)).isPublished());
+ }
+
+ @Test
+ @DisplayName("상시 모집은 입력한 정원과 상관없이 0으로 저장한다")
+ void openRecruitStoresZeroCapacity() {
+ assertEquals(0, Event.create(7L, command(RecruitType.OPEN, 50)).getCapacity());
+ assertEquals(0, Event.create(7L, command(RecruitType.OPEN, null)).getCapacity());
+ }
+ }
+
+ @Nested
+ @DisplayName("운영진 수정")
+ class Update {
+
+ private EventUpdateCommand command(RecruitType recruitType, Integer capacity) {
+ return new EventUpdateCommand(
+ "체육대회(변경)", "장소가 바뀌었습니다", "미래관 대강당",
+ LocalDateTime.of(2026, 11, 2, 10, 0), LocalDateTime.of(2026, 11, 2, 18, 0),
+ APPLY_START_AT.plusDays(1), APPLY_END_AT.plusDays(1), recruitType, capacity, true,
+ List.of(40L), List.of(), LocalDateTime.now()
+ );
+ }
+
+ @Test
+ @DisplayName("내용을 통째로 바꾼다")
+ void replacesContent() {
+ Event event = firstComeEvent();
+
+ event.update(command(RecruitType.FIRST_COME, 50));
+
+ assertEquals("체육대회(변경)", event.getTitle());
+ assertEquals("장소가 바뀌었습니다", event.getDescription());
+ assertEquals("미래관 대강당", event.getPlace());
+ assertEquals(LocalDateTime.of(2026, 11, 2, 10, 0), event.getEventStartAt());
+ assertEquals(LocalDateTime.of(2026, 11, 2, 18, 0), event.getEventEndAt());
+ assertEquals(APPLY_START_AT.plusDays(1), event.getApplyStartAt());
+ assertEquals(APPLY_END_AT.plusDays(1), event.getApplyEndAt());
+ assertEquals(50, event.getCapacity());
+ assertTrue(event.isFeePaidOnly());
+ assertEquals(List.of(40L), event.getImageIds());
+ }
+
+ @Test
+ @DisplayName("공개 여부와 작성자는 그대로 둔다")
+ void keepsPublishedAndCreator() {
+ Event event = firstComeEvent();
+
+ event.update(command(RecruitType.FIRST_COME, 50));
+
+ assertTrue(event.isPublished());
+ assertEquals(1L, event.getCreatedBy());
+ assertEquals(1L, event.getId());
+ }
+
+ @Test
+ @DisplayName("상시 모집으로 바꾸면 정원을 0으로 둔다")
+ void switchToOpenRecruitClearsCapacity() {
+ Event event = firstComeEvent();
+
+ event.update(command(RecruitType.OPEN, 50));
+
+ assertEquals(RecruitType.OPEN, event.getRecruitType());
+ assertEquals(0, event.getCapacity());
+ }
+ }
+
+ @Nested
+ @DisplayName("운영진 화면의 정원")
+ class CapacityLimit {
+
+ @Test
+ @DisplayName("선착순이면 정원을 돌려준다")
+ void firstComeReturnsCapacity() {
+ assertEquals(CAPACITY, firstComeEvent().capacityLimit());
+ }
+
+ @Test
+ @DisplayName("상시 모집은 인원 제한이 없어 null이다")
+ void openRecruitReturnsNull() {
+ assertNull(event(RecruitType.OPEN).capacityLimit());
+ }
+ }
}
diff --git a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventApplyAnswerValidatorTest.java b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventApplyAnswerValidatorTest.java
index 115de48a..2e1c6602 100644
--- a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventApplyAnswerValidatorTest.java
+++ b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventApplyAnswerValidatorTest.java
@@ -24,7 +24,7 @@ class EventApplyAnswerValidatorTest {
private final EventApplyAnswerValidator validator = new EventApplyAnswerValidator();
private static EventQuestion question(Long id, QuestionType type, boolean required, List options) {
- return EventQuestion.of(id, 1L, "질문", type, required, 0, options);
+ return EventQuestion.of(id, 1L, "질문", type, required, 0, options, false);
}
/** 단답(필수) / 장문(선택) / 객관식 3지(필수) / 체크박스 2지(선택) */
diff --git a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImplManageTest.java b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImplManageTest.java
new file mode 100644
index 00000000..b4285436
--- /dev/null
+++ b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImplManageTest.java
@@ -0,0 +1,515 @@
+package kr.ac.kookmin.stream.event.domain.event.service.impl;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertSame;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import kr.ac.kookmin.stream.common.BusinessException;
+import kr.ac.kookmin.stream.common.PageOffset;
+import kr.ac.kookmin.stream.common.PageResult;
+import kr.ac.kookmin.stream.event.domain.event.domain.Event;
+import kr.ac.kookmin.stream.event.domain.event.domain.EventApplication;
+import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationAnswer;
+import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationCursor;
+import kr.ac.kookmin.stream.event.domain.event.domain.EventCreateCommand;
+import kr.ac.kookmin.stream.event.domain.event.domain.EventCursor;
+import kr.ac.kookmin.stream.event.domain.event.domain.EventErrorCode;
+import kr.ac.kookmin.stream.event.domain.event.domain.EventManageDetail;
+import kr.ac.kookmin.stream.event.domain.event.domain.EventQuestion;
+import kr.ac.kookmin.stream.event.domain.event.domain.EventQuestionCommand;
+import kr.ac.kookmin.stream.event.domain.event.domain.EventUpdateCommand;
+import kr.ac.kookmin.stream.event.domain.event.domain.QuestionType;
+import kr.ac.kookmin.stream.event.domain.event.domain.RecruitStatus;
+import kr.ac.kookmin.stream.event.domain.event.domain.RecruitType;
+import kr.ac.kookmin.stream.event.domain.event.repository.EventRepository;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+/**
+ * 운영진 행사 등록·조회·수정. 형식 규칙 자체는 {@code EventWriteValidatorTest}가 덮고,
+ * 여기서는 저장 흐름과 수정 시의 규칙(동시 수정·정원 하한·질문 잠금)을 본다.
+ */
+class EventServiceImplManageTest {
+
+ private static final Long EVENT_ID = 15L;
+ private static final LocalDateTime UPDATED_AT = LocalDateTime.of(2026, 10, 10, 12, 30, 15);
+ private static final LocalDateTime APPLY_START_AT = LocalDateTime.of(2026, 10, 14, 10, 0);
+ private static final LocalDateTime APPLY_END_AT = LocalDateTime.of(2026, 10, 25, 23, 59, 59);
+ private static final LocalDateTime EVENT_START_AT = LocalDateTime.of(2026, 11, 1, 10, 0);
+ private static final LocalDateTime EVENT_END_AT = LocalDateTime.of(2026, 11, 1, 18, 0);
+
+ private static final EventQuestionCommand SPORTS = new EventQuestionCommand(
+ "참여 종목", QuestionType.MULTIPLE_CHOICE, true, List.of("축구", "농구"), true);
+ private static final EventQuestionCommand PHONE = new EventQuestionCommand(
+ "전화번호", QuestionType.SHORT_TEXT, true, List.of(), false);
+
+ private final InMemoryEventRepository repository = new InMemoryEventRepository();
+ private final EventServiceImpl service = new EventServiceImpl(repository, new EventWriteValidator());
+
+ private static Event savedEvent(RecruitType recruitType, int capacity) {
+ return Event.of(
+ EVENT_ID, "체육대회", "설명", "종합운동장", EVENT_START_AT, EVENT_END_AT, APPLY_START_AT, APPLY_END_AT,
+ recruitType, List.of(31L), capacity, false, true, 7L, UPDATED_AT
+ );
+ }
+
+ private static EventCreateCommand createCommand(
+ RecruitType recruitType, Integer capacity, List questions) {
+ return new EventCreateCommand(
+ "체육대회", "설명", "종합운동장", EVENT_START_AT, EVENT_END_AT, APPLY_START_AT, APPLY_END_AT,
+ recruitType, capacity, false, List.of(31L), questions
+ );
+ }
+
+ private static EventUpdateCommand updateCommand(
+ RecruitType recruitType, Integer capacity, List questions, LocalDateTime expectedUpdatedAt) {
+ return new EventUpdateCommand(
+ "체육대회(변경)", "설명", "미래관", EVENT_START_AT, EVENT_END_AT, APPLY_START_AT, APPLY_END_AT,
+ recruitType, capacity, true, List.of(40L), questions, expectedUpdatedAt
+ );
+ }
+
+ private static EventUpdateCommand updateCommand(List questions) {
+ return updateCommand(RecruitType.FIRST_COME, 100, questions, UPDATED_AT);
+ }
+
+ /** 저장된 질문처럼 id가 붙은 질문 목록. 노출 순서는 1부터 센다. */
+ private static List savedQuestions(EventQuestionCommand... commands) {
+ List questions = new ArrayList<>();
+ for (int i = 0; i < commands.length; i++) {
+ EventQuestionCommand command = commands[i];
+ questions.add(EventQuestion.of(
+ 100L + i, EVENT_ID, command.questionText(), command.questionType(), command.required(), i + 1,
+ command.options(), command.otherOptionAllowed()));
+ }
+ return questions;
+ }
+
+ private static void assertRejected(EventErrorCode expected, Runnable action) {
+ BusinessException e = assertThrows(BusinessException.class, action::run);
+ assertEquals(expected, e.getErrorCode());
+ }
+
+ @Nested
+ @DisplayName("등록")
+ class Create {
+
+ @Test
+ @DisplayName("행사를 비공개로 저장하고 작성자를 기록한다")
+ void savesUnpublishedEvent() {
+ Event created = service.create(7L, createCommand(RecruitType.FIRST_COME, 100, List.of()));
+
+ Event saved = repository.events.get(created.getId());
+ assertFalse(saved.isPublished());
+ assertEquals(7L, saved.getCreatedBy());
+ assertEquals(100, saved.getCapacity());
+ }
+
+ @Test
+ @DisplayName("질문을 요청 순서대로 1부터 노출 순서를 붙여 저장한다")
+ void savesQuestionsInRequestOrder() {
+ Event created = service.create(7L, createCommand(RecruitType.OPEN, null, List.of(PHONE, SPORTS)));
+
+ List questions = repository.questions.get(created.getId());
+ assertEquals(List.of("전화번호", "참여 종목"),
+ questions.stream().map(EventQuestion::getQuestionText).toList());
+ assertEquals(List.of(1, 2), questions.stream().map(EventQuestion::getDisplayOrder).toList());
+ assertTrue(questions.stream().allMatch(question -> question.getEventId().equals(created.getId())));
+ }
+
+ @Test
+ @DisplayName("질문이 없으면 빈 신청서로 저장한다")
+ void savesEmptyForm() {
+ Event created = service.create(7L, createCommand(RecruitType.OPEN, null, List.of()));
+
+ assertTrue(repository.findQuestionsByEventId(created.getId()).isEmpty());
+ }
+
+ @Test
+ @DisplayName("규칙을 어기면 아무것도 저장하지 않는다")
+ void savesNothingWhenInvalid() {
+ assertRejected(EventErrorCode.EVENT_INVALID_CAPACITY,
+ () -> service.create(7L, createCommand(RecruitType.FIRST_COME, 0, List.of(SPORTS))));
+
+ assertTrue(repository.events.isEmpty());
+ assertTrue(repository.questions.isEmpty());
+ }
+ }
+
+ @Nested
+ @DisplayName("상세 조회")
+ class GetEvent {
+
+ @Test
+ @DisplayName("행사·질문·유효 신청자 수를 함께 돌려준다")
+ void returnsBundle() {
+ repository.events.put(EVENT_ID, savedEvent(RecruitType.FIRST_COME, 100));
+ repository.questions.put(EVENT_ID, savedQuestions(PHONE, SPORTS));
+ repository.appliedCounts.put(EVENT_ID, 3L);
+
+ EventManageDetail detail = service.getEvent(EVENT_ID);
+
+ assertEquals(EVENT_ID, detail.event().getId());
+ assertEquals(2, detail.questions().size());
+ assertEquals(3L, detail.applicantCount());
+ }
+
+ @Test
+ @DisplayName("없거나 삭제된 행사는 찾을 수 없다")
+ void notFound() {
+ assertRejected(EventErrorCode.EVENT_NOT_FOUND, () -> service.getEvent(EVENT_ID));
+ }
+ }
+
+ @Nested
+ @DisplayName("수정")
+ class Update {
+
+ private void givenEvent(RecruitType recruitType, int capacity, List questions) {
+ repository.events.put(EVENT_ID, savedEvent(recruitType, capacity));
+ repository.questions.put(EVENT_ID, questions);
+ }
+
+ @Test
+ @DisplayName("내용을 바꿔 저장하고 공개 여부·작성자는 그대로 둔다")
+ void updatesContent() {
+ givenEvent(RecruitType.FIRST_COME, 100, savedQuestions(SPORTS));
+
+ service.update(EVENT_ID, updateCommand(List.of(SPORTS)));
+
+ Event saved = repository.events.get(EVENT_ID);
+ assertEquals("체육대회(변경)", saved.getTitle());
+ assertEquals("미래관", saved.getPlace());
+ assertEquals(List.of(40L), saved.getImageIds());
+ assertTrue(saved.isFeePaidOnly());
+ assertTrue(saved.isPublished());
+ assertEquals(7L, saved.getCreatedBy());
+ }
+
+ @Test
+ @DisplayName("없거나 삭제된 행사는 찾을 수 없다")
+ void notFound() {
+ assertRejected(EventErrorCode.EVENT_NOT_FOUND, () -> service.update(EVENT_ID, updateCommand(List.of())));
+ }
+
+ @Nested
+ @DisplayName("동시 수정")
+ class Conflict {
+
+ @Test
+ @DisplayName("수정 화면이 읽은 뒤 다른 운영진이 고쳤다면 거부한다")
+ void rejectsWhenModifiedMeanwhile() {
+ givenEvent(RecruitType.FIRST_COME, 100, savedQuestions(SPORTS));
+
+ assertRejected(EventErrorCode.EVENT_UPDATE_CONFLICT, () -> service.update(EVENT_ID,
+ updateCommand(RecruitType.FIRST_COME, 100, List.of(SPORTS), UPDATED_AT.minusSeconds(1))));
+ assertEquals("체육대회", repository.events.get(EVENT_ID).getTitle());
+ }
+
+ @Test
+ @DisplayName("같은 초 안의 밀리초 차이는 같은 시각으로 본다 — DB가 초 단위로 저장한다")
+ void ignoresSubSecondDifference() {
+ givenEvent(RecruitType.FIRST_COME, 100, savedQuestions(SPORTS));
+
+ assertDoesNotThrow(() -> service.update(EVENT_ID,
+ updateCommand(RecruitType.FIRST_COME, 100, List.of(SPORTS), UPDATED_AT.plusNanos(999_000_000))));
+ }
+
+ @Test
+ @DisplayName("동시 수정 검사는 형식 검증보다 먼저 한다")
+ void checkedBeforeFormat() {
+ givenEvent(RecruitType.FIRST_COME, 100, savedQuestions(SPORTS));
+
+ assertRejected(EventErrorCode.EVENT_UPDATE_CONFLICT, () -> service.update(EVENT_ID,
+ updateCommand(RecruitType.FIRST_COME, 0, List.of(SPORTS), UPDATED_AT.minusDays(1))));
+ }
+ }
+
+ @Nested
+ @DisplayName("정원 하한")
+ class CapacityFloor {
+
+ @Test
+ @DisplayName("선착순 정원을 현재 신청자 수보다 줄일 수 없다")
+ void rejectsBelowApplicants() {
+ givenEvent(RecruitType.FIRST_COME, 100, savedQuestions(SPORTS));
+ repository.appliedCounts.put(EVENT_ID, 10L);
+
+ assertRejected(EventErrorCode.EVENT_CAPACITY_BELOW_APPLICANTS, () -> service.update(EVENT_ID,
+ updateCommand(RecruitType.FIRST_COME, 9, List.of(SPORTS), UPDATED_AT)));
+ }
+
+ @Test
+ @DisplayName("신청자 수와 같은 정원까지는 줄일 수 있다")
+ void allowsEqualToApplicants() {
+ givenEvent(RecruitType.FIRST_COME, 100, savedQuestions(SPORTS));
+ repository.appliedCounts.put(EVENT_ID, 10L);
+
+ service.update(EVENT_ID, updateCommand(RecruitType.FIRST_COME, 10, List.of(SPORTS), UPDATED_AT));
+
+ assertEquals(10, repository.events.get(EVENT_ID).getCapacity());
+ }
+
+ @Test
+ @DisplayName("상시 모집에서 선착순으로 바꿀 때도 신청자 수 이상이어야 한다")
+ void switchingToFirstComeChecksApplicants() {
+ givenEvent(RecruitType.OPEN, 0, savedQuestions(SPORTS));
+ repository.appliedCounts.put(EVENT_ID, 30L);
+
+ assertRejected(EventErrorCode.EVENT_CAPACITY_BELOW_APPLICANTS, () -> service.update(EVENT_ID,
+ updateCommand(RecruitType.FIRST_COME, 20, List.of(SPORTS), UPDATED_AT)));
+ }
+
+ @Test
+ @DisplayName("상시 모집은 신청자 수와 상관없이 수정할 수 있다")
+ void openRecruitIgnoresApplicants() {
+ givenEvent(RecruitType.FIRST_COME, 100, savedQuestions(SPORTS));
+ repository.appliedCounts.put(EVENT_ID, 300L);
+
+ assertDoesNotThrow(() -> service.update(EVENT_ID,
+ updateCommand(RecruitType.OPEN, null, List.of(SPORTS), UPDATED_AT)));
+ }
+ }
+
+ @Nested
+ @DisplayName("신청서 질문")
+ class Questions {
+
+ @Test
+ @DisplayName("질문이 그대로면 교체하지 않아 질문 id가 유지된다")
+ void keepsQuestionsWhenUnchanged() {
+ List original = savedQuestions(PHONE, SPORTS);
+ givenEvent(RecruitType.FIRST_COME, 100, original);
+ repository.applicationExists = true;
+
+ service.update(EVENT_ID, updateCommand(List.of(PHONE, SPORTS)));
+
+ assertFalse(repository.questionsReplaced);
+ assertFalse(repository.updatedAtTouched);
+ assertSame(original, repository.questions.get(EVENT_ID));
+ }
+
+ @Test
+ @DisplayName("신청이 없으면 바뀐 질문으로 통째로 교체한다")
+ void replacesWhenNoApplication() {
+ givenEvent(RecruitType.FIRST_COME, 100, savedQuestions(PHONE, SPORTS));
+
+ service.update(EVENT_ID, updateCommand(List.of(SPORTS)));
+
+ assertTrue(repository.questionsReplaced);
+ List replaced = repository.questions.get(EVENT_ID);
+ assertEquals(List.of("참여 종목"), replaced.stream().map(EventQuestion::getQuestionText).toList());
+ assertEquals(1, replaced.getFirst().getDisplayOrder());
+ assertNull(replaced.getFirst().getId());
+ }
+
+ @Test
+ @DisplayName("질문을 교체하면 행사 수정 시각도 올린다 — 질문만 바뀌면 DB가 시각을 올리지 않는다")
+ void touchesUpdatedAtWhenReplaced() {
+ givenEvent(RecruitType.FIRST_COME, 100, savedQuestions(SPORTS));
+
+ service.update(EVENT_ID, updateCommand(List.of(SPORTS, PHONE)));
+
+ assertTrue(repository.updatedAtTouched);
+ }
+
+ @Test
+ @DisplayName("순서만 바꿔도 바뀐 것으로 본다")
+ void reorderCountsAsChange() {
+ givenEvent(RecruitType.FIRST_COME, 100, savedQuestions(PHONE, SPORTS));
+
+ service.update(EVENT_ID, updateCommand(List.of(SPORTS, PHONE)));
+
+ assertTrue(repository.questionsReplaced);
+ }
+
+ @Test
+ @DisplayName("신청(취소 포함)이 있으면 질문을 바꿀 수 없고 행사도 저장하지 않는다")
+ void lockedWhenApplicationExists() {
+ givenEvent(RecruitType.FIRST_COME, 100, savedQuestions(SPORTS));
+ repository.applicationExists = true;
+
+ assertRejected(EventErrorCode.EVENT_QUESTIONS_LOCKED,
+ () -> service.update(EVENT_ID, updateCommand(List.of(SPORTS, PHONE))));
+ assertFalse(repository.questionsReplaced);
+ assertEquals("체육대회", repository.events.get(EVENT_ID).getTitle());
+ }
+ }
+ }
+
+ @Nested
+ @DisplayName("목록 조회")
+ class GetEvents {
+
+ @Test
+ @DisplayName("검색어와 페이지를 그대로 넘겨 행사 페이지를 돌려준다")
+ void delegatesToRepository() {
+ repository.events.put(EVENT_ID, savedEvent(RecruitType.FIRST_COME, 100));
+
+ PageResult page = service.getEvents("체육", PageOffset.of(1, 10));
+
+ assertEquals("체육", repository.requestedKeyword);
+ assertEquals(PageOffset.of(1, 10), repository.requestedPageOffset);
+ assertEquals(1, page.content().size());
+ }
+
+ @Test
+ @DisplayName("신청자 수는 행사 id 목록으로 한 번에 조회한다")
+ void countsApplicantsAtOnce() {
+ repository.appliedCounts.put(1L, 3L);
+ repository.appliedCounts.put(2L, 5L);
+
+ assertEquals(Map.of(1L, 3L, 2L, 5L), service.getApplicantCounts(List.of(1L, 2L)));
+ }
+ }
+
+ @Test
+ @DisplayName("상세 조회의 모집 상태 계산에 쓰는 신청자 수는 유효 신청만 센다")
+ void detailApplicantCountFeedsRecruitStatus() {
+ repository.events.put(EVENT_ID, savedEvent(RecruitType.FIRST_COME, 3));
+ repository.questions.put(EVENT_ID, List.of());
+ repository.appliedCounts.put(EVENT_ID, 3L);
+
+ EventManageDetail detail = service.getEvent(EVENT_ID);
+
+ assertEquals(RecruitStatus.CLOSED,
+ detail.event().calculateRecruitStatus(APPLY_START_AT.plusDays(1), detail.applicantCount()));
+ }
+
+ /** 운영진 등록·조회·수정에 쓰는 메서드만 메모리로 흉내 내고, 나머지는 이 테스트가 건드리지 않는다. */
+ private static final class InMemoryEventRepository implements EventRepository {
+
+ private final Map events = new HashMap<>();
+ private final Map> questions = new HashMap<>();
+ private final Map appliedCounts = new HashMap<>();
+ private boolean applicationExists;
+ private boolean questionsReplaced;
+ private boolean updatedAtTouched;
+ private String requestedKeyword;
+ private PageOffset requestedPageOffset;
+ private long nextId = 1;
+
+ @Override
+ public Optional findById(Long id) {
+ return Optional.ofNullable(events.get(id));
+ }
+
+ @Override
+ public Event save(Event event) {
+ Long id = event.getId() == null ? nextId++ : event.getId();
+ Event saved = Event.of(
+ id, event.getTitle(), event.getDescription(), event.getPlace(), event.getEventStartAt(),
+ event.getEventEndAt(), event.getApplyStartAt(), event.getApplyEndAt(), event.getRecruitType(),
+ event.getImageIds(), event.getCapacity(), event.isFeePaidOnly(), event.isPublished(),
+ event.getCreatedBy(), event.getUpdatedAt());
+ events.put(id, saved);
+ return saved;
+ }
+
+ @Override
+ public List saveQuestions(List saved) {
+ saved.forEach(question ->
+ questions.computeIfAbsent(question.getEventId(), key -> new ArrayList<>()).add(question));
+ return saved;
+ }
+
+ @Override
+ public List replaceQuestions(Long eventId, List replaced) {
+ questionsReplaced = true;
+ questions.put(eventId, replaced);
+ return replaced;
+ }
+
+ @Override
+ public boolean existsApplicationByEventId(Long eventId) {
+ return applicationExists;
+ }
+
+ @Override
+ public void touchUpdatedAt(Long eventId) {
+ updatedAtTouched = true;
+ }
+
+ @Override
+ public PageResult findPage(String keyword, PageOffset pageOffset) {
+ requestedKeyword = keyword;
+ requestedPageOffset = pageOffset;
+ List content = List.copyOf(events.values());
+ return PageResult.of(content, pageOffset.page(), pageOffset.size(), content.size());
+ }
+
+ @Override
+ public List findQuestionsByEventId(Long eventId) {
+ return questions.getOrDefault(eventId, List.of());
+ }
+
+ @Override
+ public long countAppliedByEventId(Long eventId) {
+ return appliedCounts.getOrDefault(eventId, 0L);
+ }
+
+ @Override
+ public Map countAppliedByEventIds(List eventIds) {
+ Map counts = new HashMap<>();
+ eventIds.stream().filter(appliedCounts::containsKey).forEach(id -> counts.put(id, appliedCounts.get(id)));
+ return counts;
+ }
+
+ @Override
+ public List findAllByIds(List eventIds) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public Optional findPublishedById(Long id) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public List findPublishedSlice(
+ RecruitStatus recruitStatus, EventCursor cursor, int limit, LocalDateTime now) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public boolean existsAppliedByEventIdAndMemberId(Long eventId, Long memberId) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public EventApplication saveApplication(EventApplication application) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public List saveAnswers(List answers) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public List findApplicationSlice(Long memberId, EventApplicationCursor cursor, int limit) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public Optional findApplicationByIdAndMemberId(Long applicationId, Long memberId) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public List findAnswersByApplicationId(Long applicationId) {
+ throw new UnsupportedOperationException();
+ }
+ }
+}
diff --git a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImplTest.java b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImplTest.java
index de178814..dc7afcef 100644
--- a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImplTest.java
+++ b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImplTest.java
@@ -10,6 +10,8 @@
import java.util.Map;
import java.util.Optional;
import kr.ac.kookmin.stream.common.CursorSliceResult;
+import kr.ac.kookmin.stream.common.PageOffset;
+import kr.ac.kookmin.stream.common.PageResult;
import kr.ac.kookmin.stream.event.domain.event.domain.Event;
import kr.ac.kookmin.stream.event.domain.event.domain.EventApplication;
import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationAnswer;
@@ -34,7 +36,7 @@ private static Event event(Long id) {
return Event.of(
id, "행사 " + id, "설명", "101호",
NOW.plusDays(10), NOW.plusDays(10).plusHours(2), NOW.minusDays(1), NOW.plusDays(3),
- RecruitType.FIRST_COME, List.of(), 100, true, 1L
+ RecruitType.FIRST_COME, List.of(), 100, false, true, 1L, null
);
}
@@ -45,7 +47,7 @@ void matchesApplicantCountToItsEvent() {
List.of(event(1L), event(2L)),
Map.of(1L, 3L, 2L, 7L));
- List content = new EventServiceImpl(repository)
+ List content = new EventServiceImpl(repository, new EventWriteValidator())
.getPublishedEvents(null, null, 20).content();
// 정원 100명이라 신청자 수가 모집 상태를 바꾸지 않는다. 짝이 맞는지는 D-Day가 아니라 아래에서 직접 본다
@@ -61,7 +63,7 @@ void keepsEventsWithoutApplicants() {
List.of(event(1L), event(2L)),
Map.of(1L, 3L));
- List content = new EventServiceImpl(repository)
+ List content = new EventServiceImpl(repository, new EventWriteValidator())
.getPublishedEvents(null, null, 20).content();
assertEquals(2, content.size());
@@ -74,17 +76,17 @@ void appliesCountPerEvent() {
Event full = Event.of(
1L, "정원 3명", "설명", "101호",
NOW.plusDays(10), null, NOW.minusDays(1), NOW.plusDays(3),
- RecruitType.FIRST_COME, List.of(), 3, true, 1L);
+ RecruitType.FIRST_COME, List.of(), 3, false, true, 1L, null);
Event roomy = Event.of(
2L, "정원 100명", "설명", "101호",
NOW.plusDays(10), null, NOW.minusDays(1), NOW.plusDays(3),
- RecruitType.FIRST_COME, List.of(), 100, true, 1L);
+ RecruitType.FIRST_COME, List.of(), 100, false, true, 1L, null);
FakeEventRepository repository = new FakeEventRepository(
List.of(full, roomy),
Map.of(1L, 3L, 2L, 3L));
- List content = new EventServiceImpl(repository)
+ List content = new EventServiceImpl(repository, new EventWriteValidator())
.getPublishedEvents(null, null, 20).content();
assertEquals(RecruitStatus.CLOSED, content.get(0).recruitStatus());
@@ -97,7 +99,7 @@ void detectsNextPage() {
FakeEventRepository repository = new FakeEventRepository(
List.of(event(1L), event(2L), event(3L)), Map.of());
- CursorSliceResult result = new EventServiceImpl(repository)
+ CursorSliceResult result = new EventServiceImpl(repository, new EventWriteValidator())
.getPublishedEvents(null, null, 2);
assertEquals(3, repository.requestedLimit);
@@ -111,7 +113,7 @@ void detectsNextPage() {
void noCursorOnLastPage() {
FakeEventRepository repository = new FakeEventRepository(List.of(event(1L), event(2L)), Map.of());
- CursorSliceResult result = new EventServiceImpl(repository)
+ CursorSliceResult result = new EventServiceImpl(repository, new EventWriteValidator())
.getPublishedEvents(null, null, 2);
assertFalse(result.hasNext());
@@ -124,7 +126,7 @@ void asksNothingWhenSliceIsEmpty() {
FakeEventRepository repository = new FakeEventRepository(
List.of(), Map.of());
- CursorSliceResult result = new EventServiceImpl(repository)
+ CursorSliceResult result = new EventServiceImpl(repository, new EventWriteValidator())
.getPublishedEvents(null, null, 20);
assertTrue(result.content().isEmpty());
@@ -162,6 +164,36 @@ public Optional findById(Long id) {
throw new UnsupportedOperationException();
}
+ @Override
+ public Event save(Event event) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public List saveQuestions(List questions) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public PageResult findPage(String keyword, PageOffset pageOffset) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public List replaceQuestions(Long eventId, List questions) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public boolean existsApplicationByEventId(Long eventId) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public void touchUpdatedAt(Long eventId) {
+ throw new UnsupportedOperationException();
+ }
+
@Override
public Optional findPublishedById(Long id) {
throw new UnsupportedOperationException();
diff --git a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventWriteValidatorTest.java b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventWriteValidatorTest.java
new file mode 100644
index 00000000..37ff1230
--- /dev/null
+++ b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventWriteValidatorTest.java
@@ -0,0 +1,195 @@
+package kr.ac.kookmin.stream.event.domain.event.service.impl;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import kr.ac.kookmin.stream.common.BusinessException;
+import kr.ac.kookmin.stream.event.domain.event.domain.EventCreateCommand;
+import kr.ac.kookmin.stream.event.domain.event.domain.EventErrorCode;
+import kr.ac.kookmin.stream.event.domain.event.domain.EventQuestionCommand;
+import kr.ac.kookmin.stream.event.domain.event.domain.EventUpdateCommand;
+import kr.ac.kookmin.stream.event.domain.event.domain.QuestionType;
+import kr.ac.kookmin.stream.event.domain.event.domain.RecruitType;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+class EventWriteValidatorTest {
+
+ private static final LocalDateTime APPLY_START_AT = LocalDateTime.of(2026, 10, 14, 10, 0);
+ private static final LocalDateTime APPLY_END_AT = LocalDateTime.of(2026, 10, 25, 23, 59, 59);
+ private static final LocalDateTime EVENT_START_AT = LocalDateTime.of(2026, 11, 1, 10, 0);
+ private static final LocalDateTime EVENT_END_AT = LocalDateTime.of(2026, 11, 1, 18, 0);
+
+ private final EventWriteValidator validator = new EventWriteValidator();
+
+ private static EventCreateCommand createCommand(
+ LocalDateTime eventStartAt,
+ LocalDateTime eventEndAt,
+ LocalDateTime applyStartAt,
+ LocalDateTime applyEndAt,
+ RecruitType recruitType,
+ Integer capacity,
+ List questions
+ ) {
+ return new EventCreateCommand(
+ "체육대회", "설명", "종합운동장", eventStartAt, eventEndAt, applyStartAt, applyEndAt,
+ recruitType, capacity, false, List.of(), questions
+ );
+ }
+
+ private static EventCreateCommand validCommand() {
+ return createCommand(
+ EVENT_START_AT, EVENT_END_AT, APPLY_START_AT, APPLY_END_AT, RecruitType.FIRST_COME, 100, List.of());
+ }
+
+ private static EventQuestionCommand question(QuestionType type, List options) {
+ return new EventQuestionCommand("질문", type, true, options, false);
+ }
+
+ private static void assertRejected(EventErrorCode expected, Runnable validation) {
+ BusinessException e = assertThrows(BusinessException.class, validation::run);
+ assertEquals(expected, e.getErrorCode());
+ }
+
+ @Test
+ @DisplayName("규칙을 모두 지키면 통과한다")
+ void validCommandPasses() {
+ assertDoesNotThrow(() -> validator.validate(validCommand()));
+ }
+
+ @Nested
+ @DisplayName("기간")
+ class Period {
+
+ @Test
+ @DisplayName("행사 종료가 시작보다 빠르면 거부한다")
+ void eventEndsBeforeStart() {
+ assertRejected(EventErrorCode.EVENT_INVALID_PERIOD, () -> validator.validate(createCommand(
+ EVENT_START_AT, EVENT_START_AT.minusMinutes(1), APPLY_START_AT, APPLY_END_AT,
+ RecruitType.OPEN, null, List.of())));
+ }
+
+ @Test
+ @DisplayName("행사 시작과 종료가 같으면 거부한다")
+ void eventEndsAtStart() {
+ assertRejected(EventErrorCode.EVENT_INVALID_PERIOD, () -> validator.validate(createCommand(
+ EVENT_START_AT, EVENT_START_AT, APPLY_START_AT, APPLY_END_AT, RecruitType.OPEN, null, List.of())));
+ }
+
+ @Test
+ @DisplayName("신청 마감이 시작보다 빠르면 거부한다")
+ void applyEndsBeforeStart() {
+ assertRejected(EventErrorCode.EVENT_INVALID_PERIOD, () -> validator.validate(createCommand(
+ EVENT_START_AT, EVENT_END_AT, APPLY_START_AT, APPLY_START_AT.minusSeconds(1),
+ RecruitType.OPEN, null, List.of())));
+ }
+
+ @Test
+ @DisplayName("신청 기간과 행사 기간이 겹쳐도 각각 순서만 맞으면 통과한다")
+ void overlappingPeriodsAreAllowed() {
+ assertDoesNotThrow(() -> validator.validate(createCommand(
+ APPLY_START_AT.plusDays(1), APPLY_END_AT.plusDays(1), APPLY_START_AT, APPLY_END_AT,
+ RecruitType.OPEN, null, List.of())));
+ }
+ }
+
+ @Nested
+ @DisplayName("정원")
+ class Capacity {
+
+ @Test
+ @DisplayName("선착순인데 정원이 없으면 거부한다")
+ void firstComeWithoutCapacity() {
+ assertRejected(EventErrorCode.EVENT_INVALID_CAPACITY, () -> validator.validate(createCommand(
+ EVENT_START_AT, EVENT_END_AT, APPLY_START_AT, APPLY_END_AT, RecruitType.FIRST_COME, null, List.of())));
+ }
+
+ @Test
+ @DisplayName("선착순인데 정원이 0명이면 거부한다")
+ void firstComeWithZeroCapacity() {
+ assertRejected(EventErrorCode.EVENT_INVALID_CAPACITY, () -> validator.validate(createCommand(
+ EVENT_START_AT, EVENT_END_AT, APPLY_START_AT, APPLY_END_AT, RecruitType.FIRST_COME, 0, List.of())));
+ }
+
+ @Test
+ @DisplayName("선착순 정원은 1명부터 허용한다")
+ void firstComeWithOneCapacity() {
+ assertDoesNotThrow(() -> validator.validate(createCommand(
+ EVENT_START_AT, EVENT_END_AT, APPLY_START_AT, APPLY_END_AT, RecruitType.FIRST_COME, 1, List.of())));
+ }
+
+ @Test
+ @DisplayName("상시 모집은 정원을 보지 않는다")
+ void openRecruitIgnoresCapacity() {
+ assertDoesNotThrow(() -> validator.validate(createCommand(
+ EVENT_START_AT, EVENT_END_AT, APPLY_START_AT, APPLY_END_AT, RecruitType.OPEN, null, List.of())));
+ assertDoesNotThrow(() -> validator.validate(createCommand(
+ EVENT_START_AT, EVENT_END_AT, APPLY_START_AT, APPLY_END_AT, RecruitType.OPEN, -1, List.of())));
+ }
+ }
+
+ @Nested
+ @DisplayName("신청서 질문")
+ class Questions {
+
+ @Test
+ @DisplayName("선택형 질문에 선택지가 없으면 거부한다")
+ void choiceWithoutOptions() {
+ for (QuestionType type : List.of(QuestionType.SINGLE_CHOICE, QuestionType.MULTIPLE_CHOICE)) {
+ assertRejected(EventErrorCode.EVENT_INVALID_QUESTION, () -> validator.validate(createCommand(
+ EVENT_START_AT, EVENT_END_AT, APPLY_START_AT, APPLY_END_AT, RecruitType.OPEN, null,
+ List.of(question(type, List.of())))));
+ assertRejected(EventErrorCode.EVENT_INVALID_QUESTION, () -> validator.validate(createCommand(
+ EVENT_START_AT, EVENT_END_AT, APPLY_START_AT, APPLY_END_AT, RecruitType.OPEN, null,
+ List.of(question(type, null)))));
+ }
+ }
+
+ @Test
+ @DisplayName("여러 질문 중 하나라도 규칙을 어기면 거부한다")
+ void oneInvalidAmongMany() {
+ assertRejected(EventErrorCode.EVENT_INVALID_QUESTION, () -> validator.validate(createCommand(
+ EVENT_START_AT, EVENT_END_AT, APPLY_START_AT, APPLY_END_AT, RecruitType.OPEN, null, List.of(
+ question(QuestionType.SHORT_TEXT, List.of()),
+ question(QuestionType.SINGLE_CHOICE, List.of("참석", "불참")),
+ question(QuestionType.MULTIPLE_CHOICE, List.of())))));
+ }
+
+ @Test
+ @DisplayName("텍스트형 질문은 선택지가 없어도 통과한다")
+ void textWithoutOptions() {
+ assertDoesNotThrow(() -> validator.validate(createCommand(
+ EVENT_START_AT, EVENT_END_AT, APPLY_START_AT, APPLY_END_AT, RecruitType.OPEN, null, List.of(
+ question(QuestionType.SHORT_TEXT, null),
+ question(QuestionType.LONG_TEXT, List.of())))));
+ }
+ }
+
+ @Nested
+ @DisplayName("수정 요청")
+ class UpdateCommand {
+
+ @Test
+ @DisplayName("등록과 같은 규칙으로 검증한다")
+ void sameRulesAsCreate() {
+ EventUpdateCommand invalidPeriod = new EventUpdateCommand(
+ "체육대회", "설명", null, EVENT_START_AT, EVENT_START_AT, APPLY_START_AT, APPLY_END_AT,
+ RecruitType.OPEN, null, false, List.of(), List.of(), LocalDateTime.now());
+ EventUpdateCommand invalidCapacity = new EventUpdateCommand(
+ "체육대회", "설명", null, EVENT_START_AT, EVENT_END_AT, APPLY_START_AT, APPLY_END_AT,
+ RecruitType.FIRST_COME, 0, false, List.of(), List.of(), LocalDateTime.now());
+ EventUpdateCommand invalidQuestion = new EventUpdateCommand(
+ "체육대회", "설명", null, EVENT_START_AT, EVENT_END_AT, APPLY_START_AT, APPLY_END_AT,
+ RecruitType.OPEN, null, false, List.of(), List.of(question(QuestionType.SINGLE_CHOICE, List.of())),
+ LocalDateTime.now());
+
+ assertRejected(EventErrorCode.EVENT_INVALID_PERIOD, () -> validator.validate(invalidPeriod));
+ assertRejected(EventErrorCode.EVENT_INVALID_CAPACITY, () -> validator.validate(invalidCapacity));
+ assertRejected(EventErrorCode.EVENT_INVALID_QUESTION, () -> validator.validate(invalidQuestion));
+ }
+ }
+}
diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventApplicationJpaRepository.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventApplicationJpaRepository.java
index 863e98e1..ff405012 100644
--- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventApplicationJpaRepository.java
+++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventApplicationJpaRepository.java
@@ -13,6 +13,8 @@ public interface EventApplicationJpaRepository extends JpaRepository findByIdAndMemberId(Long id, Long memberId);
diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventJpaEntity.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventJpaEntity.java
index d33a051c..fef068c1 100644
--- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventJpaEntity.java
+++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventJpaEntity.java
@@ -66,6 +66,9 @@ public class EventJpaEntity extends BaseSoftDeleteEntity {
@Column(nullable = false)
private int capacity;
+ @Column(name = "fee_paid_only", nullable = false)
+ private boolean feePaidOnly;
+
@Column(name = "is_published", nullable = false)
private boolean isPublished;
@@ -84,6 +87,7 @@ private EventJpaEntity(Event event) {
this.recruitType = event.getRecruitType();
this.imageIds = event.getImageIds();
this.capacity = event.getCapacity();
+ this.feePaidOnly = event.isFeePaidOnly();
this.isPublished = event.isPublished();
this.createdBy = event.getCreatedBy();
}
@@ -94,7 +98,7 @@ public static EventJpaEntity from(Event event) {
public Event toDomain() {
return Event.of(id, title, description, place, eventStartAt, eventEndAt,
- applyStartAt, applyEndAt, recruitType, imageIds, capacity, isPublished,
- createdBy);
+ applyStartAt, applyEndAt, recruitType, imageIds, capacity, feePaidOnly,
+ isPublished, createdBy, getUpdatedAt());
}
}
diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventJpaRepository.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventJpaRepository.java
index 79d81881..dfc8848b 100644
--- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventJpaRepository.java
+++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventJpaRepository.java
@@ -5,8 +5,10 @@
import java.util.Optional;
import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationStatus;
import kr.ac.kookmin.stream.event.domain.event.domain.RecruitType;
+import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
@@ -16,6 +18,29 @@ public interface EventJpaRepository extends JpaRepository
Optional findByIdAndIsDeletedFalseAndIsPublishedTrue(Long id);
+ /**
+ * 삭제되지 않은 행사를 공개 여부와 상관없이 최근 등록 순으로 페이지 조회한다. 운영진 목록용이다.
+ */
+ @Query(
+ value = """
+ SELECT e FROM EventJpaEntity e
+ WHERE e.isDeleted = false
+ AND (:keyword IS NULL OR e.title LIKE CONCAT('%', :keyword, '%'))
+ ORDER BY e.id DESC
+ """,
+ countQuery = """
+ SELECT COUNT(e) FROM EventJpaEntity e
+ WHERE e.isDeleted = false
+ AND (:keyword IS NULL OR e.title LIKE CONCAT('%', :keyword, '%'))
+ """
+ )
+ Page search(@Param("keyword") String keyword, Pageable pageable);
+
+ // updated_at은 엔티티에서 읽기 전용(updatable = false)이라 네이티브 쿼리로 직접 올린다
+ @Modifying
+ @Query(value = "UPDATE events SET updated_at = CURRENT_TIMESTAMP WHERE event_id = :eventId", nativeQuery = true)
+ void touchUpdatedAt(@Param("eventId") Long eventId);
+
/**
* 게시된 행사를 (event_start_at, event_id) keyset 커서로 조회한다.
*
diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventQuestionJpaEntity.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventQuestionJpaEntity.java
index ef545f0e..4751c4dc 100644
--- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventQuestionJpaEntity.java
+++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventQuestionJpaEntity.java
@@ -53,6 +53,9 @@ public class EventQuestionJpaEntity extends BaseTimeEntity {
@Column(columnDefinition = "json")
private List options;
+ @Column(name = "has_other_option", nullable = false)
+ private boolean otherOptionAllowed;
+
private EventQuestionJpaEntity(EventQuestion question) {
this.id = question.getId();
this.eventId = question.getEventId();
@@ -61,6 +64,7 @@ private EventQuestionJpaEntity(EventQuestion question) {
this.required = question.isRequired();
this.displayOrder = question.getDisplayOrder();
this.options = question.getOptions();
+ this.otherOptionAllowed = question.isOtherOptionAllowed();
}
public static EventQuestionJpaEntity from(EventQuestion question) {
@@ -68,6 +72,7 @@ public static EventQuestionJpaEntity from(EventQuestion question) {
}
public EventQuestion toDomain() {
- return EventQuestion.of(id, eventId, questionText, questionType, required, displayOrder, options);
+ return EventQuestion.of(id, eventId, questionText, questionType, required, displayOrder, options,
+ otherOptionAllowed);
}
}
diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventQuestionJpaRepository.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventQuestionJpaRepository.java
index 5fea788b..d39e6f72 100644
--- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventQuestionJpaRepository.java
+++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventQuestionJpaRepository.java
@@ -2,8 +2,15 @@
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Modifying;
+import org.springframework.data.jpa.repository.Query;
+import org.springframework.data.repository.query.Param;
public interface EventQuestionJpaRepository extends JpaRepository {
List findAllByEventIdOrderByDisplayOrderAsc(Long eventId);
+
+ @Modifying
+ @Query("DELETE FROM EventQuestionJpaEntity q WHERE q.eventId = :eventId")
+ void deleteAllByEventId(@Param("eventId") Long eventId);
}
diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventRepositoryImpl.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventRepositoryImpl.java
index 58b9f5e1..b90bcb3a 100644
--- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventRepositoryImpl.java
+++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventRepositoryImpl.java
@@ -5,6 +5,8 @@
import java.util.Map;
import java.util.Optional;
import java.util.stream.Collectors;
+import kr.ac.kookmin.stream.common.PageOffset;
+import kr.ac.kookmin.stream.common.PageResult;
import kr.ac.kookmin.stream.event.domain.event.domain.Event;
import kr.ac.kookmin.stream.event.domain.event.domain.EventApplication;
import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationAnswer;
@@ -16,6 +18,8 @@
import kr.ac.kookmin.stream.event.domain.event.domain.RecruitType;
import kr.ac.kookmin.stream.event.domain.event.repository.EventRepository;
import lombok.RequiredArgsConstructor;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Repository;
@@ -28,6 +32,51 @@ public class EventRepositoryImpl implements EventRepository {
private final EventApplicationJpaRepository eventApplicationJpaRepository;
private final EventApplicationAnswerJpaRepository eventApplicationAnswerJpaRepository;
+ @Override
+ public Event save(Event event) {
+ return eventJpaRepository.save(EventJpaEntity.from(event)).toDomain();
+ }
+
+ @Override
+ public List saveQuestions(List questions) {
+ List entities = questions.stream()
+ .map(EventQuestionJpaEntity::from)
+ .toList();
+ return eventQuestionJpaRepository.saveAll(entities).stream()
+ .map(EventQuestionJpaEntity::toDomain)
+ .toList();
+ }
+
+ @Override
+ public List replaceQuestions(Long eventId, List questions) {
+ eventQuestionJpaRepository.deleteAllByEventId(eventId);
+ return saveQuestions(questions);
+ }
+
+ @Override
+ public boolean existsApplicationByEventId(Long eventId) {
+ return eventApplicationJpaRepository.existsByEventId(eventId);
+ }
+
+ @Override
+ public void touchUpdatedAt(Long eventId) {
+ eventJpaRepository.touchUpdatedAt(eventId);
+ }
+
+ @Override
+ public PageResult findPage(String keyword, PageOffset pageOffset) {
+ Page result = eventJpaRepository.search(
+ keyword, PageRequest.of(pageOffset.page(), pageOffset.size())
+ );
+
+ return PageResult.of(
+ result.getContent().stream().map(EventJpaEntity::toDomain).toList(),
+ result.getNumber(),
+ result.getSize(),
+ result.getTotalElements()
+ );
+ }
+
@Override
public Optional findById(Long id) {
return eventJpaRepository.findByIdAndIsDeletedFalse(id).map(EventJpaEntity::toDomain);
diff --git a/infrastructure/db/src/main/resources/db/migration/V19__add_fee_paid_only_and_has_other_option_to_events.sql b/infrastructure/db/src/main/resources/db/migration/V19__add_fee_paid_only_and_has_other_option_to_events.sql
new file mode 100644
index 00000000..4b2a1c0a
--- /dev/null
+++ b/infrastructure/db/src/main/resources/db/migration/V19__add_fee_paid_only_and_has_other_option_to_events.sql
@@ -0,0 +1,7 @@
+-- 학생회비 납부자만 신청할 수 있는 행사인지
+ALTER TABLE events
+ ADD COLUMN fee_paid_only TINYINT(1) NOT NULL DEFAULT 0;
+
+-- 선택형 질문에 '기타'(직접 입력) 선택지를 덧붙이는지
+ALTER TABLE event_questions
+ ADD COLUMN has_other_option TINYINT(1) NOT NULL DEFAULT 0;