From 70a6122d93077ed2d694d7506b1c1923aa3d7db5 Mon Sep 17 00:00:00 2001
From: gain <163835628+leegain1@users.noreply.github.com>
Date: Sun, 18 Oct 2026 00:59:32 +0900
Subject: [PATCH 1/8] =?UTF-8?q?feat:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?=
=?UTF-8?q?=ED=96=89=EC=82=AC=20=EA=B3=B5=EA=B0=9C=20=EC=83=81=ED=83=9C=20?=
=?UTF-8?q?=EB=B3=80=EA=B2=BD=20API=20=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../api/admin/event/event/AdminEventApi.java | 12 +++++++++++-
.../admin/event/event/AdminEventController.java | 14 ++++++++++++++
.../event/event/request/EventPublishRequest.java | 11 +++++++++++
.../event/event/response/EventPublishResponse.java | 10 ++++++++++
.../stream/event/domain/event/domain/Event.java | 5 +++++
.../event/domain/event/service/EventService.java | 3 +++
.../event/service/impl/EventServiceImpl.java | 8 ++++++++
7 files changed, 62 insertions(+), 1 deletion(-)
create mode 100644 api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/request/EventPublishRequest.java
create mode 100644 api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/response/EventPublishResponse.java
diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/AdminEventApi.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/AdminEventApi.java
index b2368aef..61ec9258 100644
--- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/AdminEventApi.java
+++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/AdminEventApi.java
@@ -4,11 +4,13 @@
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.EventPublishRequest;
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.EventPublishResponse;
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;
@@ -24,7 +26,7 @@
*
* 접근은 {@code ADMIN} role이면 된다. 부서 제한은 두지 않았다.
*/
-@Tag(name = "행사 관리", description = "운영진 행사 등록·조회·수정")
+@Tag(name = "행사 관리", description = "운영진 행사 등록·조회·수정·공개 전환·삭제")
public interface AdminEventApi {
/** 행사 등록. 작성자는 토큰의 관리자로 정해진다. */
@@ -71,4 +73,12 @@ ApiResponse> getEvents(
)
@ApiErrorCode(type = FileErrorCode.class, codes = {"FILE_NOT_FOUND"})
ApiResponse updateEvent(AdminApiUser apiUser, Long eventId, EventUpdateRequest request);
+
+ /** 공개 상태 변경. 공개한 행사만 학생 앱 목록·상세에 보인다. */
+ @Operation(summary = "행사 공개 상태 변경",
+ description = "행사를 학생 앱에 공개하거나 비공개로 돌린다. 이미 그 상태여도 성공으로 응답한다. "
+ + "비공개로 돌려도 기존 신청 내역은 유지된다.")
+ @ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"})
+ @ApiErrorCode(type = EventErrorCode.class, codes = {"EVENT_NOT_FOUND"})
+ ApiResponse changePublished(AdminApiUser apiUser, Long eventId, EventPublishRequest request);
}
diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/AdminEventController.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/AdminEventController.java
index e6399076..f16b172c 100644
--- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/AdminEventController.java
+++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/AdminEventController.java
@@ -5,11 +5,13 @@
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.EventPublishRequest;
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.EventPublishResponse;
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;
@@ -24,6 +26,7 @@
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
+import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
@@ -85,4 +88,15 @@ public ApiResponse updateEvent(
Event event = adminEventWriteUseCase.update(eventId, request.toCommand());
return ApiResponse.success(EventUpdateResponse.from(event));
}
+
+ @Override
+ @PatchMapping("/{eventId}/publish")
+ public ApiResponse changePublished(
+ AdminApiUser apiUser,
+ @PathVariable Long eventId,
+ @Valid @RequestBody EventPublishRequest request
+ ) {
+ Event event = eventService.changePublished(eventId, request.isPublished());
+ return ApiResponse.success(EventPublishResponse.from(event));
+ }
}
diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/request/EventPublishRequest.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/request/EventPublishRequest.java
new file mode 100644
index 00000000..2f7dc407
--- /dev/null
+++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/request/EventPublishRequest.java
@@ -0,0 +1,11 @@
+package kr.ac.kookmin.stream.api.admin.event.event.request;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import jakarta.validation.constraints.NotNull;
+
+public record EventPublishRequest(
+ @Schema(description = "공개 여부. true면 학생 앱에 노출된다", example = "true")
+ @NotNull(message = "공개 여부를 입력해 주세요.")
+ Boolean isPublished
+) {
+}
diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/response/EventPublishResponse.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/response/EventPublishResponse.java
new file mode 100644
index 00000000..2c3ec990
--- /dev/null
+++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/response/EventPublishResponse.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 EventPublishResponse(Long eventId, boolean isPublished) {
+
+ public static EventPublishResponse from(Event event) {
+ return new EventPublishResponse(event.getId(), event.isPublished());
+ }
+}
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 6ab5f3e1..2d2c8e15 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
@@ -139,6 +139,11 @@ public Long thumbnailFileId() {
return imageIds == null || imageIds.isEmpty() ? null : imageIds.getFirst();
}
+ /** 학생 앱 노출 여부를 바꾼다. 이미 그 상태여도 그대로 둔다. */
+ public void changePublished(boolean published) {
+ this.published = published;
+ }
+
/**
* 운영진 화면에 보여줄 정원. 상시 모집은 인원 제한이 없어 {@code null}이다(저장값 0은 의미가 없다).
*/
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 b6dfc05e..3549bb9d 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
@@ -47,4 +47,7 @@ public interface EventService {
* 선착순 정원을 현재 신청자 수보다 줄일 수 없다.
*/
Event update(Long eventId, EventUpdateCommand command);
+
+ /** 학생 앱 노출 여부를 바꾼다. 이미 그 상태여도 성공으로 본다. */
+ Event changePublished(Long eventId, boolean published);
}
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 6842679e..d8da5873 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
@@ -123,6 +123,14 @@ public Event update(Long eventId, EventUpdateCommand command) {
return eventRepository.save(event);
}
+ @Override
+ @Transactional
+ public Event changePublished(Long eventId, boolean published) {
+ Event event = getEventOrThrow(eventId);
+ event.changePublished(published);
+ return eventRepository.save(event);
+ }
+
private Event getEventOrThrow(Long eventId) {
return eventRepository.findById(eventId)
.orElseThrow(() -> new BusinessException(EventErrorCode.EVENT_NOT_FOUND));
From d620104ae8b31a9da6ae6cfddf599a5f4a9fedad Mon Sep 17 00:00:00 2001
From: gain <163835628+leegain1@users.noreply.github.com>
Date: Sun, 18 Oct 2026 00:59:54 +0900
Subject: [PATCH 2/8] =?UTF-8?q?feat:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?=
=?UTF-8?q?=ED=96=89=EC=82=AC=20=EC=82=AD=EC=A0=9C=20API=20=EC=B6=94?=
=?UTF-8?q?=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../stream/api/admin/event/event/AdminEventApi.java | 7 +++++++
.../api/admin/event/event/AdminEventController.java | 8 ++++++++
.../event/domain/event/repository/EventRepository.java | 7 +++++++
.../event/domain/event/service/EventService.java | 5 +++++
.../domain/event/service/impl/EventServiceImpl.java | 8 ++++++++
.../event/service/impl/EventServiceImplManageTest.java | 5 +++++
.../event/service/impl/EventServiceImplTest.java | 5 +++++
.../kookmin/stream/db/event/EventRepositoryImpl.java | 10 ++++++++++
8 files changed, 55 insertions(+)
diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/AdminEventApi.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/AdminEventApi.java
index 61ec9258..211c9777 100644
--- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/AdminEventApi.java
+++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/AdminEventApi.java
@@ -81,4 +81,11 @@ ApiResponse> getEvents(
@ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"})
@ApiErrorCode(type = EventErrorCode.class, codes = {"EVENT_NOT_FOUND"})
ApiResponse changePublished(AdminApiUser apiUser, Long eventId, EventPublishRequest request);
+
+ /** 행사 삭제. 소프트 삭제라 데이터는 남지만 어디에도 보이지 않는다. */
+ @Operation(summary = "행사 삭제",
+ description = "행사를 삭제한다. 신청자가 있어도 지울 수 있으며, 행사와 그 신청 내역은 운영진 화면과 "
+ + "학생 앱(내 신청 내역 포함)에서 보이지 않게 된다. 데이터는 지우지 않고 복구 기능은 없다.")
+ @ApiErrorCode(type = EventErrorCode.class, codes = {"EVENT_NOT_FOUND"})
+ ApiResponse deleteEvent(AdminApiUser apiUser, Long eventId);
}
diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/AdminEventController.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/AdminEventController.java
index f16b172c..61e6df56 100644
--- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/AdminEventController.java
+++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/AdminEventController.java
@@ -24,6 +24,7 @@
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.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PatchMapping;
@@ -99,4 +100,11 @@ public ApiResponse changePublished(
Event event = eventService.changePublished(eventId, request.isPublished());
return ApiResponse.success(EventPublishResponse.from(event));
}
+
+ @Override
+ @DeleteMapping("/{eventId}")
+ public ApiResponse deleteEvent(AdminApiUser apiUser, @PathVariable Long eventId) {
+ eventService.delete(eventId);
+ return ApiResponse.success();
+ }
}
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 fb496043..aa5ed43a 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
@@ -20,6 +20,13 @@ public interface EventRepository {
Event save(Event event);
+ /**
+ * 행사를 삭제 표시한다(soft delete). 신청·답변은 남긴다.
+ *
+ * @return 삭제할 행사가 있었는지. 없거나 이미 삭제됐으면 {@code false}
+ */
+ boolean delete(Long id);
+
List saveQuestions(List questions);
/** 행사의 신청서 질문을 모두 지우고 주어진 질문으로 바꾼다. */
diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/EventService.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/EventService.java
index 3549bb9d..db872699 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
@@ -50,4 +50,9 @@ public interface EventService {
/** 학생 앱 노출 여부를 바꾼다. 이미 그 상태여도 성공으로 본다. */
Event changePublished(Long eventId, boolean published);
+
+ /**
+ * 행사를 삭제한다. 신청자가 있어도 지울 수 있으며, 삭제한 행사는 운영진 화면과 학생 앱 어디에도 보이지 않는다.
+ */
+ void delete(Long eventId);
}
diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImpl.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImpl.java
index d8da5873..3f2866a7 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
@@ -131,6 +131,14 @@ public Event changePublished(Long eventId, boolean published) {
return eventRepository.save(event);
}
+ @Override
+ @Transactional
+ public void delete(Long eventId) {
+ if (!eventRepository.delete(eventId)) {
+ throw new BusinessException(EventErrorCode.EVENT_NOT_FOUND);
+ }
+ }
+
private Event getEventOrThrow(Long eventId) {
return eventRepository.findById(eventId)
.orElseThrow(() -> new BusinessException(EventErrorCode.EVENT_NOT_FOUND));
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
index b4285436..0139bb42 100644
--- 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
@@ -417,6 +417,11 @@ public Event save(Event event) {
return saved;
}
+ @Override
+ public boolean delete(Long id) {
+ return events.remove(id) != null;
+ }
+
@Override
public List saveQuestions(List saved) {
saved.forEach(question ->
diff --git a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImplTest.java b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImplTest.java
index dc7afcef..cce40cf6 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
@@ -174,6 +174,11 @@ public List saveQuestions(List questions) {
throw new UnsupportedOperationException();
}
+ @Override
+ public boolean delete(Long id) {
+ throw new UnsupportedOperationException();
+ }
+
@Override
public PageResult findPage(String keyword, PageOffset pageOffset) {
throw new UnsupportedOperationException();
diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventRepositoryImpl.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventRepositoryImpl.java
index b90bcb3a..484ae4b9 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
@@ -37,6 +37,16 @@ public Event save(Event event) {
return eventJpaRepository.save(EventJpaEntity.from(event)).toDomain();
}
+ @Override
+ public boolean delete(Long id) {
+ return eventJpaRepository.findByIdAndIsDeletedFalse(id)
+ .map(entity -> {
+ entity.delete();
+ return true;
+ })
+ .orElse(false);
+ }
+
@Override
public List saveQuestions(List questions) {
List entities = questions.stream()
From 8851d05a513d881d879647a577e49627a9d22445 Mon Sep 17 00:00:00 2001
From: gain <163835628+leegain1@users.noreply.github.com>
Date: Sun, 18 Oct 2026 01:02:24 +0900
Subject: [PATCH 3/8] =?UTF-8?q?feat:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?=
=?UTF-8?q?=ED=96=89=EC=82=AC=20=EC=8B=A0=EC=B2=AD=20=ED=98=84=ED=99=A9=20?=
=?UTF-8?q?=EC=A1=B0=ED=9A=8C=20API=20=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../event/event/AdminEventApplicationApi.java | 36 ++++++
.../AdminEventApplicationController.java | 35 ++++++
.../request/EventApplicationSearchParams.java | 22 ++++
.../AdminEventApplicationListResponse.java | 113 ++++++++++++++++++
.../AdminEventApplicationQueryUseCase.java | 74 ++++++++++++
.../event/event/usecase/EventApplicant.java | 16 +++
.../event/usecase/EventApplicantPage.java | 17 +++
.../event/domain/EventApplicationStatus.java | 19 ++-
.../domain/event/domain/EventErrorCode.java | 1 +
.../event/repository/EventRepository.java | 20 ++++
.../service/EventApplicationService.java | 26 ++++
.../impl/EventApplicationServiceImpl.java | 30 +++++
.../impl/EventServiceImplManageTest.java | 17 +++
.../service/impl/EventServiceImplTest.java | 17 +++
.../EventApplicationAnswerJpaRepository.java | 2 +
.../event/EventApplicationJpaRepository.java | 26 ++++
.../stream/db/event/EventRepositoryImpl.java | 36 ++++++
17 files changed, 506 insertions(+), 1 deletion(-)
create mode 100644 api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/AdminEventApplicationApi.java
create mode 100644 api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/AdminEventApplicationController.java
create mode 100644 api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/request/EventApplicationSearchParams.java
create mode 100644 api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/response/AdminEventApplicationListResponse.java
create mode 100644 api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/usecase/AdminEventApplicationQueryUseCase.java
create mode 100644 api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/usecase/EventApplicant.java
create mode 100644 api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/usecase/EventApplicantPage.java
diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/AdminEventApplicationApi.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/AdminEventApplicationApi.java
new file mode 100644
index 00000000..21b47a39
--- /dev/null
+++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/AdminEventApplicationApi.java
@@ -0,0 +1,36 @@
+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.EventApplicationSearchParams;
+import kr.ac.kookmin.stream.api.admin.event.event.response.AdminEventApplicationListResponse;
+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.openapi.ApiErrorCode;
+import kr.ac.kookmin.stream.common.CommonErrorCode;
+import kr.ac.kookmin.stream.event.domain.event.domain.EventErrorCode;
+import org.springdoc.core.annotations.ParameterObject;
+
+/**
+ * 운영진 행사 신청 현황 API의 문서 명세. 구현은 {@link AdminEventApplicationController}가 맡는다.
+ *
+ * 접근은 {@code ADMIN} role이면 된다. 부서 제한은 두지 않았다.
+ */
+@Tag(name = "행사 신청 현황", description = "운영진 행사 신청자 목록·상세 조회")
+public interface AdminEventApplicationApi {
+
+ /** 신청 현황. 한 줄에 신청자 정보와 질문별 답변을 함께 내려준다. */
+ @Operation(summary = "행사 신청 현황 조회",
+ description = "행사의 신청을 신청 순으로 페이지 단위 조회한다. 취소된 신청도 함께 내려가며, keyword로 이름·학번을, "
+ + "status로 신청 상태를 거른다. 답변은 질문 순서대로 담기고, 선택형은 고른 선택지 문구를 쉼표로 잇는다. "
+ + "탈퇴한 회원의 신청은 이름·학번이 비어 있다.")
+ @ApiErrorCode(type = CommonErrorCode.class, codes = {"INVALID_INPUT"})
+ @ApiErrorCode(type = EventErrorCode.class, codes = {"EVENT_NOT_FOUND", "EVENT_INVALID_APPLICATION_STATUS"})
+ ApiResponse getApplications(
+ AdminApiUser apiUser,
+ Long eventId,
+ @ParameterObject EventApplicationSearchParams params,
+ @ParameterObject PageParams pageParams
+ );
+}
diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/AdminEventApplicationController.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/AdminEventApplicationController.java
new file mode 100644
index 00000000..b1b31da9
--- /dev/null
+++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/AdminEventApplicationController.java
@@ -0,0 +1,35 @@
+package kr.ac.kookmin.stream.api.admin.event.event;
+
+import jakarta.validation.Valid;
+import kr.ac.kookmin.stream.api.admin.AdminApiUser;
+import kr.ac.kookmin.stream.api.admin.event.event.request.EventApplicationSearchParams;
+import kr.ac.kookmin.stream.api.admin.event.event.response.AdminEventApplicationListResponse;
+import kr.ac.kookmin.stream.api.admin.event.event.usecase.AdminEventApplicationQueryUseCase;
+import kr.ac.kookmin.stream.api.common.dto.ApiResponse;
+import kr.ac.kookmin.stream.api.common.dto.PageParams;
+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.RequestMapping;
+import org.springframework.web.bind.annotation.RestController;
+
+@RestController
+@RequestMapping("/v1/admin/events/{eventId}/applications")
+@RequiredArgsConstructor
+public class AdminEventApplicationController implements AdminEventApplicationApi {
+
+ private final AdminEventApplicationQueryUseCase adminEventApplicationQueryUseCase;
+
+ @Override
+ @GetMapping
+ public ApiResponse getApplications(
+ AdminApiUser apiUser,
+ @PathVariable Long eventId,
+ @Valid @ModelAttribute EventApplicationSearchParams params,
+ @Valid @ModelAttribute PageParams pageParams
+ ) {
+ return ApiResponse.success(AdminEventApplicationListResponse.from(adminEventApplicationQueryUseCase.getApplicants(
+ eventId, params.toKeyword(), params.toStatus(), pageParams.toOffset())));
+ }
+}
diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/request/EventApplicationSearchParams.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/request/EventApplicationSearchParams.java
new file mode 100644
index 00000000..c999efba
--- /dev/null
+++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/request/EventApplicationSearchParams.java
@@ -0,0 +1,22 @@
+package kr.ac.kookmin.stream.api.admin.event.event.request;
+
+import io.swagger.v3.oas.annotations.media.Schema;
+import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationStatus;
+
+public record EventApplicationSearchParams(
+ @Schema(description = "이름 또는 학번 검색어", example = "김학생")
+ String keyword,
+
+ @Schema(description = "신청 상태 필터. APPLIED(신청완료) 또는 CANCELED(취소). 없으면 전체", example = "APPLIED")
+ String status
+) {
+
+ // 빈 검색어는 검색하지 않는 것으로 본다
+ public String toKeyword() {
+ return keyword == null || keyword.isBlank() ? null : keyword.strip();
+ }
+
+ public EventApplicationStatus toStatus() {
+ return EventApplicationStatus.from(status);
+ }
+}
diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/response/AdminEventApplicationListResponse.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/response/AdminEventApplicationListResponse.java
new file mode 100644
index 00000000..8f9c70ee
--- /dev/null
+++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/response/AdminEventApplicationListResponse.java
@@ -0,0 +1,113 @@
+package kr.ac.kookmin.stream.api.admin.event.event.response;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Function;
+import java.util.stream.Collectors;
+import kr.ac.kookmin.stream.api.admin.event.event.usecase.EventApplicant;
+import kr.ac.kookmin.stream.api.admin.event.event.usecase.EventApplicantPage;
+import kr.ac.kookmin.stream.api.common.dto.PageResponse;
+import kr.ac.kookmin.stream.event.domain.event.domain.Event;
+import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationAnswer;
+import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationStatus;
+import kr.ac.kookmin.stream.event.domain.event.domain.EventQuestion;
+import kr.ac.kookmin.stream.member.domain.member.domain.Member;
+
+/**
+ * 신청 현황 화면. 질문은 답변 열의 헤더이고, 신청 한 줄의 답변은 같은 순서로 담는다.
+ *
+ * @param totalApplicationCount 취소를 포함한 전체 신청 수. 검색·필터와 상관없다
+ * @param appliedCount 신청완료 수. 검색·필터와 상관없다
+ */
+public record AdminEventApplicationListResponse(
+ Long eventId,
+ String eventTitle,
+ long totalApplicationCount,
+ long appliedCount,
+ List questions,
+ PageResponse applications
+) {
+
+ public static AdminEventApplicationListResponse from(EventApplicantPage page) {
+ Event event = page.event().event();
+ List questions = page.event().questions();
+ return new AdminEventApplicationListResponse(
+ event.getId(),
+ event.getTitle(),
+ page.totalApplicationCount(),
+ page.event().applicantCount(),
+ questions.stream().map(Question::from).toList(),
+ PageResponse.from(page.applicants(), applicant -> Application.of(applicant, questions))
+ );
+ }
+
+ public record Question(Long questionId, String questionText) {
+
+ public static Question from(EventQuestion question) {
+ return new Question(question.getId(), question.getQuestionText());
+ }
+ }
+
+ /**
+ * @param name 탈퇴한 회원의 신청이면 {@code null}
+ * @param studentId 탈퇴한 회원의 신청이면 {@code null}
+ * @param answers {@code questions}와 같은 순서
+ */
+ public record Application(
+ Long applicationId,
+ String name,
+ String studentId,
+ LocalDateTime appliedAt,
+ EventApplicationStatus status,
+ List answers
+ ) {
+
+ public static Application of(EventApplicant applicant, List questions) {
+ Member member = applicant.member();
+ Map answerByQuestionId = applicant.answers().stream()
+ .collect(Collectors.toMap(
+ EventApplicationAnswer::getEventQuestionId, Function.identity(), (first, second) -> first));
+
+ return new Application(
+ applicant.application().getId(),
+ member == null ? null : member.getName(),
+ member == null ? null : member.getStudentId(),
+ applicant.application().getAppliedAt(),
+ applicant.application().getStatus(),
+ questions.stream()
+ .map(question -> Answer.of(question, answerByQuestionId.get(question.getId())))
+ .toList()
+ );
+ }
+ }
+
+ /**
+ * @param displayText 표에 보일 답변. 답하지 않았으면 {@code null}
+ */
+ public record Answer(Long questionId, String displayText) {
+
+ /**
+ * 텍스트형은 입력한 답을, 선택형은 고른 선택지 문구를 쉼표로 이어 보여준다.
+ *
+ * @param answer 이 질문에 대한 답변. 답하지 않았으면 {@code null}
+ */
+ public static Answer of(EventQuestion question, EventApplicationAnswer answer) {
+ return new Answer(question.getId(), answer == null ? null : displayTextOf(question, answer));
+ }
+
+ private static String displayTextOf(EventQuestion question, EventApplicationAnswer answer) {
+ if (!question.getQuestionType().isChoice()) {
+ return answer.getAnswerText();
+ }
+ List selected = answer.getSelectedOptions() == null ? List.of() : answer.getSelectedOptions();
+ List options = question.getOptions() == null ? List.of() : question.getOptions();
+ // 선택지가 바뀌어 범위를 벗어난 인덱스는 건너뛴다. 신청이 있으면 질문을 못 바꾸지만 과거 데이터에 대비한다
+ String text = selected.stream()
+ .filter(index -> index >= 0 && index < options.size())
+ .map(options::get)
+ .collect(Collectors.joining(", "));
+ return text.isEmpty() ? null : text;
+ }
+ }
+}
diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/usecase/AdminEventApplicationQueryUseCase.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/usecase/AdminEventApplicationQueryUseCase.java
new file mode 100644
index 00000000..81f1ccc6
--- /dev/null
+++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/usecase/AdminEventApplicationQueryUseCase.java
@@ -0,0 +1,74 @@
+package kr.ac.kookmin.stream.api.admin.event.event.usecase;
+
+import java.util.List;
+import java.util.Map;
+import kr.ac.kookmin.stream.common.PageOffset;
+import kr.ac.kookmin.stream.common.PageResult;
+import kr.ac.kookmin.stream.event.domain.event.domain.EventApplication;
+import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationAnswer;
+import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationStatus;
+import kr.ac.kookmin.stream.event.domain.event.domain.EventManageDetail;
+import kr.ac.kookmin.stream.event.domain.event.service.EventApplicationService;
+import kr.ac.kookmin.stream.event.domain.event.service.EventService;
+import kr.ac.kookmin.stream.member.domain.member.domain.Member;
+import kr.ac.kookmin.stream.member.domain.member.service.MemberService;
+import lombok.RequiredArgsConstructor;
+import org.springframework.stereotype.Component;
+
+/**
+ * 운영진 신청 현황. 신청은 행사(event) 도메인, 이름·학번은 회원(member) 도메인이라 이 UseCase가 조합한다.
+ * 조회만 하므로 트랜잭션으로 묶지 않는다.
+ */
+@Component
+@RequiredArgsConstructor
+public class AdminEventApplicationQueryUseCase {
+
+ private final EventService eventService;
+ private final EventApplicationService eventApplicationService;
+ private final MemberService memberService;
+
+ /**
+ * @param keyword 이름 또는 학번 검색어. {@code null}이면 검색하지 않는다
+ * @param status {@code null}이면 상태로 거르지 않는다
+ */
+ public EventApplicantPage getApplicants(
+ Long eventId,
+ String keyword,
+ EventApplicationStatus status,
+ PageOffset pageOffset
+ ) {
+ EventManageDetail event = eventService.getEvent(eventId);
+ long totalApplicationCount = eventApplicationService.countApplications(eventId);
+
+ PageResult applications = searchApplications(eventId, keyword, status, pageOffset);
+ List applicationIds = applications.content().stream().map(EventApplication::getId).toList();
+ Map> answers =
+ eventApplicationService.getAnswersByApplicationIds(applicationIds);
+ // 탈퇴한 회원은 결과에서 빠지므로 그 회원의 신청은 member가 null로 남는다
+ Map members = memberService.getMapByIds(
+ applications.content().stream().map(EventApplication::getMemberId).distinct().toList());
+
+ return new EventApplicantPage(event, totalApplicationCount, applications.map(application -> new EventApplicant(
+ application,
+ members.get(application.getMemberId()),
+ answers.getOrDefault(application.getId(), List.of())
+ )));
+ }
+
+ // 검색어에 맞는 회원이 없으면 신청을 조회할 필요도 없다
+ private PageResult searchApplications(
+ Long eventId,
+ String keyword,
+ EventApplicationStatus status,
+ PageOffset pageOffset
+ ) {
+ if (keyword == null) {
+ return eventApplicationService.getApplications(eventId, status, null, pageOffset);
+ }
+ List memberIds = memberService.searchIdsByKeyword(keyword);
+ if (memberIds.isEmpty()) {
+ return PageResult.empty(pageOffset.page(), pageOffset.size());
+ }
+ return eventApplicationService.getApplications(eventId, status, memberIds, pageOffset);
+ }
+}
diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/usecase/EventApplicant.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/usecase/EventApplicant.java
new file mode 100644
index 00000000..a846ba0a
--- /dev/null
+++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/usecase/EventApplicant.java
@@ -0,0 +1,16 @@
+package kr.ac.kookmin.stream.api.admin.event.event.usecase;
+
+import java.util.List;
+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.member.domain.member.domain.Member;
+
+/**
+ * 신청 현황 한 줄을 만들 재료. 신청(event)과 신청자(member)가 서로 다른 도메인이라 UseCase가 짝지어 담는다.
+ * 화면용 가공은 응답 DTO가 한다.
+ *
+ * @param member 탈퇴(소프트 삭제)한 회원의 신청이면 {@code null}
+ * @param answers 이 신청에 저장된 답변. 답하지 않은 질문은 빠져 있다
+ */
+public record EventApplicant(EventApplication application, Member member, List answers) {
+}
diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/usecase/EventApplicantPage.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/usecase/EventApplicantPage.java
new file mode 100644
index 00000000..6d907c50
--- /dev/null
+++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/usecase/EventApplicantPage.java
@@ -0,0 +1,17 @@
+package kr.ac.kookmin.stream.api.admin.event.event.usecase;
+
+import kr.ac.kookmin.stream.common.PageResult;
+import kr.ac.kookmin.stream.event.domain.event.domain.EventManageDetail;
+
+/**
+ * 신청 현황 화면을 만들 재료. 화면 제목·답변 열 헤더·신청 수는 행사 상세에서 가져온다.
+ *
+ * @param event 행사, 노출 순서대로 정렬된 질문, 유효(APPLIED) 신청자 수
+ * @param totalApplicationCount 취소를 포함한 전체 신청 수. 검색·필터와 상관없다
+ */
+public record EventApplicantPage(
+ EventManageDetail event,
+ long totalApplicationCount,
+ PageResult applicants
+) {
+}
diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventApplicationStatus.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventApplicationStatus.java
index 373847eb..1fcfa2fb 100644
--- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventApplicationStatus.java
+++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventApplicationStatus.java
@@ -1,6 +1,23 @@
package kr.ac.kookmin.stream.event.domain.event.domain;
+import kr.ac.kookmin.stream.common.BusinessException;
+
public enum EventApplicationStatus {
APPLIED,
- CANCELED
+ CANCELED;
+
+ /**
+ * 신청 현황 필터로 들어온 문자열을 신청 상태로 바꾼다. 값이 없으면 필터를 걸지 않는다는 뜻이라 null을 돌려준다.
+ * 잘못된 값에 500이 나가지 않도록 여기서 걸러 BusinessException으로 바꾼다.
+ */
+ public static EventApplicationStatus from(String value) {
+ if (value == null) {
+ return null;
+ }
+ try {
+ return EventApplicationStatus.valueOf(value);
+ } catch (IllegalArgumentException e) {
+ throw new BusinessException(EventErrorCode.EVENT_INVALID_APPLICATION_STATUS);
+ }
+ }
}
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 f978529e..2e97b22e 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
@@ -21,6 +21,7 @@ public enum EventErrorCode implements ErrorCode {
INVALID_ANSWER(ErrorStatus.BAD_REQUEST, "신청서 답변 형식이 올바르지 않습니다."),
EVENT_INVALID_CURSOR(ErrorStatus.BAD_REQUEST, "유효하지 않은 커서입니다."),
EVENT_INVALID_RECRUIT_STATUS(ErrorStatus.BAD_REQUEST, "유효하지 않은 모집 상태입니다."),
+ EVENT_INVALID_APPLICATION_STATUS(ErrorStatus.BAD_REQUEST, "유효하지 않은 신청 상태입니다."),
EVENT_INVALID_PERIOD(ErrorStatus.BAD_REQUEST, "종료 일시는 시작 일시보다 늦어야 합니다."),
EVENT_INVALID_CAPACITY(ErrorStatus.BAD_REQUEST, "선착순 모집은 1명 이상의 정원을 입력해야 합니다."),
EVENT_INVALID_QUESTION(ErrorStatus.BAD_REQUEST, "선택형 질문에는 선택지를 1개 이상 입력해야 합니다."),
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 aa5ed43a..b50072ab 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
@@ -10,6 +10,7 @@
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.EventApplicationStatus;
import kr.ac.kookmin.stream.event.domain.event.domain.EventCursor;
import kr.ac.kookmin.stream.event.domain.event.domain.EventQuestion;
import kr.ac.kookmin.stream.event.domain.event.domain.RecruitStatus;
@@ -107,4 +108,23 @@ List findPublishedSlice(
Optional findApplicationByIdAndMemberId(Long applicationId, Long memberId);
List findAnswersByApplicationId(Long applicationId);
+
+ /**
+ * 한 행사의 신청을 신청 시각 오름차순(동일 시각은 applicationId 오름차순)으로 페이지 조회한다. 취소된 신청도 담는다.
+ *
+ * @param status {@code null}이면 상태로 거르지 않는다
+ * @param memberIds {@code null}이면 신청자로 거르지 않는다
+ */
+ PageResult findApplicationPage(
+ Long eventId,
+ EventApplicationStatus status,
+ List memberIds,
+ PageOffset pageOffset
+ );
+
+ /** 여러 신청의 답변을 한 번에 조회한다. 신청마다 따로 읽으면 페이지 크기만큼 쿼리가 더 나간다. */
+ List findAnswersByApplicationIds(List applicationIds);
+
+ /** 한 행사의 신청 수. 취소된 신청도 센다. */
+ long countApplicationsByEventId(Long eventId);
}
diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/EventApplicationService.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/EventApplicationService.java
index 753b5125..794ad78e 100644
--- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/EventApplicationService.java
+++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/EventApplicationService.java
@@ -1,9 +1,16 @@
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.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.EventApplicationDetail;
import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationForm;
+import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationStatus;
import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationResult;
import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationSummary;
import kr.ac.kookmin.stream.event.domain.event.domain.EventApplyCommand;
@@ -24,4 +31,23 @@ CursorSliceResult getMyApplications(
EventApplicationDetail getMyApplication(Long applicationId, Long memberId);
void cancelApplication(Long applicationId, Long memberId);
+
+ /**
+ * 운영진 신청 현황. 한 행사의 신청을 신청 순으로 페이지 조회하며, 취소된 신청도 담는다.
+ *
+ * @param status {@code null}이면 상태로 거르지 않는다
+ * @param memberIds {@code null}이면 신청자로 거르지 않는다
+ */
+ PageResult getApplications(
+ Long eventId,
+ EventApplicationStatus status,
+ List memberIds,
+ PageOffset pageOffset
+ );
+
+ /** 신청별 답변. 답변을 하나도 저장하지 않은 신청은 결과에 담기지 않는다. */
+ Map> getAnswersByApplicationIds(List applicationIds);
+
+ /** 한 행사의 신청 수. 취소된 신청도 센다. */
+ long countApplications(Long eventId);
}
diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventApplicationServiceImpl.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventApplicationServiceImpl.java
index f77b88e0..94793a2c 100644
--- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventApplicationServiceImpl.java
+++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventApplicationServiceImpl.java
@@ -7,6 +7,8 @@
import java.util.stream.Collectors;
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.EventApplication;
import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationAnswer;
@@ -14,6 +16,7 @@
import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationDetail;
import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationForm;
import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationResult;
+import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationStatus;
import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationSummary;
import kr.ac.kookmin.stream.event.domain.event.domain.EventApplyCommand;
import kr.ac.kookmin.stream.event.domain.event.domain.EventErrorCode;
@@ -122,6 +125,33 @@ public void cancelApplication(Long applicationId, Long memberId) {
}
/** 남의 신청은 없는 것으로 보여야 하므로 조회 단계에서 회원 식별자로 함께 거른다. */
+ @Override
+ public PageResult getApplications(
+ Long eventId,
+ EventApplicationStatus status,
+ List memberIds,
+ PageOffset pageOffset
+ ) {
+ getEventOrThrow(eventId);
+ return eventRepository.findApplicationPage(eventId, status, memberIds, pageOffset);
+ }
+
+ @Override
+ public Map> getAnswersByApplicationIds(List applicationIds) {
+ return eventRepository.findAnswersByApplicationIds(applicationIds).stream()
+ .collect(Collectors.groupingBy(EventApplicationAnswer::getEventApplicationId));
+ }
+
+ @Override
+ public long countApplications(Long eventId) {
+ return eventRepository.countApplicationsByEventId(eventId);
+ }
+
+ private Event getEventOrThrow(Long eventId) {
+ return eventRepository.findById(eventId)
+ .orElseThrow(() -> new BusinessException(EventErrorCode.EVENT_NOT_FOUND));
+ }
+
private EventApplication getMyApplicationOrThrow(Long applicationId, Long memberId) {
return eventRepository.findApplicationByIdAndMemberId(applicationId, memberId)
.orElseThrow(() -> new BusinessException(EventErrorCode.APPLICATION_NOT_FOUND));
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
index 0139bb42..f84e78f2 100644
--- 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
@@ -21,6 +21,7 @@
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.EventApplicationStatus;
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;
@@ -516,5 +517,21 @@ public Optional findApplicationByIdAndMemberId(Long applicatio
public List findAnswersByApplicationId(Long applicationId) {
throw new UnsupportedOperationException();
}
+
+ @Override
+ public PageResult findApplicationPage(
+ Long eventId, EventApplicationStatus status, List memberIds, PageOffset pageOffset) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public List findAnswersByApplicationIds(List applicationIds) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public long countApplicationsByEventId(Long eventId) {
+ 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 cce40cf6..4aa2b8a7 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
@@ -16,6 +16,7 @@
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.EventApplicationStatus;
import kr.ac.kookmin.stream.event.domain.event.domain.EventCursor;
import kr.ac.kookmin.stream.event.domain.event.domain.EventQuestion;
import kr.ac.kookmin.stream.event.domain.event.domain.EventSummary;
@@ -250,5 +251,21 @@ public Optional findApplicationByIdAndMemberId(
public List findAnswersByApplicationId(Long applicationId) {
throw new UnsupportedOperationException();
}
+
+ @Override
+ public PageResult findApplicationPage(
+ Long eventId, EventApplicationStatus status, List memberIds, PageOffset pageOffset) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public List findAnswersByApplicationIds(List applicationIds) {
+ throw new UnsupportedOperationException();
+ }
+
+ @Override
+ public long countApplicationsByEventId(Long eventId) {
+ throw new UnsupportedOperationException();
+ }
}
}
diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventApplicationAnswerJpaRepository.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventApplicationAnswerJpaRepository.java
index ca9b05d2..91731be0 100644
--- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventApplicationAnswerJpaRepository.java
+++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventApplicationAnswerJpaRepository.java
@@ -7,4 +7,6 @@ public interface EventApplicationAnswerJpaRepository
extends JpaRepository {
List findAllByEventApplicationId(Long eventApplicationId);
+
+ List findAllByEventApplicationIdIn(List eventApplicationIds);
}
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 ff405012..8bdb2b9b 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
@@ -4,6 +4,7 @@
import java.util.List;
import java.util.Optional;
import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationStatus;
+import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
@@ -15,6 +16,31 @@ public interface EventApplicationJpaRepository extends JpaRepository searchByEventId(
+ @Param("eventId") Long eventId,
+ @Param("status") EventApplicationStatus status,
+ @Param("filterByMember") boolean filterByMember,
+ @Param("memberIds") List memberIds,
+ Pageable pageable
+ );
+
boolean existsByEventIdAndMemberIdAndStatus(Long eventId, Long memberId, EventApplicationStatus status);
Optional findByIdAndMemberId(Long id, Long memberId);
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 484ae4b9..350e122a 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
@@ -188,6 +188,42 @@ public List findAnswersByApplicationId(Long applicationI
.toList();
}
+ @Override
+ public PageResult findApplicationPage(
+ Long eventId,
+ EventApplicationStatus status,
+ List memberIds,
+ PageOffset pageOffset
+ ) {
+ boolean filterByMember = memberIds != null;
+ Page result = eventApplicationJpaRepository.searchByEventId(
+ eventId, status, filterByMember, filterByMember ? memberIds : List.of(),
+ PageRequest.of(pageOffset.page(), pageOffset.size())
+ );
+
+ return PageResult.of(
+ result.getContent().stream().map(EventApplicationJpaEntity::toDomain).toList(),
+ result.getNumber(),
+ result.getSize(),
+ result.getTotalElements()
+ );
+ }
+
+ @Override
+ public List findAnswersByApplicationIds(List applicationIds) {
+ if (applicationIds.isEmpty()) {
+ return List.of();
+ }
+ return eventApplicationAnswerJpaRepository.findAllByEventApplicationIdIn(applicationIds).stream()
+ .map(EventApplicationAnswerJpaEntity::toDomain)
+ .toList();
+ }
+
+ @Override
+ public long countApplicationsByEventId(Long eventId) {
+ return eventApplicationJpaRepository.countByEventId(eventId);
+ }
+
@Override
public List saveAnswers(List answers) {
List entities = answers.stream()
From 4f246b33e99e988306c11c662021a91c75b7950b Mon Sep 17 00:00:00 2001
From: gain <163835628+leegain1@users.noreply.github.com>
Date: Sun, 18 Oct 2026 01:03:31 +0900
Subject: [PATCH 4/8] =?UTF-8?q?feat:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?=
=?UTF-8?q?=ED=96=89=EC=82=AC=20=EC=8B=A0=EC=B2=AD=EC=9E=90=20=EC=83=81?=
=?UTF-8?q?=EC=84=B8=20=EC=A1=B0=ED=9A=8C=20API=20=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../event/event/AdminEventApplicationApi.java | 8 ++
.../AdminEventApplicationController.java | 12 +++
.../AdminEventApplicationDetailResponse.java | 73 +++++++++++++++++++
.../AdminEventApplicationQueryUseCase.java | 16 ++++
.../event/usecase/EventApplicantDetail.java | 13 ++++
.../event/repository/EventRepository.java | 3 +
.../service/EventApplicationService.java | 5 ++
.../impl/EventApplicationServiceImpl.java | 7 ++
.../impl/EventServiceImplManageTest.java | 5 ++
.../service/impl/EventServiceImplTest.java | 5 ++
.../event/EventApplicationJpaRepository.java | 2 +
.../stream/db/event/EventRepositoryImpl.java | 6 ++
12 files changed, 155 insertions(+)
create mode 100644 api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/response/AdminEventApplicationDetailResponse.java
create mode 100644 api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/usecase/EventApplicantDetail.java
diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/AdminEventApplicationApi.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/AdminEventApplicationApi.java
index 21b47a39..f491c94a 100644
--- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/AdminEventApplicationApi.java
+++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/AdminEventApplicationApi.java
@@ -4,6 +4,7 @@
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.EventApplicationSearchParams;
+import kr.ac.kookmin.stream.api.admin.event.event.response.AdminEventApplicationDetailResponse;
import kr.ac.kookmin.stream.api.admin.event.event.response.AdminEventApplicationListResponse;
import kr.ac.kookmin.stream.api.common.dto.ApiResponse;
import kr.ac.kookmin.stream.api.common.dto.PageParams;
@@ -33,4 +34,11 @@ ApiResponse getApplications(
@ParameterObject EventApplicationSearchParams params,
@ParameterObject PageParams pageParams
);
+
+ /** 신청자 상세. 표에서 잘린 답변을 질문 전체와 함께 확인한다. */
+ @Operation(summary = "행사 신청자 상세 조회",
+ description = "신청 한 건의 신청자 정보와, 행사 질문 전체에 이 신청의 답변을 붙여 조회한다. 답하지 않은 질문도 "
+ + "빈 답변으로 함께 내려간다. 다른 행사의 신청이면 찾을 수 없다.")
+ @ApiErrorCode(type = EventErrorCode.class, codes = {"EVENT_NOT_FOUND", "APPLICATION_NOT_FOUND"})
+ ApiResponse getApplication(AdminApiUser apiUser, Long eventId, Long applicationId);
}
diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/AdminEventApplicationController.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/AdminEventApplicationController.java
index b1b31da9..23e9b8f1 100644
--- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/AdminEventApplicationController.java
+++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/AdminEventApplicationController.java
@@ -3,6 +3,7 @@
import jakarta.validation.Valid;
import kr.ac.kookmin.stream.api.admin.AdminApiUser;
import kr.ac.kookmin.stream.api.admin.event.event.request.EventApplicationSearchParams;
+import kr.ac.kookmin.stream.api.admin.event.event.response.AdminEventApplicationDetailResponse;
import kr.ac.kookmin.stream.api.admin.event.event.response.AdminEventApplicationListResponse;
import kr.ac.kookmin.stream.api.admin.event.event.usecase.AdminEventApplicationQueryUseCase;
import kr.ac.kookmin.stream.api.common.dto.ApiResponse;
@@ -32,4 +33,15 @@ public ApiResponse getApplications(
return ApiResponse.success(AdminEventApplicationListResponse.from(adminEventApplicationQueryUseCase.getApplicants(
eventId, params.toKeyword(), params.toStatus(), pageParams.toOffset())));
}
+
+ @Override
+ @GetMapping("/{applicationId}")
+ public ApiResponse getApplication(
+ AdminApiUser apiUser,
+ @PathVariable Long eventId,
+ @PathVariable Long applicationId
+ ) {
+ return ApiResponse.success(AdminEventApplicationDetailResponse.from(
+ adminEventApplicationQueryUseCase.getApplicant(eventId, applicationId)));
+ }
}
diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/response/AdminEventApplicationDetailResponse.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/response/AdminEventApplicationDetailResponse.java
new file mode 100644
index 00000000..f020c1b7
--- /dev/null
+++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/response/AdminEventApplicationDetailResponse.java
@@ -0,0 +1,73 @@
+package kr.ac.kookmin.stream.api.admin.event.event.response;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import kr.ac.kookmin.stream.api.admin.event.event.usecase.EventApplicantDetail;
+import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationDetail;
+import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationStatus;
+import kr.ac.kookmin.stream.event.domain.event.domain.EventQuestion;
+import kr.ac.kookmin.stream.event.domain.event.domain.QuestionType;
+import kr.ac.kookmin.stream.member.domain.member.domain.Member;
+
+/**
+ * 신청자 상세(폼 조회). 신청 현황 표에서 잘린 답변을 질문 전체와 함께 보여준다.
+ *
+ * @param name 탈퇴한 회원의 신청이면 {@code null}
+ * @param studentId 탈퇴한 회원의 신청이면 {@code null}
+ */
+public record AdminEventApplicationDetailResponse(
+ Long applicationId,
+ String name,
+ String studentId,
+ LocalDateTime appliedAt,
+ EventApplicationStatus status,
+ LocalDateTime canceledAt,
+ List questions
+) {
+
+ public static AdminEventApplicationDetailResponse from(EventApplicantDetail applicant) {
+ Member member = applicant.member();
+ EventApplicationDetail detail = applicant.detail();
+ return new AdminEventApplicationDetailResponse(
+ applicant.application().getId(),
+ member == null ? null : member.getName(),
+ member == null ? null : member.getStudentId(),
+ detail.appliedAt(),
+ detail.applicationStatus(),
+ detail.canceledAt(),
+ detail.questions().stream().map(AnsweredQuestion::from).toList()
+ );
+ }
+
+ /**
+ * 질문 한 건과 이 신청의 답변. 학생 앱 내 신청 상세와 같은 모양이다.
+ *
+ * @param answerText 텍스트형 답변. 선택형이거나 답하지 않았으면 {@code null}
+ * @param selectedOptions 고른 선택지의 0부터 시작하는 순서 번호. 텍스트형이거나 답하지 않았으면 빈 배열
+ */
+ public record AnsweredQuestion(
+ Long questionId,
+ String questionText,
+ QuestionType questionType,
+ boolean isRequired,
+ int displayOrder,
+ List options,
+ String answerText,
+ List selectedOptions
+ ) {
+
+ public static AnsweredQuestion from(EventApplicationDetail.AnsweredQuestion answeredQuestion) {
+ EventQuestion question = answeredQuestion.question();
+ return new AnsweredQuestion(
+ question.getId(),
+ question.getQuestionText(),
+ question.getQuestionType(),
+ question.isRequired(),
+ question.getDisplayOrder(),
+ question.getOptions() == null ? List.of() : question.getOptions(),
+ answeredQuestion.answerText(),
+ answeredQuestion.selectedOptions()
+ );
+ }
+ }
+}
diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/usecase/AdminEventApplicationQueryUseCase.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/usecase/AdminEventApplicationQueryUseCase.java
index 81f1ccc6..d686d866 100644
--- a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/usecase/AdminEventApplicationQueryUseCase.java
+++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/usecase/AdminEventApplicationQueryUseCase.java
@@ -6,6 +6,7 @@
import kr.ac.kookmin.stream.common.PageResult;
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.EventApplicationDetail;
import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationStatus;
import kr.ac.kookmin.stream.event.domain.event.domain.EventManageDetail;
import kr.ac.kookmin.stream.event.domain.event.service.EventApplicationService;
@@ -55,6 +56,21 @@ public EventApplicantPage getApplicants(
)));
}
+ public EventApplicantDetail getApplicant(Long eventId, Long applicationId) {
+ EventManageDetail event = eventService.getEvent(eventId);
+ EventApplication application = eventApplicationService.getApplication(eventId, applicationId);
+ List answers = eventApplicationService
+ .getAnswersByApplicationIds(List.of(applicationId))
+ .getOrDefault(applicationId, List.of());
+ Member member = memberService.getMapByIds(List.of(application.getMemberId())).get(application.getMemberId());
+
+ return new EventApplicantDetail(
+ application,
+ member,
+ EventApplicationDetail.of(event.event(), application, event.questions(), answers)
+ );
+ }
+
// 검색어에 맞는 회원이 없으면 신청을 조회할 필요도 없다
private PageResult searchApplications(
Long eventId,
diff --git a/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/usecase/EventApplicantDetail.java b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/usecase/EventApplicantDetail.java
new file mode 100644
index 00000000..d073796e
--- /dev/null
+++ b/api/admin-api/src/main/java/kr/ac/kookmin/stream/api/admin/event/event/usecase/EventApplicantDetail.java
@@ -0,0 +1,13 @@
+package kr.ac.kookmin.stream.api.admin.event.event.usecase;
+
+import kr.ac.kookmin.stream.event.domain.event.domain.EventApplication;
+import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationDetail;
+import kr.ac.kookmin.stream.member.domain.member.domain.Member;
+
+/**
+ * 신청자 상세를 만들 재료. 질문과 답변 짝짓기는 학생 앱 내 신청 상세와 같은 {@link EventApplicationDetail}을 쓴다.
+ *
+ * @param member 탈퇴(소프트 삭제)한 회원의 신청이면 {@code null}
+ */
+public record EventApplicantDetail(EventApplication application, Member member, EventApplicationDetail detail) {
+}
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 b50072ab..7e57f957 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
@@ -125,6 +125,9 @@ PageResult findApplicationPage(
/** 여러 신청의 답변을 한 번에 조회한다. 신청마다 따로 읽으면 페이지 크기만큼 쿼리가 더 나간다. */
List findAnswersByApplicationIds(List applicationIds);
+ /** 한 행사에 속한 신청 한 건을 조회한다. 다른 행사의 신청은 없는 것으로 본다. */
+ Optional findApplicationByIdAndEventId(Long applicationId, Long eventId);
+
/** 한 행사의 신청 수. 취소된 신청도 센다. */
long countApplicationsByEventId(Long eventId);
}
diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/EventApplicationService.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/EventApplicationService.java
index 794ad78e..8fc37a9a 100644
--- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/EventApplicationService.java
+++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/EventApplicationService.java
@@ -50,4 +50,9 @@ PageResult getApplications(
/** 한 행사의 신청 수. 취소된 신청도 센다. */
long countApplications(Long eventId);
+
+ /**
+ * 운영진 신청자 상세. 그 행사에 속한 신청만 조회한다.
+ */
+ EventApplication getApplication(Long eventId, Long applicationId);
}
diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventApplicationServiceImpl.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventApplicationServiceImpl.java
index 94793a2c..6a31a5ee 100644
--- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventApplicationServiceImpl.java
+++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventApplicationServiceImpl.java
@@ -147,6 +147,13 @@ public long countApplications(Long eventId) {
return eventRepository.countApplicationsByEventId(eventId);
}
+ @Override
+ public EventApplication getApplication(Long eventId, Long applicationId) {
+ getEventOrThrow(eventId);
+ return eventRepository.findApplicationByIdAndEventId(applicationId, eventId)
+ .orElseThrow(() -> new BusinessException(EventErrorCode.APPLICATION_NOT_FOUND));
+ }
+
private Event getEventOrThrow(Long eventId) {
return eventRepository.findById(eventId)
.orElseThrow(() -> new BusinessException(EventErrorCode.EVENT_NOT_FOUND));
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
index f84e78f2..e46cb164 100644
--- 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
@@ -529,6 +529,11 @@ public List findAnswersByApplicationIds(List appli
throw new UnsupportedOperationException();
}
+ @Override
+ public Optional findApplicationByIdAndEventId(Long applicationId, Long eventId) {
+ throw new UnsupportedOperationException();
+ }
+
@Override
public long countApplicationsByEventId(Long eventId) {
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 4aa2b8a7..53e58d82 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
@@ -263,6 +263,11 @@ public List findAnswersByApplicationIds(List appli
throw new UnsupportedOperationException();
}
+ @Override
+ public Optional findApplicationByIdAndEventId(Long applicationId, Long eventId) {
+ throw new UnsupportedOperationException();
+ }
+
@Override
public long countApplicationsByEventId(Long eventId) {
throw new UnsupportedOperationException();
diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventApplicationJpaRepository.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventApplicationJpaRepository.java
index 8bdb2b9b..f560d9ca 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
@@ -45,6 +45,8 @@ Page searchByEventId(
Optional findByIdAndMemberId(Long id, Long memberId);
+ Optional findByIdAndEventId(Long id, Long eventId);
+
/**
* 한 회원의 신청 내역을 (applied_at, event_application_id) keyset 커서로 최신순 조회한다.
* 취소된 신청도 이력으로 함께 내려간다. 삭제된 행사의 신청은 신청 내역과 함께 지운 것으로 보고 제외한다.
diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventRepositoryImpl.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventRepositoryImpl.java
index 350e122a..7201d6d1 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
@@ -219,6 +219,12 @@ public List findAnswersByApplicationIds(List appli
.toList();
}
+ @Override
+ public Optional findApplicationByIdAndEventId(Long applicationId, Long eventId) {
+ return eventApplicationJpaRepository.findByIdAndEventId(applicationId, eventId)
+ .map(EventApplicationJpaEntity::toDomain);
+ }
+
@Override
public long countApplicationsByEventId(Long eventId) {
return eventApplicationJpaRepository.countByEventId(eventId);
From e3665513b173925bc8aecec0a766474d26bfd9da Mon Sep 17 00:00:00 2001
From: gain <163835628+leegain1@users.noreply.github.com>
Date: Sun, 18 Oct 2026 01:05:59 +0900
Subject: [PATCH 5/8] =?UTF-8?q?test:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?=
=?UTF-8?q?=ED=96=89=EC=82=AC=20=EA=B3=B5=EA=B0=9C=C2=B7=EC=82=AD=EC=A0=9C?=
=?UTF-8?q?=C2=B7=EC=8B=A0=EC=B2=AD=20=ED=98=84=ED=99=A9=20=EB=8B=A8?=
=?UTF-8?q?=EC=9C=84=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EC=B6=94=EA=B0=80?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
...AdminEventApplicationListResponseTest.java | 149 ++++++++++++++
.../domain/EventApplicationStatusTest.java | 37 ++++
.../event/domain/event/domain/EventTest.java | 16 ++
...EventApplicationServiceImplManageTest.java | 163 +++++++++++++++
.../impl/EventServiceImplManageTest.java | 180 ++++------------
.../service/impl/InMemoryEventRepository.java | 194 ++++++++++++++++++
6 files changed, 600 insertions(+), 139 deletions(-)
create mode 100644 api/admin-api/src/test/java/kr/ac/kookmin/stream/api/admin/event/event/response/AdminEventApplicationListResponseTest.java
create mode 100644 core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventApplicationStatusTest.java
create mode 100644 core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventApplicationServiceImplManageTest.java
create mode 100644 core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/InMemoryEventRepository.java
diff --git a/api/admin-api/src/test/java/kr/ac/kookmin/stream/api/admin/event/event/response/AdminEventApplicationListResponseTest.java b/api/admin-api/src/test/java/kr/ac/kookmin/stream/api/admin/event/event/response/AdminEventApplicationListResponseTest.java
new file mode 100644
index 00000000..ca20e5a0
--- /dev/null
+++ b/api/admin-api/src/test/java/kr/ac/kookmin/stream/api/admin/event/event/response/AdminEventApplicationListResponseTest.java
@@ -0,0 +1,149 @@
+package kr.ac.kookmin.stream.api.admin.event.event.response;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+
+import java.time.LocalDateTime;
+import java.util.Arrays;
+import java.util.List;
+import kr.ac.kookmin.stream.api.admin.event.event.usecase.EventApplicant;
+import kr.ac.kookmin.stream.api.admin.event.event.usecase.EventApplicantPage;
+import kr.ac.kookmin.stream.common.PageResult;
+import kr.ac.kookmin.stream.common.Role;
+import kr.ac.kookmin.stream.event.domain.event.domain.Event;
+import kr.ac.kookmin.stream.event.domain.event.domain.EventApplication;
+import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationAnswer;
+import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationStatus;
+import kr.ac.kookmin.stream.event.domain.event.domain.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.RecruitType;
+import kr.ac.kookmin.stream.member.domain.member.domain.Department;
+import kr.ac.kookmin.stream.member.domain.member.domain.Member;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+/**
+ * 신청 현황 응답 조립. 특히 표에 보일 답변 문구(displayText)를 질문 유형별로 맞게 만드는지 본다.
+ */
+class AdminEventApplicationListResponseTest {
+
+ private static final LocalDateTime APPLIED_AT = LocalDateTime.of(2026, 10, 3, 14, 22, 7);
+
+ private static final EventQuestion PHONE = EventQuestion.of(
+ 201L, 15L, "전화번호", QuestionType.SHORT_TEXT, true, 1, List.of(), false);
+ private static final EventQuestion ATTEND = EventQuestion.of(
+ 202L, 15L, "참석 여부", QuestionType.SINGLE_CHOICE, true, 2, List.of("참석", "불참"), false);
+ private static final EventQuestion SPORTS = EventQuestion.of(
+ 203L, 15L, "참여 종목", QuestionType.MULTIPLE_CHOICE, false, 3, List.of("축구", "농구", "피구"), false);
+
+ private static final Event EVENT = Event.of(
+ 15L, "2026학년도 새내기 배움터", "설명", "미래관", APPLIED_AT.plusDays(30), APPLIED_AT.plusDays(31),
+ APPLIED_AT.minusDays(7), APPLIED_AT.plusDays(10), RecruitType.FIRST_COME, List.of(), 200,
+ false, true, 1L, APPLIED_AT
+ );
+
+ private static Member member(String name, String studentId) {
+ return Member.of(101L, studentId, name, Department.SW, null, null, null, null, Role.STUDENT, null);
+ }
+
+ private static EventApplicant applicant(Member member, List answers) {
+ EventApplication application =
+ EventApplication.of(11L, 15L, 101L, EventApplicationStatus.APPLIED, APPLIED_AT, null);
+ return new EventApplicant(application, member, answers);
+ }
+
+ private static AdminEventApplicationListResponse responseOf(EventApplicant applicant) {
+ EventApplicantPage page = new EventApplicantPage(
+ new EventManageDetail(EVENT, List.of(PHONE, ATTEND, SPORTS), 2),
+ 3,
+ PageResult.of(List.of(applicant), 0, 20, 1)
+ );
+ return AdminEventApplicationListResponse.from(page);
+ }
+
+ private static List displayTextsOf(AdminEventApplicationListResponse response) {
+ return response.applications().content().getFirst().answers().stream()
+ .map(AdminEventApplicationListResponse.Answer::displayText)
+ .toList();
+ }
+
+ @Test
+ @DisplayName("화면 제목·신청 수·답변 열 헤더를 행사에서 가져온다")
+ void header() {
+ AdminEventApplicationListResponse response = responseOf(applicant(member("김학생", "20261234"), List.of()));
+
+ assertEquals(15L, response.eventId());
+ assertEquals("2026학년도 새내기 배움터", response.eventTitle());
+ assertEquals(3, response.totalApplicationCount());
+ assertEquals(2, response.appliedCount());
+ assertEquals(List.of("전화번호", "참석 여부", "참여 종목"),
+ response.questions().stream().map(AdminEventApplicationListResponse.Question::questionText).toList());
+ }
+
+ @Test
+ @DisplayName("신청자 정보를 담는다")
+ void applicantInfo() {
+ AdminEventApplicationListResponse.Application row =
+ responseOf(applicant(member("김학생", "20261234"), List.of())).applications().content().getFirst();
+
+ assertEquals(11L, row.applicationId());
+ assertEquals("김학생", row.name());
+ assertEquals("20261234", row.studentId());
+ assertEquals(APPLIED_AT, row.appliedAt());
+ assertEquals(EventApplicationStatus.APPLIED, row.status());
+ }
+
+ @Test
+ @DisplayName("탈퇴한 회원의 신청은 이름·학번을 비운다")
+ void withdrawnMember() {
+ AdminEventApplicationListResponse.Application row =
+ responseOf(applicant(null, List.of())).applications().content().getFirst();
+
+ assertNull(row.name());
+ assertNull(row.studentId());
+ }
+
+ @Nested
+ @DisplayName("표에 보일 답변 문구")
+ class DisplayText {
+
+ @Test
+ @DisplayName("질문 순서대로, 텍스트형은 입력한 답을, 선택형은 고른 선택지 문구를 쉼표로 잇는다")
+ void formatsByQuestionType() {
+ List answers = List.of(
+ // 저장 순서가 질문 순서와 달라도 질문 순서로 맞춘다
+ EventApplicationAnswer.of(3L, 11L, 203L, null, List.of(0, 2)),
+ EventApplicationAnswer.of(1L, 11L, 201L, "010-1234-5678", null),
+ EventApplicationAnswer.of(2L, 11L, 202L, null, List.of(1))
+ );
+
+ assertEquals(List.of("010-1234-5678", "불참", "축구, 피구"),
+ displayTextsOf(responseOf(applicant(member("김학생", "20261234"), answers))));
+ }
+
+ @Test
+ @DisplayName("답하지 않은 질문은 null이다")
+ void unanswered() {
+ List answers = List.of(
+ EventApplicationAnswer.of(1L, 11L, 201L, "010-1234-5678", null));
+
+ assertEquals(Arrays.asList("010-1234-5678", null, null),
+ displayTextsOf(responseOf(applicant(member("김학생", "20261234"), answers))));
+ }
+
+ @Test
+ @DisplayName("범위를 벗어난 선택지 번호는 건너뛰고, 남는 게 없으면 null이다")
+ void skipsOutOfRangeOptions() {
+ List answers = List.of(
+ EventApplicationAnswer.of(2L, 11L, 202L, null, List.of(5)),
+ EventApplicationAnswer.of(3L, 11L, 203L, null, List.of(1, 9)));
+
+ List texts = displayTextsOf(responseOf(applicant(member("김학생", "20261234"), answers)));
+
+ assertNull(texts.get(1));
+ assertEquals("농구", texts.get(2));
+ }
+ }
+}
diff --git a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventApplicationStatusTest.java b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventApplicationStatusTest.java
new file mode 100644
index 00000000..78b4df81
--- /dev/null
+++ b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventApplicationStatusTest.java
@@ -0,0 +1,37 @@
+package kr.ac.kookmin.stream.event.domain.event.domain;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import kr.ac.kookmin.stream.common.BusinessException;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.EnumSource;
+import org.junit.jupiter.params.provider.ValueSource;
+
+class EventApplicationStatusTest {
+
+ @Test
+ @DisplayName("값이 없으면 필터를 걸지 않는다는 뜻이라 null을 돌려준다")
+ void nullMeansNoFilter() {
+ assertNull(EventApplicationStatus.from(null));
+ }
+
+ @ParameterizedTest
+ @DisplayName("정의된 이름은 그대로 바뀐다")
+ @EnumSource(EventApplicationStatus.class)
+ void parsesDefinedNames(EventApplicationStatus status) {
+ assertEquals(status, EventApplicationStatus.from(status.name()));
+ }
+
+ @ParameterizedTest
+ @DisplayName("정의되지 않은 값은 500이 아니라 400으로 걸러진다")
+ @ValueSource(strings = {"applied", " APPLIED", "CANCELLED", "1", "-"})
+ void rejectsUndefinedValues(String raw) {
+ BusinessException e = assertThrows(BusinessException.class, () -> EventApplicationStatus.from(raw));
+
+ assertEquals(EventErrorCode.EVENT_INVALID_APPLICATION_STATUS, e.getErrorCode());
+ }
+}
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 8c327aed..95cd4fb3 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
@@ -289,4 +289,20 @@ void openRecruitReturnsNull() {
assertNull(event(RecruitType.OPEN).capacityLimit());
}
}
+
+ @Nested
+ @DisplayName("공개 여부 변경")
+ class ChangePublished {
+
+ @Test
+ @DisplayName("공개 여부만 바꾼다")
+ void changesOnlyPublished() {
+ Event event = firstComeEvent();
+
+ event.changePublished(false);
+
+ assertFalse(event.isPublished());
+ assertEquals("2026 소융대 개강 행사", event.getTitle());
+ }
+ }
}
diff --git a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventApplicationServiceImplManageTest.java b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventApplicationServiceImplManageTest.java
new file mode 100644
index 00000000..e728e2cd
--- /dev/null
+++ b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventApplicationServiceImplManageTest.java
@@ -0,0 +1,163 @@
+package kr.ac.kookmin.stream.event.domain.event.service.impl;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+
+import java.time.LocalDateTime;
+import java.util.List;
+import java.util.Map;
+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.EventApplicationStatus;
+import kr.ac.kookmin.stream.event.domain.event.domain.EventErrorCode;
+import kr.ac.kookmin.stream.event.domain.event.domain.RecruitType;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+/**
+ * 운영진 신청 현황·신청자 상세 조회. 신청자 이름·학번 조합은 api 계층 UseCase가 맡으므로 여기서는 신청 조회 규칙만 본다.
+ */
+class EventApplicationServiceImplManageTest {
+
+ private static final Long EVENT_ID = 15L;
+ private static final Long OTHER_EVENT_ID = 16L;
+ private static final LocalDateTime APPLIED_AT = LocalDateTime.of(2026, 10, 3, 14, 0);
+
+ private final InMemoryEventRepository repository = new InMemoryEventRepository();
+ private final EventApplicationServiceImpl service =
+ new EventApplicationServiceImpl(repository, new EventApplyAnswerValidator());
+
+ private static Event event(Long id) {
+ return Event.of(
+ id, "새내기 배움터", "설명", "미래관", APPLIED_AT.plusDays(30), APPLIED_AT.plusDays(31),
+ APPLIED_AT.minusDays(7), APPLIED_AT.plusDays(10), RecruitType.FIRST_COME, List.of(), 200,
+ false, true, 1L, APPLIED_AT
+ );
+ }
+
+ private void givenApplication(Long id, Long eventId, Long memberId, EventApplicationStatus status,
+ LocalDateTime appliedAt) {
+ repository.applications.put(id, EventApplication.of(id, eventId, memberId, status, appliedAt,
+ status == EventApplicationStatus.CANCELED ? appliedAt.plusHours(1) : null));
+ }
+
+ private static List idsOf(PageResult page) {
+ return page.content().stream().map(EventApplication::getId).toList();
+ }
+
+ private static void assertRejected(EventErrorCode expected, Runnable action) {
+ BusinessException e = assertThrows(BusinessException.class, action::run);
+ assertEquals(expected, e.getErrorCode());
+ }
+
+ @BeforeEach
+ void setUp() {
+ repository.events.put(EVENT_ID, event(EVENT_ID));
+ repository.events.put(OTHER_EVENT_ID, event(OTHER_EVENT_ID));
+ // 신청 순서와 id 순서를 일부러 엇갈리게 둔다
+ givenApplication(11L, EVENT_ID, 101L, EventApplicationStatus.APPLIED, APPLIED_AT.plusDays(1));
+ givenApplication(12L, EVENT_ID, 102L, EventApplicationStatus.APPLIED, APPLIED_AT);
+ givenApplication(13L, EVENT_ID, 103L, EventApplicationStatus.CANCELED, APPLIED_AT.plusDays(2));
+ givenApplication(14L, OTHER_EVENT_ID, 101L, EventApplicationStatus.APPLIED, APPLIED_AT);
+ }
+
+ @Nested
+ @DisplayName("신청 현황")
+ class GetApplications {
+
+ @Test
+ @DisplayName("그 행사의 신청만, 신청 순으로, 취소된 신청까지 담는다")
+ void ordersByAppliedAtAndKeepsCanceled() {
+ PageResult page = service.getApplications(EVENT_ID, null, null, PageOffset.of(0, 20));
+
+ assertEquals(List.of(12L, 11L, 13L), idsOf(page));
+ assertEquals(3, page.totalCount());
+ }
+
+ @Test
+ @DisplayName("신청 상태로 거른다")
+ void filtersByStatus() {
+ assertEquals(List.of(12L, 11L), idsOf(service.getApplications(
+ EVENT_ID, EventApplicationStatus.APPLIED, null, PageOffset.of(0, 20))));
+ assertEquals(List.of(13L), idsOf(service.getApplications(
+ EVENT_ID, EventApplicationStatus.CANCELED, null, PageOffset.of(0, 20))));
+ }
+
+ @Test
+ @DisplayName("신청자(검색된 회원)로 거른다")
+ void filtersByMembers() {
+ assertEquals(List.of(11L, 13L), idsOf(service.getApplications(
+ EVENT_ID, null, List.of(101L, 103L), PageOffset.of(0, 20))));
+ }
+
+ @Test
+ @DisplayName("페이지 단위로 자른다")
+ void pages() {
+ PageResult secondPage =
+ service.getApplications(EVENT_ID, null, null, PageOffset.of(1, 2));
+
+ assertEquals(List.of(13L), idsOf(secondPage));
+ assertEquals(3, secondPage.totalCount());
+ assertEquals(2, secondPage.totalPage());
+ }
+
+ @Test
+ @DisplayName("없거나 삭제된 행사는 찾을 수 없다")
+ void eventNotFound() {
+ assertRejected(EventErrorCode.EVENT_NOT_FOUND,
+ () -> service.getApplications(99L, null, null, PageOffset.of(0, 20)));
+ }
+ }
+
+ @Test
+ @DisplayName("전체 신청 수는 취소된 신청도 센다")
+ void countsCanceledApplications() {
+ assertEquals(3, service.countApplications(EVENT_ID));
+ }
+
+ @Test
+ @DisplayName("답변은 신청별로 묶어 돌려주고, 답변이 없는 신청은 담지 않는다")
+ void groupsAnswersByApplication() {
+ repository.answers.add(EventApplicationAnswer.of(1L, 11L, 201L, "010-1234-5678", null));
+ repository.answers.add(EventApplicationAnswer.of(2L, 11L, 202L, null, List.of(0)));
+ repository.answers.add(EventApplicationAnswer.of(3L, 12L, 201L, "010-2345-6789", null));
+
+ Map> answers = service.getAnswersByApplicationIds(List.of(11L, 12L, 13L));
+
+ assertEquals(2, answers.get(11L).size());
+ assertEquals(1, answers.get(12L).size());
+ assertFalse(answers.containsKey(13L));
+ }
+
+ @Nested
+ @DisplayName("신청자 상세")
+ class GetApplication {
+
+ @Test
+ @DisplayName("그 행사의 신청을 돌려준다")
+ void returnsApplication() {
+ assertEquals(103L, service.getApplication(EVENT_ID, 13L).getMemberId());
+ }
+
+ @Test
+ @DisplayName("다른 행사의 신청이면 찾을 수 없다")
+ void otherEventsApplication() {
+ assertRejected(EventErrorCode.APPLICATION_NOT_FOUND, () -> service.getApplication(EVENT_ID, 14L));
+ }
+
+ @Test
+ @DisplayName("없거나 삭제된 행사는 신청보다 먼저 걸러진다")
+ void eventNotFound() {
+ repository.events.remove(EVENT_ID);
+
+ assertRejected(EventErrorCode.EVENT_NOT_FOUND, () -> service.getApplication(EVENT_ID, 11L));
+ }
+ }
+}
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
index e46cb164..fcd2a20c 100644
--- 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
@@ -10,20 +10,13 @@
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.EventApplicationStatus;
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;
@@ -32,7 +25,6 @@
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;
@@ -388,155 +380,65 @@ void detailApplicantCountFeedsRecruitStatus() {
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 boolean delete(Long id) {
- return events.remove(id) != null;
- }
-
- @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;
- }
+ @Nested
+ @DisplayName("공개 상태 변경")
+ class ChangePublished {
- @Override
- public List findAllByIds(List eventIds) {
- throw new UnsupportedOperationException();
- }
+ @Test
+ @DisplayName("공개 여부를 바꿔 저장하고 다른 내용은 그대로 둔다")
+ void changesOnlyPublished() {
+ repository.events.put(EVENT_ID, savedEvent(RecruitType.FIRST_COME, 100));
- @Override
- public Optional findPublishedById(Long id) {
- throw new UnsupportedOperationException();
- }
+ Event changed = service.changePublished(EVENT_ID, false);
- @Override
- public List findPublishedSlice(
- RecruitStatus recruitStatus, EventCursor cursor, int limit, LocalDateTime now) {
- throw new UnsupportedOperationException();
+ assertFalse(changed.isPublished());
+ assertFalse(repository.events.get(EVENT_ID).isPublished());
+ assertEquals("체육대회", repository.events.get(EVENT_ID).getTitle());
}
- @Override
- public boolean existsAppliedByEventIdAndMemberId(Long eventId, Long memberId) {
- throw new UnsupportedOperationException();
- }
+ @Test
+ @DisplayName("이미 그 상태여도 성공으로 본다")
+ void idempotent() {
+ repository.events.put(EVENT_ID, savedEvent(RecruitType.FIRST_COME, 100));
- @Override
- public EventApplication saveApplication(EventApplication application) {
- throw new UnsupportedOperationException();
+ assertTrue(service.changePublished(EVENT_ID, true).isPublished());
}
- @Override
- public List saveAnswers(List answers) {
- throw new UnsupportedOperationException();
+ @Test
+ @DisplayName("없거나 삭제된 행사는 찾을 수 없다")
+ void notFound() {
+ assertRejected(EventErrorCode.EVENT_NOT_FOUND, () -> service.changePublished(EVENT_ID, true));
}
+ }
- @Override
- public List findApplicationSlice(Long memberId, EventApplicationCursor cursor, int limit) {
- throw new UnsupportedOperationException();
- }
+ @Nested
+ @DisplayName("삭제")
+ class Delete {
- @Override
- public Optional findApplicationByIdAndMemberId(Long applicationId, Long memberId) {
- throw new UnsupportedOperationException();
- }
+ @Test
+ @DisplayName("행사를 지운다")
+ void deletes() {
+ repository.events.put(EVENT_ID, savedEvent(RecruitType.FIRST_COME, 100));
- @Override
- public List findAnswersByApplicationId(Long applicationId) {
- throw new UnsupportedOperationException();
- }
+ service.delete(EVENT_ID);
- @Override
- public PageResult findApplicationPage(
- Long eventId, EventApplicationStatus status, List memberIds, PageOffset pageOffset) {
- throw new UnsupportedOperationException();
+ assertTrue(repository.findById(EVENT_ID).isEmpty());
}
- @Override
- public List findAnswersByApplicationIds(List applicationIds) {
- throw new UnsupportedOperationException();
- }
+ @Test
+ @DisplayName("신청자가 있어도 지울 수 있다")
+ void deletesEvenWithApplications() {
+ repository.events.put(EVENT_ID, savedEvent(RecruitType.FIRST_COME, 100));
+ repository.applicationExists = true;
+ repository.appliedCounts.put(EVENT_ID, 12L);
- @Override
- public Optional findApplicationByIdAndEventId(Long applicationId, Long eventId) {
- throw new UnsupportedOperationException();
+ assertDoesNotThrow(() -> service.delete(EVENT_ID));
}
- @Override
- public long countApplicationsByEventId(Long eventId) {
- throw new UnsupportedOperationException();
+ @Test
+ @DisplayName("없거나 이미 삭제된 행사는 찾을 수 없다")
+ void notFound() {
+ assertRejected(EventErrorCode.EVENT_NOT_FOUND, () -> service.delete(EVENT_ID));
}
}
}
diff --git a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/InMemoryEventRepository.java b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/InMemoryEventRepository.java
new file mode 100644
index 00000000..923daef9
--- /dev/null
+++ b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/InMemoryEventRepository.java
@@ -0,0 +1,194 @@
+package kr.ac.kookmin.stream.event.domain.event.service.impl;
+
+import java.time.LocalDateTime;
+import java.util.ArrayList;
+import java.util.Comparator;
+import java.util.HashMap;
+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;
+import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationCursor;
+import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationStatus;
+import kr.ac.kookmin.stream.event.domain.event.domain.EventCursor;
+import kr.ac.kookmin.stream.event.domain.event.domain.EventQuestion;
+import kr.ac.kookmin.stream.event.domain.event.domain.RecruitStatus;
+import kr.ac.kookmin.stream.event.domain.event.repository.EventRepository;
+
+/**
+ * 운영진 행사 관리 테스트용 메모리 레포지토리. 운영진 기능이 쓰는 메서드만 흉내 내고,
+ * 학생 신청·목록 쪽 메서드는 이 테스트들이 건드리지 않으므로 막아 둔다.
+ */
+class InMemoryEventRepository implements EventRepository {
+
+ final Map events = new HashMap<>();
+ final Map> questions = new HashMap<>();
+ final Map appliedCounts = new HashMap<>();
+ final Map applications = new HashMap<>();
+ final List answers = new ArrayList<>();
+ /** 질문 잠금 판정용. 신청 데이터를 넣지 않고도 "신청 있음"을 만들 수 있게 따로 둔다 */
+ boolean applicationExists;
+ boolean questionsReplaced;
+ boolean updatedAtTouched;
+ String requestedKeyword;
+ 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 boolean delete(Long id) {
+ return events.remove(id) != null;
+ }
+
+ @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 PageResult findApplicationPage(
+ Long eventId,
+ EventApplicationStatus status,
+ List memberIds,
+ PageOffset pageOffset
+ ) {
+ List matched = applications.values().stream()
+ .filter(application -> application.getEventId().equals(eventId))
+ .filter(application -> status == null || application.getStatus() == status)
+ .filter(application -> memberIds == null || memberIds.contains(application.getMemberId()))
+ .sorted(Comparator.comparing(EventApplication::getAppliedAt).thenComparing(EventApplication::getId))
+ .toList();
+ List content = matched.stream()
+ .skip((long) pageOffset.page() * pageOffset.size())
+ .limit(pageOffset.size())
+ .toList();
+ return PageResult.of(content, pageOffset.page(), pageOffset.size(), matched.size());
+ }
+
+ @Override
+ public List findAnswersByApplicationIds(List applicationIds) {
+ return answers.stream()
+ .filter(answer -> applicationIds.contains(answer.getEventApplicationId()))
+ .toList();
+ }
+
+ @Override
+ public Optional findApplicationByIdAndEventId(Long applicationId, Long eventId) {
+ return Optional.ofNullable(applications.get(applicationId))
+ .filter(application -> application.getEventId().equals(eventId));
+ }
+
+ @Override
+ public long countApplicationsByEventId(Long eventId) {
+ return applications.values().stream().filter(application -> application.getEventId().equals(eventId)).count();
+ }
+
+ @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 saved) {
+ 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();
+ }
+}
From 55bbe55f6674d24059fc6d44fce836225b83c7ae Mon Sep 17 00:00:00 2001
From: gain <163835628+leegain1@users.noreply.github.com>
Date: Sun, 18 Oct 2026 01:20:42 +0900
Subject: [PATCH 6/8] =?UTF-8?q?refactor:=20=ED=96=89=EC=82=AC=20=EC=8B=A0?=
=?UTF-8?q?=EC=B2=AD=20=EC=84=9C=EB=B9=84=EC=8A=A4=EC=9D=98=20=ED=96=89?=
=?UTF-8?q?=EC=82=AC=20=EC=A1=B0=ED=9A=8C=EB=A5=BC=20getEventOrThrow?=
=?UTF-8?q?=EB=A1=9C=20=ED=86=B5=EC=9D=BC?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../event/service/impl/EventApplicationServiceImpl.java | 6 ++----
1 file changed, 2 insertions(+), 4 deletions(-)
diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventApplicationServiceImpl.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventApplicationServiceImpl.java
index 6a31a5ee..1408fceb 100644
--- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventApplicationServiceImpl.java
+++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventApplicationServiceImpl.java
@@ -90,8 +90,7 @@ public CursorSliceResult getMyApplications(
@Transactional(readOnly = true)
public EventApplicationDetail getMyApplication(Long applicationId, Long memberId) {
EventApplication application = getMyApplicationOrThrow(applicationId, memberId);
- Event event = eventRepository.findById(application.getEventId())
- .orElseThrow(() -> new BusinessException(EventErrorCode.EVENT_NOT_FOUND));
+ Event event = getEventOrThrow(application.getEventId());
// 질문은 행사 것을 쓰고 답변만 이 신청 것을 붙인다. 취소 후 재신청이면 신청마다 답변이 따로 있다
return EventApplicationDetail.of(
@@ -110,8 +109,7 @@ public void cancelApplication(Long applicationId, Long memberId) {
throw new BusinessException(EventErrorCode.ALREADY_CANCELED);
}
- Event event = eventRepository.findById(application.getEventId())
- .orElseThrow(() -> new BusinessException(EventErrorCode.EVENT_NOT_FOUND));
+ Event event = getEventOrThrow(application.getEventId());
// 마감 판정과 취소 시각이 같은 시각을 봐야 마감 직전 취소가 마감 이후로 기록되지 않는다
LocalDateTime now = LocalDateTime.now();
From c5af1a96d9b274a9ee9e53c0bd7ad1b7408bfb7d Mon Sep 17 00:00:00 2001
From: gain <163835628+leegain1@users.noreply.github.com>
Date: Sun, 18 Oct 2026 01:37:48 +0900
Subject: [PATCH 7/8] =?UTF-8?q?refactor:=20=ED=96=89=EC=82=AC=20=EB=93=B1?=
=?UTF-8?q?=EB=A1=9D=C2=B7=EC=88=98=EC=A0=95=20=EA=B2=80=EC=A6=9D=EC=9D=84?=
=?UTF-8?q?=20EventWriteCommand=20=ED=95=98=EB=82=98=EB=A1=9C=20=ED=86=B5?=
=?UTF-8?q?=ED=95=A9?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.../event/domain/EventCreateCommand.java | 2 +-
.../event/domain/EventUpdateCommand.java | 2 +-
.../event/domain/EventWriteCommand.java | 26 +++++++++++++++++++
.../service/impl/EventWriteValidator.java | 12 ++-------
4 files changed, 30 insertions(+), 12 deletions(-)
create mode 100644 core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventWriteCommand.java
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
index 2d63c3c1..9f9cd94e 100644
--- 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
@@ -19,5 +19,5 @@ public record EventCreateCommand(
boolean feePaidOnly,
List imageIds,
List questions
-) {
+) implements EventWriteCommand {
}
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
index 31ea49c9..7720da30 100644
--- 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
@@ -21,5 +21,5 @@ public record EventUpdateCommand(
List imageIds,
List questions,
LocalDateTime expectedUpdatedAt
-) {
+) implements EventWriteCommand {
}
diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventWriteCommand.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventWriteCommand.java
new file mode 100644
index 00000000..add0d14c
--- /dev/null
+++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventWriteCommand.java
@@ -0,0 +1,26 @@
+package kr.ac.kookmin.stream.event.domain.event.domain;
+
+import java.time.LocalDateTime;
+import java.util.List;
+
+/**
+ * 행사 등록·수정 요청이 공통으로 갖는 행사 내용. 두 요청을 같은 규칙으로 검증하려고 둔다.
+ * {@link EventCreateCommand}와 {@link EventUpdateCommand}가 구현하며, record 컴포넌트가 그대로 이 메서드들이 된다.
+ */
+public interface EventWriteCommand {
+
+ LocalDateTime eventStartAt();
+
+ LocalDateTime eventEndAt();
+
+ LocalDateTime applyStartAt();
+
+ LocalDateTime applyEndAt();
+
+ RecruitType recruitType();
+
+ /** 선착순 모집의 정원. 상시 모집이면 쓰이지 않는다 */
+ Integer capacity();
+
+ List questions();
+}
diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventWriteValidator.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventWriteValidator.java
index 938c5547..9de699b3 100644
--- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventWriteValidator.java
+++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventWriteValidator.java
@@ -3,10 +3,9 @@
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.EventWriteCommand;
import kr.ac.kookmin.stream.event.domain.event.domain.RecruitType;
import org.springframework.stereotype.Component;
@@ -17,14 +16,7 @@
@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) {
+ void validate(EventWriteCommand command) {
validatePeriod(command.eventStartAt(), command.eventEndAt());
validatePeriod(command.applyStartAt(), command.applyEndAt());
validateCapacity(command.recruitType(), command.capacity());
From cfcddf0429c7c8fa0b03f93f05137ec8e529daa1 Mon Sep 17 00:00:00 2001
From: gain <163835628+leegain1@users.noreply.github.com>
Date: Sun, 18 Oct 2026 01:40:42 +0900
Subject: [PATCH 8/8] =?UTF-8?q?refactor:=20=ED=96=89=EC=82=AC=20=EC=8B=A0?=
=?UTF-8?q?=EC=B2=AD=20=EC=A0=80=EC=9E=A5=EC=86=8C=EB=A5=BC=20EventApplica?=
=?UTF-8?q?tionRepository=EB=A1=9C=20=EB=B6=84=EB=A6=AC?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
EventRepository가 행사·질문과 신청·답변 메서드를 함께 갖고 있어 운영진 API를
붙이며 20개 넘게 늘었다. 사물함(LockerApplicationRepository)과 같이 신청·답변은
EventApplicationRepository로 옮긴다.
신청 저장소 안에서는 앞의 Application을 빼 이름을 줄였다
(saveApplication → save, findApplicationSlice → findSliceByMemberId 등).
신청자 수 집계 쿼리도 EventApplicationJpaRepository로 옮겼다. 동작 변화는 없다.
---
.../EventApplicationRepository.java | 74 ++++++++++
.../event/repository/EventRepository.java | 67 +--------
.../impl/EventApplicationServiceImpl.java | 26 ++--
.../event/service/impl/EventServiceImpl.java | 14 +-
...EventApplicationServiceImplManageTest.java | 11 +-
.../impl/EventServiceImplManageTest.java | 28 ++--
.../service/impl/EventServiceImplTest.java | 31 ++--
.../InMemoryEventApplicationRepository.java | 113 +++++++++++++++
.../service/impl/InMemoryEventRepository.java | 99 +------------
.../event/EventApplicationJpaRepository.java | 11 ++
.../event/EventApplicationRepositoryImpl.java | 135 ++++++++++++++++++
.../stream/db/event/EventJpaRepository.java | 11 --
.../stream/db/event/EventRepositoryImpl.java | 119 ---------------
13 files changed, 398 insertions(+), 341 deletions(-)
create mode 100644 core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/repository/EventApplicationRepository.java
create mode 100644 core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/InMemoryEventApplicationRepository.java
create mode 100644 infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventApplicationRepositoryImpl.java
diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/repository/EventApplicationRepository.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/repository/EventApplicationRepository.java
new file mode 100644
index 00000000..93689122
--- /dev/null
+++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/repository/EventApplicationRepository.java
@@ -0,0 +1,74 @@
+package kr.ac.kookmin.stream.event.domain.event.repository;
+
+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.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.EventApplicationStatus;
+
+/**
+ * 행사 신청과 신청서 답변 저장소. 행사·신청서 질문은 {@link EventRepository}가 맡는다.
+ */
+public interface EventApplicationRepository {
+
+ EventApplication save(EventApplication application);
+
+ List saveAnswers(List answers);
+
+ /**
+ * 행사별 유효 신청자 수를 한 번에 조회한다. 목록처럼 여러 행사의 신청자 수가 필요할 때 N+1을 피하기 위한 것으로,
+ * 신청이 한 건도 없는 행사는 결과에 담기지 않는다.
+ */
+ Map countAppliedByEventIds(List eventIds);
+
+ long countAppliedByEventId(Long eventId);
+
+ /** 한 행사의 신청 수. 취소된 신청도 센다. */
+ long countByEventId(Long eventId);
+
+ /**
+ * 행사에 신청이 한 건이라도 있는지. 취소된 신청도 센다 — 취소돼도 답변이 질문 id·선택지 순서를 참조한다.
+ */
+ boolean existsByEventId(Long eventId);
+
+ boolean existsAppliedByEventIdAndMemberId(Long eventId, Long memberId);
+
+ /**
+ * 한 회원의 신청 내역을 신청 시각 내림차순(동일 시각은 applicationId 내림차순)으로 {@code limit}건까지 조회한다.
+ *
+ * 삭제된 행사의 신청은 제외한다.
+ *
+ * 페이지를 어디서 끊을지는 호출부가 정한다. 여기서는 요청한 개수만큼 돌려줄 뿐이다.
+ */
+ List findSliceByMemberId(Long memberId, EventApplicationCursor cursor, int limit);
+
+ /**
+ * 한 행사의 신청을 신청 시각 오름차순(동일 시각은 applicationId 오름차순)으로 페이지 조회한다. 취소된 신청도 담는다.
+ *
+ * @param status {@code null}이면 상태로 거르지 않는다
+ * @param memberIds {@code null}이면 신청자로 거르지 않는다
+ */
+ PageResult findPageByEventId(
+ Long eventId,
+ EventApplicationStatus status,
+ List memberIds,
+ PageOffset pageOffset
+ );
+
+ /**
+ * 본인 신청 한 건을 조회한다. 남의 신청은 없는 것으로 보여야 하므로 회원 식별자를 조건에 함께 건다.
+ */
+ Optional findByIdAndMemberId(Long applicationId, Long memberId);
+
+ /** 한 행사에 속한 신청 한 건을 조회한다. 다른 행사의 신청은 없는 것으로 본다. */
+ Optional findByIdAndEventId(Long applicationId, Long eventId);
+
+ List findAnswersByApplicationId(Long applicationId);
+
+ /** 여러 신청의 답변을 한 번에 조회한다. 신청마다 따로 읽으면 페이지 크기만큼 쿼리가 더 나간다. */
+ List findAnswersByApplicationIds(List applicationIds);
+}
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 7e57f957..2e84f5e7 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
@@ -2,19 +2,17 @@
import java.time.LocalDateTime;
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;
-import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationCursor;
-import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationStatus;
import kr.ac.kookmin.stream.event.domain.event.domain.EventCursor;
import kr.ac.kookmin.stream.event.domain.event.domain.EventQuestion;
import kr.ac.kookmin.stream.event.domain.event.domain.RecruitStatus;
+/**
+ * 행사와 신청서 질문 저장소. 신청·답변은 {@link EventApplicationRepository}가 맡는다.
+ */
public interface EventRepository {
Optional findById(Long id);
@@ -33,11 +31,6 @@ public interface EventRepository {
/** 행사의 신청서 질문을 모두 지우고 주어진 질문으로 바꾼다. */
List replaceQuestions(Long eventId, List questions);
- /**
- * 행사에 신청이 한 건이라도 있는지. 취소된 신청도 센다 — 취소돼도 답변이 질문 id·선택지 순서를 참조한다.
- */
- boolean existsApplicationByEventId(Long eventId);
-
/**
* 행사의 수정 시각을 지금으로 갱신한다. 신청서 질문만 바뀌면 events 행의 값이 그대로라
* DB가 updated_at을 올리지 않으므로, 동시 수정 감지를 위해 직접 올린다.
@@ -54,7 +47,7 @@ public interface EventRepository {
/**
* 주어진 식별자의 행사를 조회한다. 삭제 여부로 거르지 않는다.
*
- * 내 신청 내역에서 신청마다 행사 정보를 붙일 때 쓴다. 삭제된 행사의 신청은 {@link #findApplicationSlice}가
+ * 내 신청 내역에서 신청마다 행사 정보를 붙일 때 쓴다. 삭제된 행사의 신청은 {@link EventApplicationRepository#findSliceByMemberId}가
* 이미 걸러내므로 여기서 다시 거르지 않는다. 여기서 걸러내면 커서 페이지의 크기 계산이 어긋난다.
*/
List findAllByIds(List eventIds);
@@ -77,57 +70,5 @@ List findPublishedSlice(
LocalDateTime now
);
- /**
- * 행사별 유효 신청자 수를 한 번에 조회한다. 목록처럼 여러 행사의 신청자 수가 필요할 때 N+1을 피하기 위한 것으로,
- * 신청이 한 건도 없는 행사는 결과에 담기지 않는다.
- */
- Map countAppliedByEventIds(List eventIds);
-
List findQuestionsByEventId(Long eventId);
-
- long countAppliedByEventId(Long eventId);
-
- boolean existsAppliedByEventIdAndMemberId(Long eventId, Long memberId);
-
- EventApplication saveApplication(EventApplication application);
-
- List saveAnswers(List answers);
-
- /**
- * 한 회원의 신청 내역을 신청 시각 내림차순(동일 시각은 applicationId 내림차순)으로 {@code limit}건까지 조회한다.
- *
- * 삭제된 행사의 신청은 제외한다.
- *
- * 페이지를 어디서 끊을지는 호출부가 정한다. 여기서는 요청한 개수만큼 돌려줄 뿐이다.
- */
- List findApplicationSlice(Long memberId, EventApplicationCursor cursor, int limit);
-
- /**
- * 본인 신청 한 건을 조회한다. 남의 신청은 없는 것으로 보여야 하므로 회원 식별자를 조건에 함께 건다.
- */
- Optional findApplicationByIdAndMemberId(Long applicationId, Long memberId);
-
- List findAnswersByApplicationId(Long applicationId);
-
- /**
- * 한 행사의 신청을 신청 시각 오름차순(동일 시각은 applicationId 오름차순)으로 페이지 조회한다. 취소된 신청도 담는다.
- *
- * @param status {@code null}이면 상태로 거르지 않는다
- * @param memberIds {@code null}이면 신청자로 거르지 않는다
- */
- PageResult findApplicationPage(
- Long eventId,
- EventApplicationStatus status,
- List memberIds,
- PageOffset pageOffset
- );
-
- /** 여러 신청의 답변을 한 번에 조회한다. 신청마다 따로 읽으면 페이지 크기만큼 쿼리가 더 나간다. */
- List findAnswersByApplicationIds(List applicationIds);
-
- /** 한 행사에 속한 신청 한 건을 조회한다. 다른 행사의 신청은 없는 것으로 본다. */
- Optional findApplicationByIdAndEventId(Long applicationId, Long eventId);
-
- /** 한 행사의 신청 수. 취소된 신청도 센다. */
- long countApplicationsByEventId(Long eventId);
}
diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventApplicationServiceImpl.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventApplicationServiceImpl.java
index 1408fceb..ff117558 100644
--- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventApplicationServiceImpl.java
+++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventApplicationServiceImpl.java
@@ -22,6 +22,7 @@
import kr.ac.kookmin.stream.event.domain.event.domain.EventErrorCode;
import kr.ac.kookmin.stream.event.domain.event.domain.EventQuestion;
import kr.ac.kookmin.stream.event.domain.event.domain.RecruitStatus;
+import kr.ac.kookmin.stream.event.domain.event.repository.EventApplicationRepository;
import kr.ac.kookmin.stream.event.domain.event.repository.EventRepository;
import kr.ac.kookmin.stream.event.domain.event.service.EventApplicationService;
import lombok.RequiredArgsConstructor;
@@ -33,6 +34,7 @@
class EventApplicationServiceImpl implements EventApplicationService {
private final EventRepository eventRepository;
+ private final EventApplicationRepository eventApplicationRepository;
private final EventApplyAnswerValidator eventApplyAnswerValidator;
@Override
@@ -47,17 +49,17 @@ public EventApplicationForm getApplicationForm(Long eventId) {
public EventApplicationResult apply(Long eventId, Long memberId, EventApplyCommand command) {
Event event = getOpenEvent(eventId);
- if (eventRepository.existsAppliedByEventIdAndMemberId(eventId, memberId)) {
+ if (eventApplicationRepository.existsAppliedByEventIdAndMemberId(eventId, memberId)) {
throw new BusinessException(EventErrorCode.ALREADY_APPLIED);
}
List questions = eventRepository.findQuestionsByEventId(eventId);
eventApplyAnswerValidator.validate(questions, command);
- EventApplication application = eventRepository.saveApplication(
+ EventApplication application = eventApplicationRepository.save(
EventApplication.create(eventId, memberId, LocalDateTime.now())
);
- eventRepository.saveAnswers(toAnswers(application.getId(), command));
+ eventApplicationRepository.saveAnswers(toAnswers(application.getId(), command));
return new EventApplicationResult(application.getId(), event);
}
@@ -70,7 +72,7 @@ public CursorSliceResult getMyApplications(
int size
) {
// 다음 페이지가 있는지 알려면 한 건 더 읽어봐야 한다
- List fetched = eventRepository.findApplicationSlice(memberId, cursor, size + 1);
+ List fetched = eventApplicationRepository.findSliceByMemberId(memberId, cursor, size + 1);
// 행사는 한 번에 모아 조회한다. 신청마다 따로 읽으면 페이지 크기만큼 쿼리가 더 나간다
Map events = eventRepository.findAllByIds(
@@ -97,7 +99,7 @@ public EventApplicationDetail getMyApplication(Long applicationId, Long memberId
event,
application,
eventRepository.findQuestionsByEventId(event.getId()),
- eventRepository.findAnswersByApplicationId(applicationId)
+ eventApplicationRepository.findAnswersByApplicationId(applicationId)
);
}
@@ -119,7 +121,7 @@ public void cancelApplication(Long applicationId, Long memberId) {
}
application.cancel(now);
- eventRepository.saveApplication(application);
+ eventApplicationRepository.save(application);
}
/** 남의 신청은 없는 것으로 보여야 하므로 조회 단계에서 회원 식별자로 함께 거른다. */
@@ -131,24 +133,24 @@ public PageResult getApplications(
PageOffset pageOffset
) {
getEventOrThrow(eventId);
- return eventRepository.findApplicationPage(eventId, status, memberIds, pageOffset);
+ return eventApplicationRepository.findPageByEventId(eventId, status, memberIds, pageOffset);
}
@Override
public Map> getAnswersByApplicationIds(List applicationIds) {
- return eventRepository.findAnswersByApplicationIds(applicationIds).stream()
+ return eventApplicationRepository.findAnswersByApplicationIds(applicationIds).stream()
.collect(Collectors.groupingBy(EventApplicationAnswer::getEventApplicationId));
}
@Override
public long countApplications(Long eventId) {
- return eventRepository.countApplicationsByEventId(eventId);
+ return eventApplicationRepository.countByEventId(eventId);
}
@Override
public EventApplication getApplication(Long eventId, Long applicationId) {
getEventOrThrow(eventId);
- return eventRepository.findApplicationByIdAndEventId(applicationId, eventId)
+ return eventApplicationRepository.findByIdAndEventId(applicationId, eventId)
.orElseThrow(() -> new BusinessException(EventErrorCode.APPLICATION_NOT_FOUND));
}
@@ -158,7 +160,7 @@ private Event getEventOrThrow(Long eventId) {
}
private EventApplication getMyApplicationOrThrow(Long applicationId, Long memberId) {
- return eventRepository.findApplicationByIdAndMemberId(applicationId, memberId)
+ return eventApplicationRepository.findByIdAndMemberId(applicationId, memberId)
.orElseThrow(() -> new BusinessException(EventErrorCode.APPLICATION_NOT_FOUND));
}
@@ -173,7 +175,7 @@ private Event getOpenEvent(Long eventId) {
.orElseThrow(() -> new BusinessException(EventErrorCode.EVENT_NOT_FOUND));
LocalDateTime now = LocalDateTime.now();
- long appliedCount = eventRepository.countAppliedByEventId(eventId);
+ long appliedCount = eventApplicationRepository.countAppliedByEventId(eventId);
if (event.calculateRecruitStatus(now, appliedCount) != RecruitStatus.OPEN) {
throw new BusinessException(event.isClosedByCapacityOnly(now, appliedCount)
? EventErrorCode.CAPACITY_FULL
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 3f2866a7..895ea6a7 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
@@ -21,6 +21,7 @@
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.EventApplicationRepository;
import kr.ac.kookmin.stream.event.domain.event.repository.EventRepository;
import kr.ac.kookmin.stream.event.domain.event.service.EventService;
import lombok.RequiredArgsConstructor;
@@ -32,6 +33,7 @@
class EventServiceImpl implements EventService {
private final EventRepository eventRepository;
+ private final EventApplicationRepository eventApplicationRepository;
private final EventWriteValidator eventWriteValidator;
@Override
@@ -47,7 +49,7 @@ public CursorSliceResult getPublishedEvents(
List fetched = eventRepository.findPublishedSlice(recruitStatus, cursor, size + 1, now);
// 신청자 수는 한 번에 모아 조회한다. 행사마다 따로 세면 페이지 크기만큼 쿼리가 더 나간다
- Map applicantCounts = eventRepository.countAppliedByEventIds(
+ Map applicantCounts = eventApplicationRepository.countAppliedByEventIds(
fetched.stream().limit(size).map(Event::getId).toList());
return CursorSliceResult.ofSlice(
@@ -65,7 +67,7 @@ public EventDetail getPublishedEvent(Long eventId) {
.orElseThrow(() -> new BusinessException(EventErrorCode.EVENT_NOT_FOUND));
LocalDateTime now = LocalDateTime.now();
- return EventDetail.of(event, eventRepository.countAppliedByEventId(eventId), now);
+ return EventDetail.of(event, eventApplicationRepository.countAppliedByEventId(eventId), now);
}
@Override
@@ -87,7 +89,7 @@ public PageResult getEvents(String keyword, PageOffset pageOffset) {
@Override
@Transactional(readOnly = true)
public Map getApplicantCounts(List eventIds) {
- return eventRepository.countAppliedByEventIds(eventIds);
+ return eventApplicationRepository.countAppliedByEventIds(eventIds);
}
@Override
@@ -97,7 +99,7 @@ public EventManageDetail getEvent(Long eventId) {
return new EventManageDetail(
event,
eventRepository.findQuestionsByEventId(eventId),
- eventRepository.countAppliedByEventId(eventId)
+ eventApplicationRepository.countAppliedByEventId(eventId)
);
}
@@ -112,7 +114,7 @@ public Event update(Long eventId, EventUpdateCommand command) {
List questions = toQuestions(eventId, command.questions());
if (questionsChanged(eventRepository.findQuestionsByEventId(eventId), questions)) {
// 답변이 질문 id·선택지 순서로 저장돼 있어 신청이 생긴 뒤 바꾸면 기존 답변의 뜻이 달라진다
- if (eventRepository.existsApplicationByEventId(eventId)) {
+ if (eventApplicationRepository.existsByEventId(eventId)) {
throw new BusinessException(EventErrorCode.EVENT_QUESTIONS_LOCKED);
}
eventRepository.replaceQuestions(eventId, questions);
@@ -155,7 +157,7 @@ private void validateNotModified(Event event, LocalDateTime expectedUpdatedAt) {
// 이미 받은 신청을 정원 밖으로 밀어낼 수는 없다. 상시 모집은 인원 제한이 없어 보지 않는다
private void validateCapacityNotBelowApplicants(Long eventId, EventUpdateCommand command) {
if (command.recruitType() == RecruitType.FIRST_COME
- && command.capacity() < eventRepository.countAppliedByEventId(eventId)) {
+ && command.capacity() < eventApplicationRepository.countAppliedByEventId(eventId)) {
throw new BusinessException(EventErrorCode.EVENT_CAPACITY_BELOW_APPLICANTS);
}
}
diff --git a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventApplicationServiceImplManageTest.java b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventApplicationServiceImplManageTest.java
index e728e2cd..3c0ce53d 100644
--- a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventApplicationServiceImplManageTest.java
+++ b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventApplicationServiceImplManageTest.java
@@ -31,8 +31,9 @@ class EventApplicationServiceImplManageTest {
private static final LocalDateTime APPLIED_AT = LocalDateTime.of(2026, 10, 3, 14, 0);
private final InMemoryEventRepository repository = new InMemoryEventRepository();
+ private final InMemoryEventApplicationRepository applicationRepository = new InMemoryEventApplicationRepository();
private final EventApplicationServiceImpl service =
- new EventApplicationServiceImpl(repository, new EventApplyAnswerValidator());
+ new EventApplicationServiceImpl(repository, applicationRepository, new EventApplyAnswerValidator());
private static Event event(Long id) {
return Event.of(
@@ -44,7 +45,7 @@ private static Event event(Long id) {
private void givenApplication(Long id, Long eventId, Long memberId, EventApplicationStatus status,
LocalDateTime appliedAt) {
- repository.applications.put(id, EventApplication.of(id, eventId, memberId, status, appliedAt,
+ applicationRepository.applications.put(id, EventApplication.of(id, eventId, memberId, status, appliedAt,
status == EventApplicationStatus.CANCELED ? appliedAt.plusHours(1) : null));
}
@@ -125,9 +126,9 @@ void countsCanceledApplications() {
@Test
@DisplayName("답변은 신청별로 묶어 돌려주고, 답변이 없는 신청은 담지 않는다")
void groupsAnswersByApplication() {
- repository.answers.add(EventApplicationAnswer.of(1L, 11L, 201L, "010-1234-5678", null));
- repository.answers.add(EventApplicationAnswer.of(2L, 11L, 202L, null, List.of(0)));
- repository.answers.add(EventApplicationAnswer.of(3L, 12L, 201L, "010-2345-6789", null));
+ applicationRepository.answers.add(EventApplicationAnswer.of(1L, 11L, 201L, "010-1234-5678", null));
+ applicationRepository.answers.add(EventApplicationAnswer.of(2L, 11L, 202L, null, List.of(0)));
+ applicationRepository.answers.add(EventApplicationAnswer.of(3L, 12L, 201L, "010-2345-6789", null));
Map> answers = service.getAnswersByApplicationIds(List.of(11L, 12L, 13L));
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
index fcd2a20c..07663164 100644
--- 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
@@ -48,7 +48,9 @@ class EventServiceImplManageTest {
"전화번호", QuestionType.SHORT_TEXT, true, List.of(), false);
private final InMemoryEventRepository repository = new InMemoryEventRepository();
- private final EventServiceImpl service = new EventServiceImpl(repository, new EventWriteValidator());
+ private final InMemoryEventApplicationRepository applicationRepository = new InMemoryEventApplicationRepository();
+ private final EventServiceImpl service =
+ new EventServiceImpl(repository, applicationRepository, new EventWriteValidator());
private static Event savedEvent(RecruitType recruitType, int capacity) {
return Event.of(
@@ -149,7 +151,7 @@ class GetEvent {
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);
+ applicationRepository.appliedCounts.put(EVENT_ID, 3L);
EventManageDetail detail = service.getEvent(EVENT_ID);
@@ -237,7 +239,7 @@ class CapacityFloor {
@DisplayName("선착순 정원을 현재 신청자 수보다 줄일 수 없다")
void rejectsBelowApplicants() {
givenEvent(RecruitType.FIRST_COME, 100, savedQuestions(SPORTS));
- repository.appliedCounts.put(EVENT_ID, 10L);
+ applicationRepository.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)));
@@ -247,7 +249,7 @@ void rejectsBelowApplicants() {
@DisplayName("신청자 수와 같은 정원까지는 줄일 수 있다")
void allowsEqualToApplicants() {
givenEvent(RecruitType.FIRST_COME, 100, savedQuestions(SPORTS));
- repository.appliedCounts.put(EVENT_ID, 10L);
+ applicationRepository.appliedCounts.put(EVENT_ID, 10L);
service.update(EVENT_ID, updateCommand(RecruitType.FIRST_COME, 10, List.of(SPORTS), UPDATED_AT));
@@ -258,7 +260,7 @@ void allowsEqualToApplicants() {
@DisplayName("상시 모집에서 선착순으로 바꿀 때도 신청자 수 이상이어야 한다")
void switchingToFirstComeChecksApplicants() {
givenEvent(RecruitType.OPEN, 0, savedQuestions(SPORTS));
- repository.appliedCounts.put(EVENT_ID, 30L);
+ applicationRepository.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)));
@@ -268,7 +270,7 @@ void switchingToFirstComeChecksApplicants() {
@DisplayName("상시 모집은 신청자 수와 상관없이 수정할 수 있다")
void openRecruitIgnoresApplicants() {
givenEvent(RecruitType.FIRST_COME, 100, savedQuestions(SPORTS));
- repository.appliedCounts.put(EVENT_ID, 300L);
+ applicationRepository.appliedCounts.put(EVENT_ID, 300L);
assertDoesNotThrow(() -> service.update(EVENT_ID,
updateCommand(RecruitType.OPEN, null, List.of(SPORTS), UPDATED_AT)));
@@ -284,7 +286,7 @@ class Questions {
void keepsQuestionsWhenUnchanged() {
List original = savedQuestions(PHONE, SPORTS);
givenEvent(RecruitType.FIRST_COME, 100, original);
- repository.applicationExists = true;
+ applicationRepository.applicationExists = true;
service.update(EVENT_ID, updateCommand(List.of(PHONE, SPORTS)));
@@ -331,7 +333,7 @@ void reorderCountsAsChange() {
@DisplayName("신청(취소 포함)이 있으면 질문을 바꿀 수 없고 행사도 저장하지 않는다")
void lockedWhenApplicationExists() {
givenEvent(RecruitType.FIRST_COME, 100, savedQuestions(SPORTS));
- repository.applicationExists = true;
+ applicationRepository.applicationExists = true;
assertRejected(EventErrorCode.EVENT_QUESTIONS_LOCKED,
() -> service.update(EVENT_ID, updateCommand(List.of(SPORTS, PHONE))));
@@ -360,8 +362,8 @@ void delegatesToRepository() {
@Test
@DisplayName("신청자 수는 행사 id 목록으로 한 번에 조회한다")
void countsApplicantsAtOnce() {
- repository.appliedCounts.put(1L, 3L);
- repository.appliedCounts.put(2L, 5L);
+ applicationRepository.appliedCounts.put(1L, 3L);
+ applicationRepository.appliedCounts.put(2L, 5L);
assertEquals(Map.of(1L, 3L, 2L, 5L), service.getApplicantCounts(List.of(1L, 2L)));
}
@@ -372,7 +374,7 @@ void countsApplicantsAtOnce() {
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);
+ applicationRepository.appliedCounts.put(EVENT_ID, 3L);
EventManageDetail detail = service.getEvent(EVENT_ID);
@@ -429,8 +431,8 @@ void deletes() {
@DisplayName("신청자가 있어도 지울 수 있다")
void deletesEvenWithApplications() {
repository.events.put(EVENT_ID, savedEvent(RecruitType.FIRST_COME, 100));
- repository.applicationExists = true;
- repository.appliedCounts.put(EVENT_ID, 12L);
+ applicationRepository.applicationExists = true;
+ applicationRepository.appliedCounts.put(EVENT_ID, 12L);
assertDoesNotThrow(() -> service.delete(EVENT_ID));
}
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 53e58d82..6d10e42c 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
@@ -22,6 +22,7 @@
import kr.ac.kookmin.stream.event.domain.event.domain.EventSummary;
import kr.ac.kookmin.stream.event.domain.event.domain.RecruitStatus;
import kr.ac.kookmin.stream.event.domain.event.domain.RecruitType;
+import kr.ac.kookmin.stream.event.domain.event.repository.EventApplicationRepository;
import kr.ac.kookmin.stream.event.domain.event.repository.EventRepository;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
@@ -48,7 +49,7 @@ void matchesApplicantCountToItsEvent() {
List.of(event(1L), event(2L)),
Map.of(1L, 3L, 2L, 7L));
- List content = new EventServiceImpl(repository, new EventWriteValidator())
+ List content = new EventServiceImpl(repository, repository, new EventWriteValidator())
.getPublishedEvents(null, null, 20).content();
// 정원 100명이라 신청자 수가 모집 상태를 바꾸지 않는다. 짝이 맞는지는 D-Day가 아니라 아래에서 직접 본다
@@ -64,7 +65,7 @@ void keepsEventsWithoutApplicants() {
List.of(event(1L), event(2L)),
Map.of(1L, 3L));
- List content = new EventServiceImpl(repository, new EventWriteValidator())
+ List content = new EventServiceImpl(repository, repository, new EventWriteValidator())
.getPublishedEvents(null, null, 20).content();
assertEquals(2, content.size());
@@ -87,7 +88,7 @@ void appliesCountPerEvent() {
List.of(full, roomy),
Map.of(1L, 3L, 2L, 3L));
- List content = new EventServiceImpl(repository, new EventWriteValidator())
+ List content = new EventServiceImpl(repository, repository, new EventWriteValidator())
.getPublishedEvents(null, null, 20).content();
assertEquals(RecruitStatus.CLOSED, content.get(0).recruitStatus());
@@ -100,7 +101,7 @@ void detectsNextPage() {
FakeEventRepository repository = new FakeEventRepository(
List.of(event(1L), event(2L), event(3L)), Map.of());
- CursorSliceResult result = new EventServiceImpl(repository, new EventWriteValidator())
+ CursorSliceResult result = new EventServiceImpl(repository, repository, new EventWriteValidator())
.getPublishedEvents(null, null, 2);
assertEquals(3, repository.requestedLimit);
@@ -114,7 +115,7 @@ void detectsNextPage() {
void noCursorOnLastPage() {
FakeEventRepository repository = new FakeEventRepository(List.of(event(1L), event(2L)), Map.of());
- CursorSliceResult result = new EventServiceImpl(repository, new EventWriteValidator())
+ CursorSliceResult result = new EventServiceImpl(repository, repository, new EventWriteValidator())
.getPublishedEvents(null, null, 2);
assertFalse(result.hasNext());
@@ -127,15 +128,15 @@ void asksNothingWhenSliceIsEmpty() {
FakeEventRepository repository = new FakeEventRepository(
List.of(), Map.of());
- CursorSliceResult result = new EventServiceImpl(repository, new EventWriteValidator())
+ CursorSliceResult result = new EventServiceImpl(repository, repository, new EventWriteValidator())
.getPublishedEvents(null, null, 20);
assertTrue(result.content().isEmpty());
assertEquals(List.of(), repository.requestedEventIds);
}
- /** 목록 조회에 쓰이는 두 메서드만 답하고, 나머지는 이 테스트가 건드리지 않는다. */
- private static final class FakeEventRepository implements EventRepository {
+ /** 목록 조회에 쓰이는 두 메서드(행사 조회·신청자 수)만 답하고, 나머지는 이 테스트가 건드리지 않는다. */
+ private static final class FakeEventRepository implements EventRepository, EventApplicationRepository {
private final List fetched;
private final Map applicantCounts;
@@ -191,7 +192,7 @@ public List