From 9546017aaca16982a97c08811f32bea821209533 Mon Sep 17 00:00:00 2001 From: gain <163835628+leegain1@users.noreply.github.com> Date: Sat, 12 Sep 2026 16:02:01 +0900 Subject: [PATCH 1/7] =?UTF-8?q?feat:=20=EC=BB=A4=EC=84=9C=20=EC=8A=AC?= =?UTF-8?q?=EB=9D=BC=EC=9D=B4=EC=8A=A4=20=EC=9D=91=EB=8B=B5=EC=9D=98=20nex?= =?UTF-8?q?tCursor=EB=A5=BC=20=EB=B6=88=ED=88=AC=EB=AA=85=20=EB=AC=B8?= =?UTF-8?q?=EC=9E=90=EC=97=B4=EB=A1=9C=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kr/ac/kookmin/stream/CursorCodec.java | 25 +++++++++++++++++++ .../kookmin/stream/CursorSliceResponse.java | 2 +- .../stream/common/CursorSliceResult.java | 2 +- 3 files changed, 27 insertions(+), 2 deletions(-) create mode 100644 api/common-api/src/main/java/kr/ac/kookmin/stream/CursorCodec.java 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) {} From c255d9164e2bd2b5202c25aa1f5f86967bcc19f8 Mon Sep 17 00:00:00 2001 From: gain <163835628+leegain1@users.noreply.github.com> Date: Sat, 12 Sep 2026 16:02:16 +0900 Subject: [PATCH 2/7] =?UTF-8?q?feat:=20events=20=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EB=B8=94=EC=97=90=20=EA=B2=8C=EC=8B=9C=20=EC=97=AC=EB=B6=80=20?= =?UTF-8?q?=EC=BB=AC=EB=9F=BC=EA=B3=BC=20=EB=AA=A9=EB=A1=9D=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EC=9D=B8=EB=8D=B1=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../stream/event/domain/event/domain/Event.java | 4 +++- .../kookmin/stream/db/event/EventJpaEntity.java | 16 ++++++++++++++-- .../migration/V5__add_is_published_to_events.sql | 7 +++++++ 3 files changed, 24 insertions(+), 3 deletions(-) create mode 100644 infrastructure/db/src/main/resources/db/migration/V5__add_is_published_to_events.sql 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..ba2b5b7 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 @@ -25,6 +25,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 +42,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 ); } 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/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); From 6d312b10b2c9eb1c031d6364c24c7bc123c566c2 Mon Sep 17 00:00:00 2001 From: gain <163835628+leegain1@users.noreply.github.com> Date: Sat, 12 Sep 2026 16:02:42 +0900 Subject: [PATCH 3/7] =?UTF-8?q?feat:=20=ED=96=89=EC=82=AC=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=EC=9A=A9=20=EB=8F=84=EB=A9=94?= =?UTF-8?q?=EC=9D=B8=20=EB=AA=A8=EB=8D=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../event/domain/EventApplicantCount.java | 6 +++ .../domain/event/domain/EventCursor.java | 35 +++++++++++++ .../domain/event/domain/EventErrorCode.java | 3 +- .../domain/event/domain/EventSummary.java | 49 +++++++++++++++++++ 4 files changed, 92 insertions(+), 1 deletion(-) create mode 100644 core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventApplicantCount.java create mode 100644 core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventCursor.java create mode 100644 core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventSummary.java diff --git a/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventApplicantCount.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventApplicantCount.java new file mode 100644 index 0000000..015349c --- /dev/null +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventApplicantCount.java @@ -0,0 +1,6 @@ +package kr.ac.kookmin.stream.event.domain.event.domain; + +/** + * 행사와 그 행사의 유효 신청자 수(status가 APPLIED인 신청). 모집 상태 계산에 필요해 함께 조회한다. + */ +public record EventApplicantCount(Event event, long applicantCount) {} 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/EventErrorCode.java b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventErrorCode.java index e9e0901..46fc2f7 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,8 @@ 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, "유효하지 않은 커서입니다."); 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..671195d --- /dev/null +++ b/core/domain/event/src/main/java/kr/ac/kookmin/stream/event/domain/event/domain/EventSummary.java @@ -0,0 +1,49 @@ +package kr.ac.kookmin.stream.event.domain.event.domain; + +import java.time.LocalDateTime; +import java.time.temporal.ChronoUnit; +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, + calculateDaysUntilDeadline(event, recruitStatus, now) + ); + } + + // D-Day 배지는 모집 중일 때만 노출하므로 그 외 상태에서는 값을 내려보내지 않는다 + private static Integer calculateDaysUntilDeadline(Event event, RecruitStatus recruitStatus, LocalDateTime now) { + if (recruitStatus != RecruitStatus.OPEN) { + return null; + } + return (int) ChronoUnit.DAYS.between(now.toLocalDate(), event.getApplyEndAt().toLocalDate()); + } + + private static Long thumbnailFileIdOf(List imageIds) { + return imageIds == null || imageIds.isEmpty() ? null : imageIds.getFirst(); + } +} From 318ec986e4cdce59406a12255b83875462579369 Mon Sep 17 00:00:00 2001 From: gain <163835628+leegain1@users.noreply.github.com> Date: Sat, 12 Sep 2026 16:03:38 +0900 Subject: [PATCH 4/7] =?UTF-8?q?feat:=20=ED=96=89=EC=82=AC=20=EB=AA=A9?= =?UTF-8?q?=EB=A1=9D=20keyset=20=EC=A1=B0=ED=9A=8C=EC=99=80=20=EC=8B=A0?= =?UTF-8?q?=EC=B2=AD=EC=9E=90=20=EC=88=98=20=EC=A7=91=EA=B3=84=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 --- .../event/repository/EventRepository.java | 16 +++++ .../stream/db/event/EventJpaRepository.java | 67 +++++++++++++++++++ .../stream/db/event/EventRepositoryImpl.java | 57 ++++++++++++++++ 3 files changed, 140 insertions(+) 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..3427105 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,32 @@ package kr.ac.kookmin.stream.event.domain.event.repository; +import java.time.LocalDateTime; import java.util.List; 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.EventApplicantCount; 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); + /** + * 게시되고 삭제되지 않은 행사를 행사 시작 일시 오름차순(동일 시각은 eventId 오름차순)으로 조회한다. + * recruitStatus가 주어지면 {@code now} 기준으로 계산한 모집 상태가 일치하는 행사만 남긴다. + */ + CursorSliceResult findPublishedSlice( + RecruitStatus recruitStatus, + EventCursor cursor, + int size, + LocalDateTime now + ); + List findQuestionsByEventId(Long eventId); long countAppliedByEventId(Long eventId); 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..37225aa 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,76 @@ 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); + + /** + * 게시된 행사를 (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 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..633dcc5 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,23 @@ 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.EventApplicantCount; 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 +34,54 @@ public Optional findById(Long id) { return eventJpaRepository.findByIdAndIsDeletedFalse(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(); + + Map applicantCounts = countApplicants(events); + List content = events.stream() + .map(event -> new EventApplicantCount(event, applicantCounts.getOrDefault(event.getId(), 0L))) + .toList(); + + String nextCursor = hasNext ? EventCursor.of(events.getLast()).format() : null; + + return new CursorSliceResult<>(content, hasNext, nextCursor); + } + + private Map countApplicants(List events) { + if (events.isEmpty()) { + return Map.of(); + } + List eventIds = events.stream().map(Event::getId).toList(); + return eventJpaRepository.countApplicantsByEventIds(eventIds, EventApplicationStatus.APPLIED).stream() + .collect(Collectors.toMap(row -> (Long) row[0], row -> (Long) row[1])); + } + @Override public List findQuestionsByEventId(Long eventId) { return eventQuestionJpaRepository.findAllByEventIdOrderByDisplayOrderAsc(eventId).stream() From 896ee651fa3bcd8bf22708a1c503f0fba45a669b Mon Sep 17 00:00:00 2001 From: gain <163835628+leegain1@users.noreply.github.com> Date: Sat, 12 Sep 2026 16:03:55 +0900 Subject: [PATCH 5/7] =?UTF-8?q?feat:=20=EA=B2=8C=EC=8B=9C=EB=90=9C=20?= =?UTF-8?q?=ED=96=89=EC=82=AC=20=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?=EC=84=9C=EB=B9=84=EC=8A=A4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/event/service/EventService.java | 6 +++++ .../event/service/impl/EventServiceImpl.java | 23 +++++++++++++++++++ 2 files changed, 29 insertions(+) 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..59d6fba 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,17 @@ 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.EventSummary; +import kr.ac.kookmin.stream.event.domain.event.domain.RecruitStatus; public interface EventService { + CursorSliceResult getPublishedEvents(RecruitStatus recruitStatus, EventCursor cursor, int size); + 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 758d836..07d95b1 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 @@ -3,15 +3,19 @@ import java.time.LocalDateTime; import java.util.List; 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.EventApplicantCount; 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.EventApplicationStatus; 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.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; @@ -26,6 +30,25 @@ 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); + + List content = slice.content().stream() + .map(entry -> EventSummary.of(entry.event(), entry.applicantCount(), now)) + .toList(); + + return new CursorSliceResult<>(content, slice.hasNext(), slice.nextCursor()); + } + @Override @Transactional(readOnly = true) public EventApplicationForm getApplicationForm(Long eventId) { From 15e2b16fd4053dfd10ae81b4592f9d5e87abb835 Mon Sep 17 00:00:00 2001 From: gain <163835628+leegain1@users.noreply.github.com> Date: Sat, 12 Sep 2026 16:05:09 +0900 Subject: [PATCH 6/7] =?UTF-8?q?feat:=20=ED=95=99=EC=83=9D=20=EC=95=B1=20?= =?UTF-8?q?=ED=96=89=EC=82=AC=20=EB=AA=A9=EB=A1=9D=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../stream/event/AppEventController.java | 23 +++++++++++++ .../stream/event/EventListItemResponse.java | 34 +++++++++++++++++++ .../stream/event/EventListRequest.java | 32 +++++++++++++++++ .../domain/event/domain/EventErrorCode.java | 3 +- .../domain/event/domain/RecruitStatus.java | 19 ++++++++++- 5 files changed, 109 insertions(+), 2 deletions(-) create mode 100644 api/app-api/src/main/java/kr/ac/kookmin/stream/event/EventListItemResponse.java create mode 100644 api/app-api/src/main/java/kr/ac/kookmin/stream/event/EventListRequest.java 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..31efcc1 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,15 @@ 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}/form") public ApiResponse getApplicationForm(@PathVariable Long eventId) { return ApiResponse.success(EventFormResponse.from(eventService.getApplicationForm(eventId))); @@ -34,4 +48,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/EventListItemResponse.java b/api/app-api/src/main/java/kr/ac/kookmin/stream/event/EventListItemResponse.java new file mode 100644 index 0000000..5f8a953 --- /dev/null +++ b/api/app-api/src/main/java/kr/ac/kookmin/stream/event/EventListItemResponse.java @@ -0,0 +1,34 @@ +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(), + // 대표 이미지 파일 id는 summary.thumbnailFileId()로 알 수 있으나 + // 파일 키 → 공개 URL 조립(#17)이 아직 없어 URL은 내려보내지 않는다 + null, + summary.applyStartAt(), + summary.applyEndAt(), + summary.recruitStatus(), + summary.daysUntilDeadline() + ); + } +} 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/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 46fc2f7..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 @@ -16,7 +16,8 @@ public enum EventErrorCode implements ErrorCode { CAPACITY_FULL(ErrorStatus.CONFLICT, "모집 정원이 마감되었습니다."), ALREADY_APPLIED(ErrorStatus.CONFLICT, "이미 신청한 행사입니다."), INVALID_ANSWER(ErrorStatus.BAD_REQUEST, "신청서 답변 형식이 올바르지 않습니다."), - EVENT_INVALID_CURSOR(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/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); + } + } } From 091a8ab7bc2b268e139458c9965737ea7f408d89 Mon Sep 17 00:00:00 2001 From: gain <163835628+leegain1@users.noreply.github.com> Date: Sat, 12 Sep 2026 16:05:09 +0900 Subject: [PATCH 7/7] =?UTF-8?q?docs:=20=EC=BB=A4=EC=84=9C=20=EC=8A=AC?= =?UTF-8?q?=EB=9D=BC=EC=9D=B4=EC=8A=A4=20=EC=9D=91=EB=8B=B5=EC=9D=98=20nex?= =?UTF-8?q?tCursor=20=EA=B7=9C=EC=B9=99=20=EA=B0=B1=EC=8B=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/conventions/coding-style.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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) {} ```