diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/event/AppEventController.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/event/AppEventController.java index 7de5744..8b88e32 100644 --- a/api/app-api/src/main/java/kr/ac/kookmin/stream/event/AppEventController.java +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/event/AppEventController.java @@ -2,10 +2,15 @@ import jakarta.validation.Valid; import kr.ac.kookmin.stream.ApiResponse; +import kr.ac.kookmin.stream.CursorCodec; +import kr.ac.kookmin.stream.CursorSliceResponse; import kr.ac.kookmin.stream.app.AppApiUser; +import kr.ac.kookmin.stream.common.CursorSliceResult; +import kr.ac.kookmin.stream.event.domain.event.domain.EventSummary; import kr.ac.kookmin.stream.event.domain.event.service.EventService; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -19,6 +24,20 @@ public class AppEventController { private final EventService eventService; + @GetMapping + public ApiResponse> getEvents( + @Valid @ModelAttribute EventListRequest request + ) { + CursorSliceResult result = eventService.getPublishedEvents( + request.toRecruitStatus(), request.toCursor(), request.sizeOrDefault()); + return ApiResponse.success(CursorSliceResponse.from(toResponse(result))); + } + + @GetMapping("/{eventId}") + public ApiResponse getEvent(@PathVariable Long eventId) { + return ApiResponse.success(EventDetailResponse.from(eventService.getPublishedEvent(eventId))); + } + @GetMapping("/{eventId}/form") public ApiResponse getApplicationForm(@PathVariable Long eventId) { return ApiResponse.success(EventFormResponse.from(eventService.getApplicationForm(eventId))); @@ -34,4 +53,13 @@ public ApiResponse apply( EventApplyResponse.from(eventService.apply(eventId, apiUser.userId(), request.toCommand())) ); } + + // 커서는 클라이언트에게 불투명한 토큰이어야 하므로 응답 직전 웹 계층에서 인코딩한다 + private CursorSliceResult toResponse(CursorSliceResult result) { + return new CursorSliceResult<>( + result.content().stream().map(EventListItemResponse::from).toList(), + result.hasNext(), + result.nextCursor() == null ? null : CursorCodec.encode(result.nextCursor()) + ); + } } diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/event/EventDetailResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/event/EventDetailResponse.java new file mode 100644 index 0000000..4309703 --- /dev/null +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/event/EventDetailResponse.java @@ -0,0 +1,49 @@ +package kr.ac.kookmin.stream.event; + +import java.time.LocalDateTime; +import java.util.List; +import kr.ac.kookmin.stream.event.domain.event.domain.EventDetail; +import kr.ac.kookmin.stream.event.domain.event.domain.RecruitStatus; + +public record EventDetailResponse( + Long eventId, + String title, + String description, + String target, + String place, + LocalDateTime eventStartAt, + LocalDateTime eventEndAt, + LocalDateTime applyStartAt, + LocalDateTime applyEndAt, + RecruitStatus recruitStatus, + Integer daysUntilDeadline, + List images +) { + + public static EventDetailResponse from(EventDetail detail) { + return new EventDetailResponse( + detail.eventId(), + detail.title(), + detail.description(), + detail.target(), + detail.place(), + detail.eventStartAt(), + detail.eventEndAt(), + detail.applyStartAt(), + detail.applyEndAt(), + detail.recruitStatus(), + detail.daysUntilDeadline(), + detail.imageIds().stream().map(Image::from).toList() + ); + } + + public record Image(Long fileId, String fileUrl) { + + /** + * 파일 키 → 공개 URL 조립(#17)이 아직 없어 URL은 비어 있다. #17이 머지되면 이 팩토리만 채우면 된다. + */ + public static Image from(Long fileId) { + return new Image(fileId, null); + } + } +} diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/event/EventListItemResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/event/EventListItemResponse.java new file mode 100644 index 0000000..45708e4 --- /dev/null +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/event/EventListItemResponse.java @@ -0,0 +1,41 @@ +package kr.ac.kookmin.stream.event; + +import java.time.LocalDateTime; +import kr.ac.kookmin.stream.event.domain.event.domain.EventSummary; +import kr.ac.kookmin.stream.event.domain.event.domain.RecruitStatus; + +public record EventListItemResponse( + Long eventId, + String title, + String target, + LocalDateTime eventStartAt, + String thumbnailUrl, + LocalDateTime applyStartAt, + LocalDateTime applyEndAt, + RecruitStatus recruitStatus, + Integer daysUntilDeadline +) { + + public static EventListItemResponse from(EventSummary summary) { + return new EventListItemResponse( + summary.eventId(), + summary.title(), + summary.target(), + summary.eventStartAt(), + thumbnailUrlOf(summary.thumbnailFileId()), + summary.applyStartAt(), + summary.applyEndAt(), + summary.recruitStatus(), + summary.daysUntilDeadline() + ); + } + + /** + * 대표 이미지 파일 id를 공개 URL로 바꾼다. + *

+ * 파일 키 → 공개 URL 조립(#17)이 아직 없어 현재는 항상 비어 있다. #17이 머지되면 이 메서드만 채우면 된다. + */ + private static String thumbnailUrlOf(Long thumbnailFileId) { + return null; + } +} diff --git a/api/app-api/src/main/java/kr/ac/kookmin/stream/event/EventListRequest.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/event/EventListRequest.java new file mode 100644 index 0000000..ece4d1b --- /dev/null +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/event/EventListRequest.java @@ -0,0 +1,32 @@ +package kr.ac.kookmin.stream.event; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import kr.ac.kookmin.stream.CursorCodec; +import kr.ac.kookmin.stream.event.domain.event.domain.EventCursor; +import kr.ac.kookmin.stream.event.domain.event.domain.RecruitStatus; + +public record EventListRequest( + String cursor, + + @Min(value = 1, message = "조회 개수는 1 이상 100 이하여야 합니다.") + @Max(value = 100, message = "조회 개수는 1 이상 100 이하여야 합니다.") + Integer size, + + String recruitStatus +) { + + private static final int DEFAULT_SIZE = 20; + + public EventCursor toCursor() { + return cursor == null ? null : EventCursor.from(CursorCodec.decode(cursor)); + } + + public RecruitStatus toRecruitStatus() { + return RecruitStatus.from(recruitStatus); + } + + public int sizeOrDefault() { + return size == null ? DEFAULT_SIZE : size; + } +} diff --git a/api/common-api/src/main/java/kr/ac/kookmin/stream/CursorCodec.java b/api/common-api/src/main/java/kr/ac/kookmin/stream/CursorCodec.java new file mode 100644 index 0000000..51e5245 --- /dev/null +++ b/api/common-api/src/main/java/kr/ac/kookmin/stream/CursorCodec.java @@ -0,0 +1,25 @@ +package kr.ac.kookmin.stream; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import kr.ac.kookmin.stream.common.BusinessException; +import kr.ac.kookmin.stream.common.CommonErrorCode; + +// 커서 문자열을 클라이언트에게 불투명한 토큰으로 감싼다. 실제 정렬 키 파싱은 각 도메인이 담당하고, +// 여기서는 웹(쿼리 파라미터)으로 오가는 형태(Base64 URL-safe)만 다룬다. +public final class CursorCodec { + + private CursorCodec() {} + + public static String encode(String raw) { + return Base64.getUrlEncoder().withoutPadding().encodeToString(raw.getBytes(StandardCharsets.UTF_8)); + } + + public static String decode(String cursor) { + try { + return new String(Base64.getUrlDecoder().decode(cursor), StandardCharsets.UTF_8); + } catch (IllegalArgumentException e) { + throw new BusinessException(CommonErrorCode.INVALID_INPUT); + } + } +} diff --git a/api/common-api/src/main/java/kr/ac/kookmin/stream/CursorSliceResponse.java b/api/common-api/src/main/java/kr/ac/kookmin/stream/CursorSliceResponse.java index 21e6277..019667d 100644 --- a/api/common-api/src/main/java/kr/ac/kookmin/stream/CursorSliceResponse.java +++ b/api/common-api/src/main/java/kr/ac/kookmin/stream/CursorSliceResponse.java @@ -3,7 +3,7 @@ import java.util.List; import kr.ac.kookmin.stream.common.CursorSliceResult; -public record CursorSliceResponse(List content, boolean hasNext, Long nextCursor) { +public record CursorSliceResponse(List content, boolean hasNext, String nextCursor) { public static CursorSliceResponse from(CursorSliceResult result) { return new CursorSliceResponse<>( diff --git a/core/common/src/main/java/kr/ac/kookmin/stream/common/CursorSliceResult.java b/core/common/src/main/java/kr/ac/kookmin/stream/common/CursorSliceResult.java index 4bd0d3d..556170c 100644 --- a/core/common/src/main/java/kr/ac/kookmin/stream/common/CursorSliceResult.java +++ b/core/common/src/main/java/kr/ac/kookmin/stream/common/CursorSliceResult.java @@ -2,4 +2,4 @@ import java.util.List; -public record CursorSliceResult(List content, boolean hasNext, Long nextCursor) {} +public record CursorSliceResult(List content, boolean hasNext, String nextCursor) {} 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 d786e3d..3d52827 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 @@ -1,6 +1,7 @@ package kr.ac.kookmin.stream.event.domain.event.domain; import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; import java.util.List; import lombok.AccessLevel; import lombok.AllArgsConstructor; @@ -25,6 +26,7 @@ public class Event { private List imageIds; private int capacity; private RecruitStatus recruitStatus; + private boolean published; private Long createdBy; public static Event of( @@ -41,11 +43,12 @@ public static Event of( List imageIds, int capacity, RecruitStatus recruitStatus, + boolean published, Long createdBy ) { return new Event( id, title, description, target, place, eventStartAt, eventEndAt, applyStartAt, - applyEndAt, recruitType, imageIds, capacity, recruitStatus, createdBy + applyEndAt, recruitType, imageIds, capacity, recruitStatus, published, createdBy ); } @@ -70,6 +73,22 @@ public RecruitStatus calculateRecruitStatus(LocalDateTime now, long appliedCount return RecruitStatus.OPEN; } + /** + * 신청 마감까지 남은 날짜 수. 목록·상세가 같은 D-Day를 보여야 하므로 도메인에 둔다. + *

+ * 시각이 아니라 날짜 단위로 세므로 마감 당일은 언제든 0이다. D-Day 배지는 모집 중일 때만 노출하기로 해 + * 그 외 상태에서는 값을 내려보내지 않는다. + * + * @param recruitStatus {@link #calculateRecruitStatus}로 이미 계산해둔 모집 상태 + * @return 모집 중이면 남은 날짜 수, 그 외에는 {@code null} + */ + public Integer daysUntilDeadline(LocalDateTime now, RecruitStatus recruitStatus) { + if (recruitStatus != RecruitStatus.OPEN) { + return null; + } + return (int) ChronoUnit.DAYS.between(now.toLocalDate(), applyEndAt.toLocalDate()); + } + /** * 정원이 찼는지 판정한다. 선착순 모집에만 정원 제한이 있고, 상시 모집은 인원 제한이 없다. */ diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventCursor.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventCursor.java new file mode 100644 index 0000000..1703f01 --- /dev/null +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventCursor.java @@ -0,0 +1,35 @@ +package kr.ac.kookmin.stream.event.domain.event.domain; + +import java.time.LocalDateTime; +import kr.ac.kookmin.stream.common.BusinessException; + +/** + * 행사 목록의 keyset 커서. 정렬 기준(행사 시작 일시 오름차순 + eventId 오름차순)과 짝을 이룬다. + */ +public record EventCursor(LocalDateTime eventStartAt, Long eventId) { + + private static final String JOIN = "|"; + private static final String SPLIT_REGEX = "\\|"; + private static final int PART_COUNT = 2; + + public static EventCursor of(Event event) { + return new EventCursor(event.getEventStartAt(), event.getId()); + } + + // Base64 인코딩은 웹(Controller) 계층 책임이라 여기서는 순수 문자열 표현만 다룬다 + public static EventCursor from(String raw) { + String[] parts = raw.split(SPLIT_REGEX, -1); + if (parts.length != PART_COUNT) { + throw new BusinessException(EventErrorCode.EVENT_INVALID_CURSOR); + } + try { + return new EventCursor(LocalDateTime.parse(parts[0]), Long.valueOf(parts[1])); + } catch (RuntimeException e) { + throw new BusinessException(EventErrorCode.EVENT_INVALID_CURSOR); + } + } + + public String format() { + return eventStartAt + JOIN + eventId; + } +} diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventDetail.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventDetail.java new file mode 100644 index 0000000..ce6ae46 --- /dev/null +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventDetail.java @@ -0,0 +1,42 @@ +package kr.ac.kookmin.stream.event.domain.event.domain; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 행사 상세 한 건. 목록과 마찬가지로 모집 상태와 마감까지 남은 일수는 저장값이 아니라 조회 시점 기준으로 계산한다. + */ +public record EventDetail( + Long eventId, + String title, + String description, + String target, + String place, + LocalDateTime eventStartAt, + LocalDateTime eventEndAt, + LocalDateTime applyStartAt, + LocalDateTime applyEndAt, + List imageIds, + RecruitStatus recruitStatus, + Integer daysUntilDeadline +) { + + public static EventDetail of(Event event, long applicantCount, LocalDateTime now) { + // 모집 상태 판정은 목록·폼 조회·신청과 같은 기준을 써야 하므로 Event의 계산을 그대로 쓴다 + RecruitStatus recruitStatus = event.calculateRecruitStatus(now, applicantCount); + return new EventDetail( + event.getId(), + event.getTitle(), + event.getDescription(), + event.getTarget(), + event.getPlace(), + event.getEventStartAt(), + event.getEventEndAt(), + event.getApplyStartAt(), + event.getApplyEndAt(), + event.getImageIds() == null ? List.of() : event.getImageIds(), + recruitStatus, + event.daysUntilDeadline(now, recruitStatus) + ); + } +} 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 e9e0901..95b89e7 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 @@ -15,7 +15,9 @@ public enum EventErrorCode implements ErrorCode { ALREADY_CLOSED(ErrorStatus.CONFLICT, "행사 마감되었습니다."), CAPACITY_FULL(ErrorStatus.CONFLICT, "모집 정원이 마감되었습니다."), ALREADY_APPLIED(ErrorStatus.CONFLICT, "이미 신청한 행사입니다."), - INVALID_ANSWER(ErrorStatus.BAD_REQUEST, "신청서 답변 형식이 올바르지 않습니다."); + INVALID_ANSWER(ErrorStatus.BAD_REQUEST, "신청서 답변 형식이 올바르지 않습니다."), + EVENT_INVALID_CURSOR(ErrorStatus.BAD_REQUEST, "유효하지 않은 커서입니다."), + EVENT_INVALID_RECRUIT_STATUS(ErrorStatus.BAD_REQUEST, "유효하지 않은 모집 상태입니다."); private final int status; private final String message; diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventSummary.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventSummary.java new file mode 100644 index 0000000..73abff0 --- /dev/null +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventSummary.java @@ -0,0 +1,40 @@ +package kr.ac.kookmin.stream.event.domain.event.domain; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 행사 목록 한 건. 모집 상태와 마감까지 남은 일수는 저장값이 아니라 조회 시점 기준으로 계산한다. + */ +public record EventSummary( + Long eventId, + String title, + String target, + LocalDateTime eventStartAt, + Long thumbnailFileId, + LocalDateTime applyStartAt, + LocalDateTime applyEndAt, + RecruitStatus recruitStatus, + Integer daysUntilDeadline +) { + + public static EventSummary of(Event event, long applicantCount, LocalDateTime now) { + // 모집 상태 판정은 폼 조회·신청과 같은 기준을 써야 하므로 Event의 계산을 그대로 쓴다 + RecruitStatus recruitStatus = event.calculateRecruitStatus(now, applicantCount); + return new EventSummary( + event.getId(), + event.getTitle(), + event.getTarget(), + event.getEventStartAt(), + thumbnailFileIdOf(event.getImageIds()), + event.getApplyStartAt(), + event.getApplyEndAt(), + recruitStatus, + event.daysUntilDeadline(now, recruitStatus) + ); + } + + private static Long thumbnailFileIdOf(List imageIds) { + return imageIds == null || imageIds.isEmpty() ? null : imageIds.getFirst(); + } +} diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/RecruitStatus.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/RecruitStatus.java index 8ebb86d..9c7bea8 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/RecruitStatus.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/RecruitStatus.java @@ -1,7 +1,24 @@ package kr.ac.kookmin.stream.event.domain.event.domain; +import kr.ac.kookmin.stream.common.BusinessException; + public enum RecruitStatus { BEFORE_OPEN, OPEN, - CLOSED + CLOSED; + + /** + * 목록 조회 필터로 들어온 문자열을 모집 상태로 바꾼다. 값이 없으면 필터를 걸지 않는다는 뜻이라 null을 돌려준다. + * 잘못된 값에 500이 나가지 않도록 여기서 걸러 BusinessException으로 바꾼다. + */ + public static RecruitStatus from(String value) { + if (value == null) { + return null; + } + try { + return RecruitStatus.valueOf(value); + } catch (IllegalArgumentException e) { + throw new BusinessException(EventErrorCode.EVENT_INVALID_RECRUIT_STATUS); + } + } } 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 3d0b2d9..87f69e2 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 @@ -1,16 +1,43 @@ package kr.ac.kookmin.stream.event.domain.event.repository; +import java.time.LocalDateTime; import java.util.List; +import java.util.Map; import java.util.Optional; +import kr.ac.kookmin.stream.common.CursorSliceResult; 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.EventCursor; import kr.ac.kookmin.stream.event.domain.event.domain.EventQuestion; +import kr.ac.kookmin.stream.event.domain.event.domain.RecruitStatus; public interface EventRepository { Optional findById(Long id); + /** + * 게시되고 삭제되지 않은 행사 한 건을 조회한다. 아직 공개하지 않은 행사는 학생에게 없는 것으로 보여야 한다. + */ + Optional findPublishedById(Long id); + + /** + * 게시되고 삭제되지 않은 행사를 행사 시작 일시 오름차순(동일 시각은 eventId 오름차순)으로 조회한다. + * recruitStatus가 주어지면 {@code now} 기준으로 계산한 모집 상태가 일치하는 행사만 남긴다. + */ + CursorSliceResult findPublishedSlice( + RecruitStatus recruitStatus, + EventCursor cursor, + int size, + LocalDateTime now + ); + + /** + * 행사별 유효 신청자 수를 한 번에 조회한다. 목록처럼 여러 행사의 신청자 수가 필요할 때 N+1을 피하기 위한 것으로, + * 신청이 한 건도 없는 행사는 결과에 담기지 않는다. + */ + Map countAppliedByEventIds(List eventIds); + List findQuestionsByEventId(Long eventId); long countAppliedByEventId(Long eventId); diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/EventService.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/EventService.java index f9bd920..a274958 100644 --- a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/EventService.java +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/EventService.java @@ -1,11 +1,20 @@ package kr.ac.kookmin.stream.event.domain.event.service; +import kr.ac.kookmin.stream.common.CursorSliceResult; import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationForm; import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationResult; import kr.ac.kookmin.stream.event.domain.event.domain.EventApplyCommand; +import kr.ac.kookmin.stream.event.domain.event.domain.EventCursor; +import kr.ac.kookmin.stream.event.domain.event.domain.EventDetail; +import kr.ac.kookmin.stream.event.domain.event.domain.EventSummary; +import kr.ac.kookmin.stream.event.domain.event.domain.RecruitStatus; public interface EventService { + CursorSliceResult getPublishedEvents(RecruitStatus recruitStatus, EventCursor cursor, int size); + + EventDetail getPublishedEvent(Long eventId); + EventApplicationForm getApplicationForm(Long eventId); EventApplicationResult apply(Long eventId, Long memberId, EventApplyCommand command); diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImpl.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImpl.java index 286f737..acdc5ca 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 @@ -2,15 +2,20 @@ 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.CursorSliceResult; import kr.ac.kookmin.stream.event.domain.event.domain.Event; import kr.ac.kookmin.stream.event.domain.event.domain.EventApplication; import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationAnswer; import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationForm; import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationResult; import kr.ac.kookmin.stream.event.domain.event.domain.EventApplyCommand; +import kr.ac.kookmin.stream.event.domain.event.domain.EventCursor; +import kr.ac.kookmin.stream.event.domain.event.domain.EventDetail; import kr.ac.kookmin.stream.event.domain.event.domain.EventErrorCode; import kr.ac.kookmin.stream.event.domain.event.domain.EventQuestion; +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.repository.EventRepository; import kr.ac.kookmin.stream.event.domain.event.service.EventService; @@ -25,6 +30,38 @@ class EventServiceImpl implements EventService { private final EventRepository eventRepository; private final EventApplyAnswerValidator eventApplyAnswerValidator; + @Override + @Transactional(readOnly = true) + public CursorSliceResult getPublishedEvents( + RecruitStatus recruitStatus, + EventCursor cursor, + int size + ) { + // 필터링(DB)과 응답(도메인)이 같은 시각을 봐야 모집 상태가 어긋나지 않는다 + LocalDateTime now = LocalDateTime.now(); + CursorSliceResult slice = eventRepository.findPublishedSlice(recruitStatus, cursor, size, now); + + // 신청자 수는 한 번에 모아 조회한다. 행사마다 따로 세면 페이지 크기만큼 쿼리가 더 나간다 + Map applicantCounts = eventRepository.countAppliedByEventIds( + slice.content().stream().map(Event::getId).toList()); + + List content = slice.content().stream() + .map(event -> EventSummary.of(event, applicantCounts.getOrDefault(event.getId(), 0L), now)) + .toList(); + + return new CursorSliceResult<>(content, slice.hasNext(), slice.nextCursor()); + } + + @Override + @Transactional(readOnly = true) + public EventDetail getPublishedEvent(Long eventId) { + Event event = eventRepository.findPublishedById(eventId) + .orElseThrow(() -> new BusinessException(EventErrorCode.EVENT_NOT_FOUND)); + + LocalDateTime now = LocalDateTime.now(); + return EventDetail.of(event, eventRepository.countAppliedByEventId(eventId), now); + } + @Override @Transactional(readOnly = true) public EventApplicationForm getApplicationForm(Long eventId) { @@ -55,10 +92,11 @@ public EventApplicationResult apply(Long eventId, Long memberId, EventApplyComma /** * 모집 중인 행사를 가져온다. 폼 조회와 신청이 같은 기준으로 열려 있어야 하므로 한곳에 둔다. *

+ * 아직 게시하지 않은 행사는 학생에게 없는 것으로 보여야 하므로 목록·상세와 같은 기준으로 거른다. * 닫혀 있으면 사유를 가른다. 강제 마감·기간 종료가 정원 마감보다 앞선 사유다. */ private Event getOpenEvent(Long eventId) { - Event event = eventRepository.findById(eventId) + Event event = eventRepository.findPublishedById(eventId) .orElseThrow(() -> new BusinessException(EventErrorCode.EVENT_NOT_FOUND)); LocalDateTime now = LocalDateTime.now(); diff --git a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventCursorTest.java b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventCursorTest.java new file mode 100644 index 0000000..108a8e8 --- /dev/null +++ b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventCursorTest.java @@ -0,0 +1,53 @@ +package kr.ac.kookmin.stream.event.domain.event.domain; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; + +import java.time.LocalDateTime; +import java.util.List; +import kr.ac.kookmin.stream.common.BusinessException; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +class EventCursorTest { + + private static final LocalDateTime EVENT_START_AT = LocalDateTime.of(2026, 9, 12, 10, 0); + + @Test + @DisplayName("문자열로 바꿨다가 되돌리면 같은 커서다") + void roundTrip() { + EventCursor cursor = new EventCursor(EVENT_START_AT, 42L); + + assertEquals(cursor, EventCursor.from(cursor.format())); + } + + @Test + @DisplayName("행사로부터 정렬 키를 그대로 딴다") + void fromEvent() { + Event event = Event.of( + 42L, "행사", "설명", "전교생", "학생회관", + EVENT_START_AT, EVENT_START_AT.plusHours(2), EVENT_START_AT.minusDays(7), EVENT_START_AT.minusDays(1), + RecruitType.OPEN, List.of(), 0, RecruitStatus.OPEN, true, 100L + ); + + assertEquals(new EventCursor(EVENT_START_AT, 42L), EventCursor.of(event)); + } + + @ParameterizedTest + @DisplayName("형식이 어긋나면 커서 오류를 던진다") + @ValueSource(strings = { + "", + "2026-09-12T10:00", + "2026-09-12T10:00|42|extra", + "not-a-date|42", + "2026-09-12T10:00|not-a-number", + "|42" + }) + void invalidFormat(String raw) { + BusinessException e = assertThrows(BusinessException.class, () -> EventCursor.from(raw)); + + assertEquals(EventErrorCode.EVENT_INVALID_CURSOR, e.getErrorCode()); + } +} diff --git a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventDetailTest.java b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventDetailTest.java new file mode 100644 index 0000000..bea8235 --- /dev/null +++ b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventDetailTest.java @@ -0,0 +1,116 @@ +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.assertTrue; + +import java.time.LocalDateTime; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class EventDetailTest { + + private static final LocalDateTime NOW = LocalDateTime.of(2026, 9, 12, 10, 0); + + private static Event event(RecruitType recruitType, RecruitStatus recruitStatus, List imageIds) { + return Event.of( + 15L, "2026 소융대 개강 행사", "소융대 재학생을 위한 개강 행사입니다.", "소융대 재학생", "101호", + NOW.plusDays(10), NOW.plusDays(10).plusHours(2), NOW.minusDays(1), NOW.plusDays(3), + recruitType, imageIds, 30, recruitStatus, true, 1L + ); + } + + private static Event openEvent() { + return event(RecruitType.OPEN, RecruitStatus.OPEN, List.of()); + } + + @Test + @DisplayName("행사의 상세 필드를 그대로 옮긴다") + void copiesDetailFields() { + EventDetail detail = EventDetail.of(openEvent(), 0, NOW); + + assertEquals(15L, detail.eventId()); + assertEquals("2026 소융대 개강 행사", detail.title()); + assertEquals("소융대 재학생을 위한 개강 행사입니다.", detail.description()); + assertEquals("소융대 재학생", detail.target()); + assertEquals("101호", detail.place()); + assertEquals(NOW.plusDays(10), detail.eventStartAt()); + assertEquals(NOW.plusDays(10).plusHours(2), detail.eventEndAt()); + assertEquals(NOW.minusDays(1), detail.applyStartAt()); + assertEquals(NOW.plusDays(3), detail.applyEndAt()); + } + + /** + * 모집 상태 판정 규칙 자체는 {@code EventTest}가 덮는다. + * 여기서는 EventDetail이 저장값을 그대로 베끼지 않고 Event의 계산을 거치는지만 확인한다. + */ + @Nested + @DisplayName("모집 상태 위임") + class RecruitStatusDelegation { + + @Test + @DisplayName("저장된 모집 상태가 아니라 조회 시점 기준으로 계산한 값을 싣는다") + void computesInsteadOfCopying() { + Event event = event(RecruitType.OPEN, RecruitStatus.BEFORE_OPEN, List.of()); + + assertEquals(RecruitStatus.BEFORE_OPEN, event.getRecruitStatus()); + assertEquals(RecruitStatus.OPEN, EventDetail.of(event, 0, NOW).recruitStatus()); + } + + @Test + @DisplayName("신청자 수를 함께 넘겨 정원 마감까지 반영한다") + void passesApplicantCount() { + Event event = event(RecruitType.FIRST_COME, RecruitStatus.OPEN, List.of()); + + assertEquals(RecruitStatus.OPEN, EventDetail.of(event, 29, NOW).recruitStatus()); + assertEquals(RecruitStatus.CLOSED, EventDetail.of(event, 30, NOW).recruitStatus()); + } + } + + @Nested + @DisplayName("마감까지 남은 일수") + class DaysUntilDeadline { + + @Test + @DisplayName("모집 중이면 목록과 같은 값을 싣는다") + void sameAsSummary() { + Event event = openEvent(); + + assertEquals( + EventSummary.of(event, 0, NOW).daysUntilDeadline(), + EventDetail.of(event, 0, NOW).daysUntilDeadline()); + } + + @Test + @DisplayName("모집 중이 아니면 내려보내지 않는다") + void nullWhenNotOpen() { + Event forceClosed = event(RecruitType.OPEN, RecruitStatus.CLOSED, List.of()); + + assertNull(EventDetail.of(forceClosed, 0, NOW).daysUntilDeadline()); + } + } + + @Nested + @DisplayName("이미지 목록") + class Images { + + @Test + @DisplayName("행사 이미지 id를 순서대로 싣는다") + void keepsOrder() { + Event event = event(RecruitType.OPEN, RecruitStatus.OPEN, List.of(31L, 32L)); + + assertEquals(List.of(31L, 32L), EventDetail.of(event, 0, NOW).imageIds()); + } + + @Test + @DisplayName("이미지가 없으면 null이 아니라 빈 목록이다") + void emptyListWhenNoImage() { + assertTrue(EventDetail.of(event(RecruitType.OPEN, RecruitStatus.OPEN, List.of()), 0, NOW) + .imageIds().isEmpty()); + assertTrue(EventDetail.of(event(RecruitType.OPEN, RecruitStatus.OPEN, null), 0, NOW) + .imageIds().isEmpty()); + } + } +} diff --git a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventSummaryTest.java b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventSummaryTest.java new file mode 100644 index 0000000..09ea4c4 --- /dev/null +++ b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/EventSummaryTest.java @@ -0,0 +1,111 @@ +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 java.time.LocalDateTime; +import java.util.List; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Nested; +import org.junit.jupiter.api.Test; + +class EventSummaryTest { + + private static final LocalDateTime NOW = LocalDateTime.of(2026, 9, 12, 10, 0); + + private static Event event( + LocalDateTime applyStartAt, + LocalDateTime applyEndAt, + RecruitType recruitType, + int capacity, + RecruitStatus recruitStatus, + List imageIds + ) { + return Event.of( + 1L, "행사", "설명", "전교생", "학생회관", + NOW.plusDays(30), NOW.plusDays(31), applyStartAt, applyEndAt, + recruitType, imageIds, capacity, recruitStatus, true, 100L + ); + } + + private static Event openEvent() { + return event(NOW.minusDays(1), NOW.plusDays(3), RecruitType.OPEN, 0, RecruitStatus.OPEN, List.of()); + } + + /** + * 모집 상태 판정 규칙 자체는 {@code EventTest}가 덮는다. + * 여기서는 EventSummary가 저장값을 그대로 베끼지 않고 Event의 계산을 거치는지만 확인한다. + */ + @Nested + @DisplayName("모집 상태 위임") + class RecruitStatusDelegation { + + @Test + @DisplayName("저장된 모집 상태가 아니라 조회 시점 기준으로 계산한 값을 싣는다") + void computesInsteadOfCopying() { + Event event = event( + NOW.minusDays(1), NOW.plusDays(3), RecruitType.OPEN, 0, RecruitStatus.BEFORE_OPEN, List.of()); + + assertEquals(RecruitStatus.BEFORE_OPEN, event.getRecruitStatus()); + assertEquals(RecruitStatus.OPEN, EventSummary.of(event, 0, NOW).recruitStatus()); + } + + @Test + @DisplayName("신청자 수를 함께 넘겨 정원 마감까지 반영한다") + void passesApplicantCount() { + Event event = event( + NOW.minusDays(1), NOW.plusDays(3), RecruitType.FIRST_COME, 10, RecruitStatus.OPEN, List.of()); + + assertEquals(RecruitStatus.OPEN, EventSummary.of(event, 9, NOW).recruitStatus()); + assertEquals(RecruitStatus.CLOSED, EventSummary.of(event, 10, NOW).recruitStatus()); + } + } + + /** + * 날짜 계산 규칙 자체는 {@code EventTest}가 덮는다. 여기서는 계산 결과가 실리는지만 확인한다. + */ + @Nested + @DisplayName("마감까지 남은 일수") + class DaysUntilDeadline { + + @Test + @DisplayName("모집 중이면 Event가 계산한 값을 싣고, 그 외에는 비운다") + void carriesEventCalculation() { + Event open = event( + NOW.minusDays(1), NOW.plusDays(3), RecruitType.OPEN, 0, RecruitStatus.OPEN, List.of()); + Event closed = event( + NOW.minusDays(5), NOW.minusDays(1), RecruitType.OPEN, 0, RecruitStatus.OPEN, List.of()); + + assertEquals( + open.daysUntilDeadline(NOW, RecruitStatus.OPEN), + EventSummary.of(open, 0, NOW).daysUntilDeadline()); + assertNull(EventSummary.of(closed, 0, NOW).daysUntilDeadline()); + } + } + + @Nested + @DisplayName("대표 이미지") + class Thumbnail { + + @Test + @DisplayName("이미지 목록의 첫 번째를 대표로 쓴다") + void firstImage() { + Event event = event( + NOW.minusDays(1), NOW.plusDays(3), RecruitType.OPEN, 0, RecruitStatus.OPEN, List.of(7L, 8L, 9L)); + + assertEquals(7L, EventSummary.of(event, 0, NOW).thumbnailFileId()); + } + + @Test + @DisplayName("이미지가 없으면 대표 이미지도 없다") + void noImage() { + Event empty = event( + NOW.minusDays(1), NOW.plusDays(3), RecruitType.OPEN, 0, RecruitStatus.OPEN, List.of()); + Event nullImages = event( + NOW.minusDays(1), NOW.plusDays(3), RecruitType.OPEN, 0, RecruitStatus.OPEN, null); + + assertNull(EventSummary.of(empty, 0, NOW).thumbnailFileId()); + assertNull(EventSummary.of(nullImages, 0, NOW).thumbnailFileId()); + } + } +} 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 541122d..0a1e82d 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 @@ -2,6 +2,7 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; import static org.junit.jupiter.api.Assertions.assertTrue; import java.time.LocalDateTime; @@ -21,7 +22,7 @@ private static Event event(RecruitType recruitType, RecruitStatus recruitStatus) 1L, "2026 소융대 개강 행사", "설명", "소융대 재학생", "101호", LocalDateTime.of(2026, 9, 15, 18, 0), LocalDateTime.of(2026, 9, 15, 20, 0), APPLY_START_AT, APPLY_END_AT, - recruitType, List.of(), CAPACITY, recruitStatus, 1L + recruitType, List.of(), CAPACITY, recruitStatus, true, 1L ); } @@ -97,6 +98,50 @@ void openRecruitIgnoresCapacity() { } } + @Nested + @DisplayName("마감까지 남은 일수") + class DaysUntilDeadline { + + // 신청 마감은 APPLY_END_AT = 2026-09-10 18:00 + + @Test + @DisplayName("모집 중이면 마감일까지 남은 날짜 수를 센다") + void countsRemainingDays() { + Event event = firstComeEvent(); + + assertEquals(2, event.daysUntilDeadline( + LocalDateTime.of(2026, 9, 8, 10, 0), RecruitStatus.OPEN)); + } + + @Test + @DisplayName("마감 당일이면 0이다") + void deadlineToday() { + Event event = firstComeEvent(); + + assertEquals(0, event.daysUntilDeadline( + LocalDateTime.of(2026, 9, 10, 9, 0), RecruitStatus.OPEN)); + } + + @Test + @DisplayName("시각이 아니라 날짜로 세므로 19시간 뒤 마감이어도 하루 뒤면 1이다") + void countsByDateNotByHours() { + Event event = firstComeEvent(); + + assertEquals(1, event.daysUntilDeadline( + LocalDateTime.of(2026, 9, 9, 23, 0), RecruitStatus.OPEN)); + } + + @Test + @DisplayName("모집 중이 아니면 내려보내지 않는다") + void nullWhenNotOpen() { + Event event = firstComeEvent(); + LocalDateTime now = LocalDateTime.of(2026, 9, 8, 10, 0); + + assertNull(event.daysUntilDeadline(now, RecruitStatus.BEFORE_OPEN)); + assertNull(event.daysUntilDeadline(now, RecruitStatus.CLOSED)); + } + } + @Nested @DisplayName("정원 마감 여부") class CapacityFull { diff --git a/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/RecruitStatusTest.java b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/RecruitStatusTest.java new file mode 100644 index 0000000..8752719 --- /dev/null +++ b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/domain/RecruitStatusTest.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 RecruitStatusTest { + + @Test + @DisplayName("값이 없으면 필터를 걸지 않는다는 뜻이라 null을 돌려준다") + void nullMeansNoFilter() { + assertNull(RecruitStatus.from(null)); + } + + @ParameterizedTest + @DisplayName("정의된 이름은 그대로 바뀐다") + @EnumSource(RecruitStatus.class) + void parsesDefinedNames(RecruitStatus status) { + assertEquals(status, RecruitStatus.from(status.name())); + } + + @ParameterizedTest + @DisplayName("정의되지 않은 값은 500이 아니라 400으로 걸러진다") + @ValueSource(strings = {"open", " OPEN", "OPENED", "1", "-"}) + void rejectsUndefinedValues(String raw) { + BusinessException e = assertThrows(BusinessException.class, () -> RecruitStatus.from(raw)); + + assertEquals(EventErrorCode.EVENT_INVALID_RECRUIT_STATUS, e.getErrorCode()); + } +} 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 new file mode 100644 index 0000000..9f86ce1 --- /dev/null +++ b/core/domain/event/src/test/java/kr/ac/kookmin/stream/event/domain/event/service/impl/EventServiceImplTest.java @@ -0,0 +1,176 @@ +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.assertTrue; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import kr.ac.kookmin.stream.common.CursorSliceResult; +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.EventCursor; +import kr.ac.kookmin.stream.event.domain.event.domain.EventQuestion; +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.EventRepository; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +/** + * 행사 목록은 행사와 신청자 수를 각각 조회해 서비스에서 짝지으므로, 그 조합이 어긋나지 않는지 확인한다. + */ +class EventServiceImplTest { + + private static final LocalDateTime NOW = LocalDateTime.now(); + + private static Event event(Long id) { + return Event.of( + id, "행사 " + id, "설명", "전교생", "101호", + NOW.plusDays(10), NOW.plusDays(10).plusHours(2), NOW.minusDays(1), NOW.plusDays(3), + RecruitType.FIRST_COME, List.of(), 100, RecruitStatus.OPEN, true, 1L + ); + } + + @Test + @DisplayName("행사마다 자기 신청자 수를 붙인다") + void matchesApplicantCountToItsEvent() { + FakeEventRepository repository = new FakeEventRepository( + new CursorSliceResult<>(List.of(event(1L), event(2L)), false, null), + Map.of(1L, 3L, 2L, 7L)); + + List content = new EventServiceImpl(repository, null) + .getPublishedEvents(null, null, 20).content(); + + // 정원 100명이라 신청자 수가 모집 상태를 바꾸지 않는다. 짝이 맞는지는 D-Day가 아니라 아래에서 직접 본다 + assertEquals(List.of(1L, 2L), content.stream().map(EventSummary::eventId).toList()); + assertEquals(List.of(1L, 2L), repository.requestedEventIds); + } + + @Test + @DisplayName("신청이 한 건도 없는 행사도 0명으로 채워 목록에서 빠지지 않는다") + void keepsEventsWithoutApplicants() { + // 집계 쿼리는 신청이 없는 행사를 아예 돌려주지 않으므로 2번 행사는 맵에 없다 + FakeEventRepository repository = new FakeEventRepository( + new CursorSliceResult<>(List.of(event(1L), event(2L)), false, null), + Map.of(1L, 3L)); + + List content = new EventServiceImpl(repository, null) + .getPublishedEvents(null, null, 20).content(); + + assertEquals(2, content.size()); + assertEquals(List.of(1L, 2L), content.stream().map(EventSummary::eventId).toList()); + } + + @Test + @DisplayName("정원이 찬 행사만 마감으로 바뀐다") + void appliesCountPerEvent() { + Event full = Event.of( + 1L, "정원 3명", "설명", "전교생", "101호", + NOW.plusDays(10), null, NOW.minusDays(1), NOW.plusDays(3), + RecruitType.FIRST_COME, List.of(), 3, RecruitStatus.OPEN, true, 1L); + Event roomy = Event.of( + 2L, "정원 100명", "설명", "전교생", "101호", + NOW.plusDays(10), null, NOW.minusDays(1), NOW.plusDays(3), + RecruitType.FIRST_COME, List.of(), 100, RecruitStatus.OPEN, true, 1L); + + FakeEventRepository repository = new FakeEventRepository( + new CursorSliceResult<>(List.of(full, roomy), false, null), + Map.of(1L, 3L, 2L, 3L)); + + List content = new EventServiceImpl(repository, null) + .getPublishedEvents(null, null, 20).content(); + + assertEquals(RecruitStatus.CLOSED, content.get(0).recruitStatus()); + assertEquals(RecruitStatus.OPEN, content.get(1).recruitStatus()); + } + + @Test + @DisplayName("hasNext와 nextCursor는 조회 결과를 그대로 싣는다") + void carriesSliceMetadata() { + FakeEventRepository repository = new FakeEventRepository( + new CursorSliceResult<>(List.of(event(1L)), true, "커서"), Map.of()); + + CursorSliceResult result = new EventServiceImpl(repository, null) + .getPublishedEvents(null, null, 20); + + assertTrue(result.hasNext()); + assertEquals("커서", result.nextCursor()); + } + + @Test + @DisplayName("조회 결과가 없으면 신청자 수도 빈 목록으로 물어본다") + void asksNothingWhenSliceIsEmpty() { + FakeEventRepository repository = new FakeEventRepository( + new CursorSliceResult<>(List.of(), false, null), Map.of()); + + CursorSliceResult result = new EventServiceImpl(repository, null) + .getPublishedEvents(null, null, 20); + + assertTrue(result.content().isEmpty()); + assertEquals(List.of(), repository.requestedEventIds); + } + + /** 목록 조회에 쓰이는 두 메서드만 답하고, 나머지는 이 테스트가 건드리지 않는다. */ + private static final class FakeEventRepository implements EventRepository { + + private final CursorSliceResult slice; + private final Map applicantCounts; + private List requestedEventIds; + + private FakeEventRepository(CursorSliceResult slice, Map applicantCounts) { + this.slice = slice; + this.applicantCounts = applicantCounts; + } + + @Override + public CursorSliceResult findPublishedSlice( + RecruitStatus recruitStatus, EventCursor cursor, int size, LocalDateTime now) { + return slice; + } + + @Override + public Map countAppliedByEventIds(List eventIds) { + this.requestedEventIds = eventIds; + return applicantCounts; + } + + @Override + public Optional findById(Long id) { + throw new UnsupportedOperationException(); + } + + @Override + public Optional findPublishedById(Long id) { + throw new UnsupportedOperationException(); + } + + @Override + public List findQuestionsByEventId(Long eventId) { + throw new UnsupportedOperationException(); + } + + @Override + public long countAppliedByEventId(Long eventId) { + throw new UnsupportedOperationException(); + } + + @Override + public boolean existsAppliedByEventIdAndMemberId(Long eventId, Long memberId) { + throw new UnsupportedOperationException(); + } + + @Override + public EventApplication saveApplication(EventApplication application) { + throw new UnsupportedOperationException(); + } + + @Override + public List saveAnswers(List answers) { + throw new UnsupportedOperationException(); + } + } +} diff --git a/docs/conventions/coding-style.md b/docs/conventions/coding-style.md index ccc5d9b..ffd2ce2 100644 --- a/docs/conventions/coding-style.md +++ b/docs/conventions/coding-style.md @@ -115,10 +115,11 @@ public final class ApiResponse { | 오프셋 | 페이지 번호·전체 개수·전체 페이지 필요 | `PageResult` / `PageResponse` | - 커서 기반 응답 필드는 항상 `content`/`hasNext`/`nextCursor`로 통일한다. +- `nextCursor`는 **클라이언트에게 불투명한 문자열**이다. 정렬 키가 여러 개인 keyset 커서를 담아야 하므로 도메인이 `{Domain}Cursor` record로 정렬 키와 그 문자열 표현(`format()`/`from(String)`)을 소유하고, Base64 URL-safe 인코딩은 웹 계층에서 `CursorCodec`(`api:common-api`)으로 처리한다. ```java // core:common -public record CursorSliceResult(List content, boolean hasNext, Long nextCursor) {} +public record CursorSliceResult(List content, boolean hasNext, String nextCursor) {} public record PageResult(List content, int page, int size, long totalCount, int totalPage) {} ``` diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventApplicantCountRow.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventApplicantCountRow.java new file mode 100644 index 0000000..573024a --- /dev/null +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventApplicantCountRow.java @@ -0,0 +1,6 @@ +package kr.ac.kookmin.stream.db.event; + +/** + * 행사별 유효 신청자 수 집계 결과. JPQL 생성자 표현식으로 채워지는 조회 전용 프로젝션이다. + */ +public record EventApplicantCountRow(Long eventId, Long applicantCount) {} diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventJpaEntity.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventJpaEntity.java index 542c3ce..204032a 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventJpaEntity.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventJpaEntity.java @@ -7,6 +7,7 @@ import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; +import jakarta.persistence.Index; import jakarta.persistence.Table; import java.time.LocalDateTime; import java.util.List; @@ -20,7 +21,13 @@ import org.hibernate.type.SqlTypes; @Entity -@Table(name = "events") +@Table( + name = "events", + indexes = @Index( + name = "idx_events_is_published_is_deleted_event_start_at", + columnList = "is_published, is_deleted, event_start_at" + ) +) @NoArgsConstructor(access = AccessLevel.PROTECTED) public class EventJpaEntity extends BaseSoftDeleteEntity { @@ -67,6 +74,9 @@ public class EventJpaEntity extends BaseSoftDeleteEntity { @Column(name = "recruit_status", nullable = false, length = 30) private RecruitStatus recruitStatus; + @Column(name = "is_published", nullable = false) + private boolean isPublished; + @Column(name = "created_by", nullable = false) private Long createdBy; @@ -84,6 +94,7 @@ private EventJpaEntity(Event event) { this.imageIds = event.getImageIds(); this.capacity = event.getCapacity(); this.recruitStatus = event.getRecruitStatus(); + this.isPublished = event.isPublished(); this.createdBy = event.getCreatedBy(); } @@ -93,6 +104,7 @@ public static EventJpaEntity from(Event event) { public Event toDomain() { return Event.of(id, title, description, target, place, eventStartAt, eventEndAt, - applyStartAt, applyEndAt, recruitType, imageIds, capacity, recruitStatus, createdBy); + applyStartAt, applyEndAt, recruitType, imageIds, capacity, recruitStatus, isPublished, + createdBy); } } diff --git a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventJpaRepository.java b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventJpaRepository.java index 71c6628..d387ad0 100644 --- a/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventJpaRepository.java +++ b/infrastructure/db/src/main/java/kr/ac/kookmin/stream/db/event/EventJpaRepository.java @@ -1,9 +1,79 @@ package kr.ac.kookmin.stream.db.event; +import java.time.LocalDateTime; +import java.util.List; import java.util.Optional; +import kr.ac.kookmin.stream.event.domain.event.domain.EventApplicationStatus; +import kr.ac.kookmin.stream.event.domain.event.domain.RecruitStatus; +import kr.ac.kookmin.stream.event.domain.event.domain.RecruitType; +import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface EventJpaRepository extends JpaRepository { Optional findByIdAndIsDeletedFalse(Long id); + + Optional findByIdAndIsDeletedFalseAndIsPublishedTrue(Long id); + + /** + * 게시된 행사를 (event_start_at, event_id) keyset 커서로 조회한다. + *

+ * 모집 상태는 저장값이 아니라 강제 마감 여부·신청 기간·잔여 정원으로 판정하므로, 필터가 주어지면 + * {@code Event.calculateRecruitStatus}와 같은 규칙을 SQL로 옮겨 적용한다. 두 곳의 규칙이 어긋나면 + * 필터로 거른 결과와 응답에 실린 모집 상태가 달라지므로, 한쪽을 고치면 다른 쪽도 함께 고쳐야 한다. + */ + @Query(""" + SELECT e FROM EventJpaEntity e + WHERE e.isDeleted = false + AND e.isPublished = true + AND (:cursorEventStartAt IS NULL + OR e.eventStartAt > :cursorEventStartAt + OR (e.eventStartAt = :cursorEventStartAt AND e.id > :cursorEventId)) + AND (:filterAll = true + OR (:filterBeforeOpen = true + AND e.recruitStatus <> :forceClosedStatus + AND :now <= e.applyEndAt + AND :now < e.applyStartAt) + OR (:filterOpen = true + AND e.recruitStatus <> :forceClosedStatus + AND :now >= e.applyStartAt + AND :now <= e.applyEndAt + AND (e.recruitType <> :capacityLimitedType + OR (SELECT COUNT(a) FROM EventApplicationJpaEntity a + WHERE a.eventId = e.id AND a.status = :appliedStatus) < e.capacity)) + OR (:filterClosed = true + AND (e.recruitStatus = :forceClosedStatus + OR :now > e.applyEndAt + OR (:now >= e.applyStartAt + AND e.recruitType = :capacityLimitedType + AND (SELECT COUNT(a) FROM EventApplicationJpaEntity a + WHERE a.eventId = e.id AND a.status = :appliedStatus) >= e.capacity)))) + ORDER BY e.eventStartAt ASC, e.id ASC + """) + List findPublishedSlice( + @Param("filterAll") boolean filterAll, + @Param("filterBeforeOpen") boolean filterBeforeOpen, + @Param("filterOpen") boolean filterOpen, + @Param("filterClosed") boolean filterClosed, + @Param("forceClosedStatus") RecruitStatus forceClosedStatus, + @Param("capacityLimitedType") RecruitType capacityLimitedType, + @Param("appliedStatus") EventApplicationStatus appliedStatus, + @Param("now") LocalDateTime now, + @Param("cursorEventStartAt") LocalDateTime cursorEventStartAt, + @Param("cursorEventId") Long cursorEventId, + Pageable pageable + ); + + @Query(""" + SELECT new kr.ac.kookmin.stream.db.event.EventApplicantCountRow(a.eventId, COUNT(a)) + FROM EventApplicationJpaEntity a + WHERE a.eventId IN :eventIds AND a.status = :appliedStatus + GROUP BY a.eventId + """) + List countApplicantsByEventIds( + @Param("eventIds") List eventIds, + @Param("appliedStatus") EventApplicationStatus appliedStatus + ); } 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 bf182ad..749dff2 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 @@ -1,14 +1,22 @@ package kr.ac.kookmin.stream.db.event; +import java.time.LocalDateTime; import java.util.List; +import java.util.Map; import java.util.Optional; +import java.util.stream.Collectors; +import kr.ac.kookmin.stream.common.CursorSliceResult; 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.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.domain.RecruitType; import kr.ac.kookmin.stream.event.domain.event.repository.EventRepository; import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Repository; @Repository @@ -25,6 +33,54 @@ public Optional findById(Long id) { return eventJpaRepository.findByIdAndIsDeletedFalse(id).map(EventJpaEntity::toDomain); } + @Override + public Optional findPublishedById(Long id) { + return eventJpaRepository.findByIdAndIsDeletedFalseAndIsPublishedTrue(id).map(EventJpaEntity::toDomain); + } + + @Override + public CursorSliceResult findPublishedSlice( + RecruitStatus recruitStatus, + EventCursor cursor, + int size, + LocalDateTime now + ) { + // 다음 페이지 존재 여부를 알기 위해 한 건 더 읽는다 + Pageable pageable = Pageable.ofSize(size + 1); + List entities = eventJpaRepository.findPublishedSlice( + recruitStatus == null, + recruitStatus == RecruitStatus.BEFORE_OPEN, + recruitStatus == RecruitStatus.OPEN, + recruitStatus == RecruitStatus.CLOSED, + RecruitStatus.CLOSED, + RecruitType.FIRST_COME, + EventApplicationStatus.APPLIED, + now, + cursor == null ? null : cursor.eventStartAt(), + cursor == null ? null : cursor.eventId(), + pageable + ); + + boolean hasNext = entities.size() > size; + List events = entities.stream() + .limit(size) + .map(EventJpaEntity::toDomain) + .toList(); + + String nextCursor = hasNext ? EventCursor.of(events.getLast()).format() : null; + + return new CursorSliceResult<>(events, hasNext, nextCursor); + } + + @Override + public Map countAppliedByEventIds(List eventIds) { + if (eventIds.isEmpty()) { + return Map.of(); + } + return eventJpaRepository.countApplicantsByEventIds(eventIds, EventApplicationStatus.APPLIED).stream() + .collect(Collectors.toMap(EventApplicantCountRow::eventId, EventApplicantCountRow::applicantCount)); + } + @Override public List findQuestionsByEventId(Long eventId) { return eventQuestionJpaRepository.findAllByEventIdOrderByDisplayOrderAsc(eventId).stream() diff --git a/infrastructure/db/src/main/resources/db/migration/V5__add_is_published_to_events.sql b/infrastructure/db/src/main/resources/db/migration/V5__add_is_published_to_events.sql new file mode 100644 index 0000000..f26af62 --- /dev/null +++ b/infrastructure/db/src/main/resources/db/migration/V5__add_is_published_to_events.sql @@ -0,0 +1,7 @@ +ALTER TABLE events + ADD COLUMN is_published TINYINT(1) NOT NULL DEFAULT 0; + +-- 사용자 행사 목록: WHERE is_published = 1 AND is_deleted = 0 ORDER BY event_start_at, event_id +-- (InnoDB가 세컨더리 인덱스 끝에 PK(event_id)를 붙이므로 보조 정렬 키까지 커버된다) +CREATE INDEX idx_events_is_published_is_deleted_event_start_at + ON events (is_published, is_deleted, event_start_at);