Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
fe4344f
feat: 커서 슬라이스 응답의 nextCursor를 불투명 문자열로 전환
leegain1 Sep 12, 2026
a17c95e
feat: events 테이블에 게시 여부 컬럼과 목록 조회 인덱스 추가
leegain1 Sep 12, 2026
40df1c4
feat: 행사 목록 조회용 도메인 모델 추가
leegain1 Sep 12, 2026
ff65ae3
feat: 행사 목록 keyset 조회와 신청자 수 집계 추가
leegain1 Sep 12, 2026
165ce74
feat: 게시된 행사 목록 조회 서비스 추가
leegain1 Sep 12, 2026
d369a7b
feat: 학생 앱 행사 목록 조회 API 추가
leegain1 Sep 12, 2026
2af5633
docs: 커서 슬라이스 응답의 nextCursor 규칙 갱신
leegain1 Sep 12, 2026
58000c1
refactor: 신청자 수 집계를 Object[] 대신 record 프로젝션으로 조회
leegain1 Sep 12, 2026
532f4be
refactor: 대표 이미지 URL 조립 지점을 별도 메서드로 분리
leegain1 Sep 12, 2026
d98ccf3
test: 행사 목록 도메인 모델 단위 테스트 추가
leegain1 Sep 12, 2026
df46d65
fix: Event.of 시그니처에 게시 여부가 추가된 것을 EventTest 호출부에 반영
leegain1 Sep 12, 2026
33c80dd
refactor: 마감까지 남은 일수 계산을 Event 도메인 메서드로 이동
leegain1 Sep 12, 2026
9f890b8
feat: 게시된 행사 상세 조회 도메인·서비스 추가
leegain1 Sep 12, 2026
e5e0ddc
feat: 학생 앱 행사 상세 조회 API 추가
leegain1 Sep 12, 2026
61b3456
test: 행사 상세 조회와 마감 일수 계산 단위 테스트 추가
leegain1 Sep 12, 2026
b3ebcf3
fix: 미게시 행사의 신청서 폼 조회·신청 차단
leegain1 Sep 12, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -19,6 +24,20 @@ public class AppEventController {

private final EventService eventService;

@GetMapping
public ApiResponse<CursorSliceResponse<EventListItemResponse>> getEvents(
@Valid @ModelAttribute EventListRequest request
) {
CursorSliceResult<EventSummary> result = eventService.getPublishedEvents(
request.toRecruitStatus(), request.toCursor(), request.sizeOrDefault());
return ApiResponse.success(CursorSliceResponse.from(toResponse(result)));
}

@GetMapping("/{eventId}")
public ApiResponse<EventDetailResponse> getEvent(@PathVariable Long eventId) {
return ApiResponse.success(EventDetailResponse.from(eventService.getPublishedEvent(eventId)));
}

@GetMapping("/{eventId}/form")
public ApiResponse<EventFormResponse> getApplicationForm(@PathVariable Long eventId) {
return ApiResponse.success(EventFormResponse.from(eventService.getApplicationForm(eventId)));
Expand All @@ -34,4 +53,13 @@ public ApiResponse<EventApplyResponse> apply(
EventApplyResponse.from(eventService.apply(eventId, apiUser.userId(), request.toCommand()))
);
}

// 커서는 클라이언트에게 불투명한 토큰이어야 하므로 응답 직전 웹 계층에서 인코딩한다
private CursorSliceResult<EventListItemResponse> toResponse(CursorSliceResult<EventSummary> result) {
return new CursorSliceResult<>(
result.content().stream().map(EventListItemResponse::from).toList(),
result.hasNext(),
result.nextCursor() == null ? null : CursorCodec.encode(result.nextCursor())
);
}
}
Original file line number Diff line number Diff line change
@@ -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<Image> 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);
}
}
}
Original file line number Diff line number Diff line change
@@ -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로 바꾼다.
* <p>
* 파일 키 → 공개 URL 조립(#17)이 아직 없어 현재는 항상 비어 있다. #17이 머지되면 이 메서드만 채우면 된다.
*/
private static String thumbnailUrlOf(Long thumbnailFileId) {
return null;
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
25 changes: 25 additions & 0 deletions api/common-api/src/main/java/kr/ac/kookmin/stream/CursorCodec.java
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
import java.util.List;
import kr.ac.kookmin.stream.common.CursorSliceResult;

public record CursorSliceResponse<T>(List<T> content, boolean hasNext, Long nextCursor) {
public record CursorSliceResponse<T>(List<T> content, boolean hasNext, String nextCursor) {

public static <T> CursorSliceResponse<T> from(CursorSliceResult<T> result) {
return new CursorSliceResponse<>(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,4 @@

import java.util.List;

public record CursorSliceResult<T>(List<T> content, boolean hasNext, Long nextCursor) {}
public record CursorSliceResult<T>(List<T> content, boolean hasNext, String nextCursor) {}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -25,6 +26,7 @@ public class Event {
private List<Long> imageIds;
private int capacity;
private RecruitStatus recruitStatus;
private boolean published;
private Long createdBy;

public static Event of(
Expand All @@ -41,11 +43,12 @@ public static Event of(
List<Long> 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
);
}

Expand All @@ -70,6 +73,22 @@ public RecruitStatus calculateRecruitStatus(LocalDateTime now, long appliedCount
return RecruitStatus.OPEN;
}

/**
* 신청 마감까지 남은 날짜 수. 목록·상세가 같은 D-Day를 보여야 하므로 도메인에 둔다.
* <p>
* 시각이 아니라 날짜 단위로 세므로 마감 당일은 언제든 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());
}

/**
* 정원이 찼는지 판정한다. 선착순 모집에만 정원 제한이 있고, 상시 모집은 인원 제한이 없다.
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package kr.ac.kookmin.stream.event.domain.event.domain;

/**
* 행사와 그 행사의 유효 신청자 수(status가 APPLIED인 신청). 모집 상태 계산에 필요해 함께 조회한다.
*/
public record EventApplicantCount(Event event, long applicantCount) {}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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<Long> 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)
);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Loading