Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
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,15 @@ 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}/form")
public ApiResponse<EventFormResponse> getApplicationForm(@PathVariable Long eventId) {
return ApiResponse.success(EventFormResponse.from(eventService.getApplicationForm(eventId)));
Expand All @@ -34,4 +48,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,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()
);
}
}
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
Expand Up @@ -25,6 +25,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 +42,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 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
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
Original file line number Diff line number Diff line change
@@ -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<Long> imageIds) {
return imageIds == null || imageIds.isEmpty() ? null : imageIds.getFirst();
}
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
Original file line number Diff line number Diff line change
@@ -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<Event> findById(Long id);

/**
* 게시되고 삭제되지 않은 행사를 행사 시작 일시 오름차순(동일 시각은 eventId 오름차순)으로 조회한다.
* recruitStatus가 주어지면 {@code now} 기준으로 계산한 모집 상태가 일치하는 행사만 남긴다.
*/
CursorSliceResult<EventApplicantCount> findPublishedSlice(
RecruitStatus recruitStatus,
EventCursor cursor,
int size,
LocalDateTime now
);

List<EventQuestion> findQuestionsByEventId(Long eventId);

long countAppliedByEventId(Long eventId);
Expand Down
Original file line number Diff line number Diff line change
@@ -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<EventSummary> getPublishedEvents(RecruitStatus recruitStatus, EventCursor cursor, int size);

EventApplicationForm getApplicationForm(Long eventId);

EventApplicationResult apply(Long eventId, Long memberId, EventApplyCommand command);
Expand Down
Loading
Loading