diff --git a/src/main/java/com/sku/refit/domain/event/controller/EventController.java b/src/main/java/com/sku/refit/domain/event/controller/EventController.java index 5505622..c4debd7 100644 --- a/src/main/java/com/sku/refit/domain/event/controller/EventController.java +++ b/src/main/java/com/sku/refit/domain/event/controller/EventController.java @@ -12,8 +12,16 @@ import org.springframework.web.bind.annotation.*; import org.springframework.web.multipart.MultipartFile; -import com.sku.refit.domain.event.dto.request.EventRequest.*; -import com.sku.refit.domain.event.dto.response.EventResponse.*; +import com.sku.refit.domain.event.dto.request.EventRequest.EventInfoRequest; +import com.sku.refit.domain.event.dto.request.EventRequest.EventRsvRequest; +import com.sku.refit.domain.event.dto.response.EventResponse.EventCardResponse; +import com.sku.refit.domain.event.dto.response.EventResponse.EventDetailResponse; +import com.sku.refit.domain.event.dto.response.EventResponse.EventGroupResponse; +import com.sku.refit.domain.event.dto.response.EventResponse.EventImageResponse; +import com.sku.refit.domain.event.dto.response.EventResponse.EventPagedResponse; +import com.sku.refit.domain.event.dto.response.EventResponse.EventReservationResponse; +import com.sku.refit.domain.event.dto.response.EventResponse.EventSimpleResponse; +import com.sku.refit.domain.event.entity.EventStatus; import com.sku.refit.global.response.BaseResponse; import io.swagger.v3.oas.annotations.Operation; @@ -30,47 +38,132 @@ public interface EventController { * ========================= */ @PostMapping(value = "/admin", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) - @Operation(summary = "[관리자] 행사 생성", description = "행사를 생성합니다.") + @Operation( + summary = "[관리자] 행사 생성", + description = + """ + multipart/form-data 로 요청합니다. + + - request: 행사 정보(JSON) + - thumbnail: 대표 사진(파일) + """) ResponseEntity> createEvent( @Parameter( - description = "행사 정보", + description = "행사 정보(JSON 파트)", content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)) @RequestPart("request") @Valid EventInfoRequest request, @Parameter( - description = "대표 사진(썸네일)", + description = "대표 사진(썸네일 파일 파트)", content = @Content(mediaType = MediaType.MULTIPART_FORM_DATA_VALUE)) @RequestPart("thumbnail") MultipartFile thumbnail); @PutMapping(value = "/admin/{id}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) - @Operation(summary = "[관리자] 행사 수정", description = "행사를 수정합니다.") + @Operation( + summary = "[관리자] 행사 수정", + description = + """ + multipart/form-data 로 요청합니다. + + - request: 행사 정보(JSON) + - thumbnail: 대표 사진(파일, 선택) + """) ResponseEntity> updateEvent( - @PathVariable Long id, - @RequestPart("request") @Valid EventInfoRequest request, - @RequestPart(value = "thumbnail", required = false) MultipartFile thumbnail); + @Parameter(description = "행사 ID", example = "1") @PathVariable Long id, + @Parameter( + description = "행사 정보(JSON 파트)", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)) + @RequestPart("request") + @Valid + EventInfoRequest request, + @Parameter( + description = "대표 사진(썸네일 파일 파트, 선택)", + content = @Content(mediaType = MediaType.MULTIPART_FORM_DATA_VALUE)) + @RequestPart(value = "thumbnail", required = false) + MultipartFile thumbnail); @DeleteMapping("/admin/{id}") - @Operation(summary = "[관리자] 행사 삭제", description = "행사 뿐만 아니라 대표사진 및 이미지들까지 모두 삭제합니다.") - ResponseEntity> deleteEvent(@PathVariable Long id); + @Operation(summary = "[관리자] 행사 삭제", description = "행사 뿐만 아니라 대표사진 및 예약 이미지들까지 모두 삭제합니다.") + ResponseEntity> deleteEvent( + @Parameter(description = "행사 ID", example = "1") @PathVariable Long id); + + @GetMapping("/admin") + @Operation( + summary = "[관리자] 행사 리스트 조회", + description = + """ + 페이지네이션 기반으로 행사 목록을 조회합니다. + + ■ 조회 옵션 + - status 생략: 전체 조회 + - status=UPCOMING: 예정 + - status=ONGOING: 진행중 + - status=ENDED: 완료 + - q: 검색어(행사명/장소 부분일치) + + ■ 상태 판정 기준 (today 기준) + - UPCOMING: startDate > today + - ONGOING: startDate <= today <= endDate + - ENDED: endDate < today + + ■ 정렬 + - status 미지정(전체): ONGOING → UPCOMING → ENDED (우선순위) + 각 그룹 내 정렬은 아래 기준 적용 + - ONGOING: startDate desc (최근 시작한 진행중부터) + - UPCOMING: startDate asc (곧 시작하는 행사부터) + - ENDED: endDate desc (최근 종료된 것부터) + """) + ResponseEntity> getEvents( + @Parameter(description = "페이지 번호(0부터 시작)", example = "0") @RequestParam(defaultValue = "0") + int page, + @Parameter(description = "페이지 크기", example = "10") @RequestParam(defaultValue = "10") + int size, + @Parameter(description = "행사 상태 필터(미지정 시 전체). UPCOMING/ONGOING/ENDED", example = "ONGOING") + @RequestParam(required = false) + EventStatus status, + @Parameter(description = "검색어(행사명/장소 부분일치)", example = "서울") @RequestParam(required = false) + String q); /* ========================= * Public List * ========================= */ @GetMapping("/upcoming") - @Operation(summary = "예정된 행사 리스트", description = "예정된 행사만 조회합니다. D-day가 가까운 순(오름차순)으로 정렬합니다.") + @Operation( + summary = "예정된 행사 리스트", + description = + """ + 예정된 행사만 조회합니다. + + - UPCOMING: startDate > today + - 정렬: startDate asc (곧 시작하는 행사부터) + """) ResponseEntity>> getUpcomingEvents(); - @GetMapping("/end") - @Operation(summary = "종료된 행사 리스트", description = "종료된 행사만 조회합니다. 최근 종료 순(내림차순)으로 정렬합니다.") + @GetMapping("/ended") + @Operation( + summary = "종료된 행사 리스트", + description = + """ + 종료된 행사만 조회합니다. + + - ENDED: endDate < today + - 정렬: endDate desc (최근 종료된 행사부터) + """) ResponseEntity>> getEndedEvents(); - @GetMapping + @GetMapping("/group") @Operation( - summary = "다가오는/예정된/종료된 행사 조회", - description = "다가오는(가장 가까운 D-day 1개) / 예정된(그 다음 1개) / 종료된(가장 최근 종료 1개)로 반환합니다.") + summary = "행사 그룹 조회 (다가오는/예정/종료 행사 1개씩)", + description = + """ + 목록(list)이 아니라 그룹 응답입니다. + + - upcoming: 가장 가까운 D-day 1개 + - scheduled: 그 다음 예정 1개 + - ended: 가장 최근 종료 1개 + """) ResponseEntity> getEventGroups(); /* ========================= @@ -80,22 +173,40 @@ ResponseEntity> updateEvent( @GetMapping("/{id}") @Operation( summary = "행사 상세 조회", - description = "행사 예약 여부 + 행사 정보 + 최근 예약 이미지 4장 + 4장 제외 의류수를 반환합니다.") - ResponseEntity> getEventDetail(@PathVariable Long id); + description = "행사 예약 여부 + 행사 정보 + 최근 예약 이미지 4장 + 4장 제외 의류 수를 반환합니다.") + ResponseEntity> getEventDetail( + @Parameter(description = "행사 ID", example = "1") @PathVariable Long id); @GetMapping("/{id}/img") @Operation(summary = "행사 더보기 이미지 조회", description = "해당 행사의 예약에서 업로드된 모든 옷 사진을 최신 등록순으로 반환합니다.") ResponseEntity>> getEventAllReservationImages( - @PathVariable Long id); + @Parameter(description = "행사 ID", example = "1") @PathVariable Long id); /* ========================= * Reservation * ========================= */ @PostMapping(value = "/{id}/rsv", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) - @Operation(summary = "행사 예약", description = "예약 정보를 저장하고 업로드 이미지는 WebP로 저장합니다.") + @Operation( + summary = "행사 예약", + description = + """ + multipart/form-data 로 요청합니다. + + - request: 예약 정보(JSON) + - clothImageList: 의류 이미지 리스트(파일, 선택) + """) ResponseEntity> reserveEvent( - @PathVariable Long id, - @RequestPart("request") @Valid EventRsvRequest request, - @RequestPart(value = "clothImageList", required = false) List clothImageList); + @Parameter(description = "행사 ID", example = "1") @PathVariable Long id, + @Parameter( + description = "예약 정보(JSON 파트)", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)) + @RequestPart("request") + @Valid + EventRsvRequest request, + @Parameter( + description = "의류 이미지 리스트(파일 파트, 선택)", + content = @Content(mediaType = MediaType.MULTIPART_FORM_DATA_VALUE)) + @RequestPart(value = "clothImageList", required = false) + List clothImageList); } diff --git a/src/main/java/com/sku/refit/domain/event/controller/EventControllerImpl.java b/src/main/java/com/sku/refit/domain/event/controller/EventControllerImpl.java index 49a4bf9..0cd3fcc 100644 --- a/src/main/java/com/sku/refit/domain/event/controller/EventControllerImpl.java +++ b/src/main/java/com/sku/refit/domain/event/controller/EventControllerImpl.java @@ -19,8 +19,10 @@ import com.sku.refit.domain.event.dto.response.EventResponse.EventDetailResponse; import com.sku.refit.domain.event.dto.response.EventResponse.EventGroupResponse; import com.sku.refit.domain.event.dto.response.EventResponse.EventImageResponse; +import com.sku.refit.domain.event.dto.response.EventResponse.EventPagedResponse; import com.sku.refit.domain.event.dto.response.EventResponse.EventReservationResponse; import com.sku.refit.domain.event.dto.response.EventResponse.EventSimpleResponse; +import com.sku.refit.domain.event.entity.EventStatus; import com.sku.refit.domain.event.service.EventService; import com.sku.refit.global.response.BaseResponse; @@ -94,4 +96,11 @@ public ResponseEntity> reserveEvent( return ResponseEntity.ok( BaseResponse.success(eventService.reserveEvent(id, request, clothImageList))); } + + @Override + public ResponseEntity> getEvents( + int page, int size, EventStatus status, String q) { + + return ResponseEntity.ok(BaseResponse.success(eventService.getEvents(page, size, status, q))); + } } diff --git a/src/main/java/com/sku/refit/domain/event/dto/request/EventRequest.java b/src/main/java/com/sku/refit/domain/event/dto/request/EventRequest.java index 2a188b3..16301fd 100644 --- a/src/main/java/com/sku/refit/domain/event/dto/request/EventRequest.java +++ b/src/main/java/com/sku/refit/domain/event/dto/request/EventRequest.java @@ -27,8 +27,14 @@ public static class EventInfoRequest { @Schema(description = "행사 설명", example = "파티에 대한 짧은 설명입니다.") private String description; - @NotNull(message = "행사 날짜는 필수입니다.") @Schema(description = "행사 날짜", example = "2025-12-24") - private LocalDate date; + @NotNull(message = "행사 시작 날짜는 필수입니다.") @Schema(description = "시작 날짜", example = "2025-12-24") + private LocalDate startDate; + + @NotNull(message = "행사 종료 날짜는 필수입니다.") @Schema(description = "종료 날짜", example = "2025-12-26") + private LocalDate endDate; + + @Schema(description = "예약 정원", example = "100") + private Integer capacity; @NotBlank(message = "행사 장소는 필수입니다.") @Schema(description = "행사 장소", example = "서울 성동구") diff --git a/src/main/java/com/sku/refit/domain/event/dto/response/EventResponse.java b/src/main/java/com/sku/refit/domain/event/dto/response/EventResponse.java index d11fba6..e02b296 100644 --- a/src/main/java/com/sku/refit/domain/event/dto/response/EventResponse.java +++ b/src/main/java/com/sku/refit/domain/event/dto/response/EventResponse.java @@ -6,6 +6,8 @@ import java.time.LocalDate; import java.util.List; +import com.sku.refit.domain.event.entity.EventStatus; + import io.swagger.v3.oas.annotations.media.Schema; import lombok.*; @@ -25,7 +27,7 @@ public static class EventDetailResponse { @Schema(description = "행사 식별자", example = "1") private Long eventId; - @Schema(description = "누적 예약 인원(옷 수량 기준)", example = "25") + @Schema(description = "누적 예약 인원", example = "25") private Integer totalReservedCount; @Schema(description = "행사 대표 이미지 URL") @@ -42,12 +44,18 @@ public static class EventDetailResponse { /* ===== 중간 ===== */ - @Schema(description = "행사 날짜", example = "2025-12-24") - private LocalDate date; + @Schema(description = "시작 날짜", example = "2025-12-24") + private LocalDate startDate; + + @Schema(description = "종료 날짜", example = "2025-12-26") + private LocalDate endDate; @Schema(description = "행사 장소", example = "서울 성동구") private String location; + @Schema(description = "예약 정원", example = "100") + private Integer capacity; + /* ===== 하단 ===== */ @Schema(description = "최근 등록된 예약 이미지 4장 URL 리스트") @@ -106,9 +114,6 @@ public static class EventCardResponse { @Schema(description = "행사 설명", example = "따뜻한 겨울을 위한 의류 기부 행사입니다.") private String description; - @Schema(description = "행사 날짜", example = "2025-12-24") - private LocalDate date; - @Schema(description = "행사 장소", example = "서울 성동구") private String location; } @@ -127,8 +132,8 @@ public static class EventSimpleResponse { @Schema(description = "행사명", example = "겨울 의류 나눔 행사") private String name; - @Schema(description = "행사 날짜", example = "2025-12-24") - private LocalDate date; + @Schema(description = "시작 날짜", example = "2025-12-24") + private LocalDate startDate; @Schema(description = "행사 장소", example = "서울 성동구") private String location; @@ -148,4 +153,41 @@ public static class EventGroupResponse { @Schema(description = "종료된 행사") private EventSimpleResponse ended; } + + @Getter + @Builder + public static class EventPagedResponse { + private int page; + private int size; + private long totalElements; + private int totalPages; + private boolean hasNext; + private List items; + } + + @Getter + @Builder + public static class EventListItem { + + @Schema(description = "행사 식별자", example = "1") + private Long eventId; + + @Schema(description = "행사명", example = "겨울 의류 나눔 행사") + private String name; + + @Schema(description = "시작 날짜", example = "2025-12-24") + private LocalDate startDate; + + @Schema(description = "행사 장소", example = "서울 성동구") + private String location; + + @Schema(description = "누적 예약 인원", example = "25") + private Integer reservedCount; + + @Schema(description = "예약 정원", example = "100") + private Integer capacity; + + @Schema(description = "행사 상태", example = "진행중") + private EventStatus status; + } } diff --git a/src/main/java/com/sku/refit/domain/event/entity/Event.java b/src/main/java/com/sku/refit/domain/event/entity/Event.java index c343597..60ab9f2 100644 --- a/src/main/java/com/sku/refit/domain/event/entity/Event.java +++ b/src/main/java/com/sku/refit/domain/event/entity/Event.java @@ -33,8 +33,11 @@ public class Event extends BaseTimeEntity { @Column(nullable = false, columnDefinition = "TEXT") private String description; - @Column(nullable = false) - private LocalDate date; + @Column(name = "start_date", nullable = false) + private LocalDate startDate; + + @Column(name = "end_date", nullable = false) + private LocalDate endDate; @Column(nullable = false) private String location; @@ -48,13 +51,23 @@ public class Event extends BaseTimeEntity { @Builder.Default private Integer totalReservedCount = 0; + @Column private Integer capacity; + public void update( - String name, String description, LocalDate date, String location, String detailLink) { + String name, + String description, + LocalDate startDate, + LocalDate endDate, + String location, + String detailLink, + Integer capacity) { this.name = name; this.description = description; - this.date = date; + this.startDate = startDate; + this.endDate = endDate; this.location = location; this.detailLink = detailLink; + this.capacity = capacity; } public void updateThumbnailUrl(String thumbnailUrl) { @@ -64,4 +77,10 @@ public void updateThumbnailUrl(String thumbnailUrl) { public void increaseReservedCount() { this.totalReservedCount = (this.totalReservedCount == null ? 0 : this.totalReservedCount) + 1; } + + public boolean isFull() { + if (this.capacity == null) return false; + int current = (this.totalReservedCount == null) ? 0 : this.totalReservedCount; + return current >= this.capacity; + } } diff --git a/src/main/java/com/sku/refit/domain/event/entity/EventStatus.java b/src/main/java/com/sku/refit/domain/event/entity/EventStatus.java new file mode 100644 index 0000000..1b855c8 --- /dev/null +++ b/src/main/java/com/sku/refit/domain/event/entity/EventStatus.java @@ -0,0 +1,24 @@ +/* + * Copyright (c) SKU 다시입을Lab + */ +package com.sku.refit.domain.event.entity; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +@Schema(description = "행사 상태 Enum") +public enum EventStatus { + @Schema(description = "예정") + UPCOMING("예정"), + + @Schema(description = "진행중") + ONGOING("진행중"), + + @Schema(description = "완료") + ENDED("완료"); + + private final String ko; +} diff --git a/src/main/java/com/sku/refit/domain/event/exception/EventErrorCode.java b/src/main/java/com/sku/refit/domain/event/exception/EventErrorCode.java index c1d63af..1a3b159 100644 --- a/src/main/java/com/sku/refit/domain/event/exception/EventErrorCode.java +++ b/src/main/java/com/sku/refit/domain/event/exception/EventErrorCode.java @@ -16,23 +16,29 @@ public enum EventErrorCode implements BaseErrorCode { EVENT_NOT_FOUND("EVENT001", "행사가 존재하지 않습니다.", HttpStatus.NOT_FOUND), EVENT_THUMBNAIL_REQUIRED("EVENT010", "행사 대표 사진은 필수입니다.", HttpStatus.BAD_REQUEST), + EVENT_INVALID_DATE_RANGE("EVENT011", "행사 시작일은 종료일보다 늦을 수 없습니다.", HttpStatus.BAD_REQUEST), + EVENT_INVALID_CAPACITY("EVENT012", "예약 정원(capacity)은 1 이상이어야 합니다.", HttpStatus.BAD_REQUEST), + EVENT_THUMBNAIL_UPLOAD_FAILED( - "EVENT011", "행사 대표 사진 업로드에 실패했습니다.", HttpStatus.INTERNAL_SERVER_ERROR), + "EVENT020", "행사 대표 사진 업로드에 실패했습니다.", HttpStatus.INTERNAL_SERVER_ERROR), EVENT_THUMBNAIL_DELETE_FAILED( - "EVENT012", "행사 대표 사진 삭제에 실패했습니다.", HttpStatus.INTERNAL_SERVER_ERROR), - - EVENT_CREATE_FAILED("EVENT013", "행사 생성에 실패했습니다.", HttpStatus.INTERNAL_SERVER_ERROR), - EVENT_UPDATE_FAILED("EVENT014", "행사 수정에 실패했습니다.", HttpStatus.INTERNAL_SERVER_ERROR), + "EVENT021", "행사 대표 사진 삭제에 실패했습니다.", HttpStatus.INTERNAL_SERVER_ERROR), - EVENT_DELETE_FAILED("EVENT015", "행사 삭제에 실패했습니다.", HttpStatus.INTERNAL_SERVER_ERROR), - EVENT_RESERVATION_IMAGES_DELETE_FAILED( - "EVENT016", "예약 이미지 삭제에 실패했습니다.", HttpStatus.INTERNAL_SERVER_ERROR), + EVENT_CREATE_FAILED("EVENT030", "행사 생성에 실패했습니다.", HttpStatus.INTERNAL_SERVER_ERROR), + EVENT_UPDATE_FAILED("EVENT031", "행사 수정에 실패했습니다.", HttpStatus.INTERNAL_SERVER_ERROR), + EVENT_DELETE_FAILED("EVENT032", "행사 삭제에 실패했습니다.", HttpStatus.INTERNAL_SERVER_ERROR), - EVENT_ALREADY_RESERVED("EVENT020", "이미 예약한 행사입니다.", HttpStatus.CONFLICT), + EVENT_ALREADY_RESERVED("EVENT040", "이미 예약한 행사입니다.", HttpStatus.CONFLICT), + EVENT_CAPACITY_EXCEEDED("EVENT041", "행사 예약 정원이 초과되었습니다.", HttpStatus.CONFLICT), EVENT_RESERVATION_CREATE_FAILED( - "EVENT021", "행사 예약 저장에 실패했습니다.", HttpStatus.INTERNAL_SERVER_ERROR), + "EVENT042", "행사 예약 저장에 실패했습니다.", HttpStatus.INTERNAL_SERVER_ERROR), + EVENT_RESERVATION_IMAGE_UPLOAD_FAILED( - "EVENT022", "예약 이미지 업로드에 실패했습니다.", HttpStatus.INTERNAL_SERVER_ERROR), + "EVENT050", "예약 이미지 업로드에 실패했습니다.", HttpStatus.INTERNAL_SERVER_ERROR), + EVENT_RESERVATION_IMAGES_DELETE_FAILED( + "EVENT051", "예약 이미지 삭제에 실패했습니다.", HttpStatus.INTERNAL_SERVER_ERROR), + + EVENT_LIST_FETCH_FAILED("EVENT060", "행사 리스트 조회에 실패했습니다.", HttpStatus.INTERNAL_SERVER_ERROR), ; private final String code; diff --git a/src/main/java/com/sku/refit/domain/event/mapper/EventMapper.java b/src/main/java/com/sku/refit/domain/event/mapper/EventMapper.java index 3d86911..15a627a 100644 --- a/src/main/java/com/sku/refit/domain/event/mapper/EventMapper.java +++ b/src/main/java/com/sku/refit/domain/event/mapper/EventMapper.java @@ -7,6 +7,7 @@ import java.time.temporal.ChronoUnit; import java.util.List; +import org.springframework.data.domain.Page; import org.springframework.stereotype.Component; import com.sku.refit.domain.event.dto.request.EventRequest.*; @@ -14,6 +15,7 @@ import com.sku.refit.domain.event.entity.Event; import com.sku.refit.domain.event.entity.EventReservation; import com.sku.refit.domain.event.entity.EventReservationImage; +import com.sku.refit.domain.event.entity.EventStatus; import com.sku.refit.domain.user.entity.User; @Component @@ -23,10 +25,12 @@ public Event toEvent(EventInfoRequest req, String thumbnailUrl) { return Event.builder() .name(req.getName()) .description(req.getDescription()) - .date(req.getDate()) + .startDate(req.getStartDate()) + .endDate(req.getEndDate()) .location(req.getLocation()) .detailLink(req.getDetailLink()) .thumbnailUrl(thumbnailUrl) + .capacity(req.getCapacity()) // null 허용 .totalReservedCount(0) .build(); } @@ -41,8 +45,10 @@ public EventDetailResponse toDetail( .name(event.getName()) .description(event.getDescription()) .detailLink(event.getDetailLink()) - .date(event.getDate()) + .startDate(event.getStartDate()) + .endDate(event.getEndDate()) .location(event.getLocation()) + .capacity(event.getCapacity()) .recentImageUrlList(recent4) .clothCountExceptRecent4(clothCountExcept4) .build(); @@ -57,14 +63,14 @@ public EventImageResponse toImageResponse(EventReservationImage img) { * ========================= */ public EventCardResponse toUpcomingCard(Event event, LocalDate today) { - long dday = ChronoUnit.DAYS.between(today, event.getDate()); + long dday = ChronoUnit.DAYS.between(today, event.getStartDate()); + return EventCardResponse.builder() .eventId(event.getId()) .thumbnailUrl(event.getThumbnailUrl()) .dday(dday) .name(event.getName()) .description(event.getDescription()) - .date(event.getDate()) .location(event.getLocation()) .build(); } @@ -74,7 +80,7 @@ public EventSimpleResponse toSimple(Event event) { .eventId(event.getId()) .thumbnailUrl(event.getThumbnailUrl()) .name(event.getName()) - .date(event.getDate()) + .startDate(event.getStartDate()) // DTO 스펙에 맞춤 .location(event.getLocation()) .build(); } @@ -99,6 +105,7 @@ public EventGroupResponse toGroupResponseSingle( /* ========================= * Reservation Response * ========================= */ + public EventReservation toReservation(Event event, User user, EventRsvRequest request) { return EventReservation.builder() .event(event) @@ -118,4 +125,45 @@ public EventReservationResponse toReservationResponse(Event event) { .totalReservedCount(event.getTotalReservedCount()) .build(); } + + public EventPagedResponse toPagedResponse(Page page, LocalDate today) { + + List items = + page.getContent().stream().map(event -> toItem(event, today)).toList(); + + return EventPagedResponse.builder() + .page(page.getNumber()) + .size(page.getSize()) + .totalElements(page.getTotalElements()) + .totalPages(page.getTotalPages()) + .hasNext(page.hasNext()) + .items(items) + .build(); + } + + private EventListItem toItem(Event event, LocalDate today) { + + return EventListItem.builder() + .eventId(event.getId()) + .name(event.getName()) + .startDate(event.getStartDate()) + .location(event.getLocation()) + .reservedCount(event.getTotalReservedCount() == null ? 0 : event.getTotalReservedCount()) + .capacity(event.getCapacity()) + .status(resolveStatus(event, today)) + .build(); + } + + private EventStatus resolveStatus(Event event, LocalDate today) { + + if (event.getStartDate().isAfter(today)) { + return EventStatus.UPCOMING; + } + + if (event.getEndDate().isBefore(today)) { + return EventStatus.ENDED; + } + + return EventStatus.ONGOING; + } } diff --git a/src/main/java/com/sku/refit/domain/event/repository/EventRepository.java b/src/main/java/com/sku/refit/domain/event/repository/EventRepository.java index 75649b1..679cf87 100644 --- a/src/main/java/com/sku/refit/domain/event/repository/EventRepository.java +++ b/src/main/java/com/sku/refit/domain/event/repository/EventRepository.java @@ -6,17 +6,105 @@ import java.time.LocalDate; import java.util.List; +import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import com.sku.refit.domain.event.entity.Event; public interface EventRepository extends JpaRepository { - List findByDateGreaterThanEqualOrderByDateAsc(LocalDate today); - List findByDateLessThanOrderByDateDesc(LocalDate today); + List findByStartDateGreaterThanEqualOrderByStartDateAsc(LocalDate today); - List findByDateGreaterThanEqualOrderByDateAsc(LocalDate date, Pageable pageable); + List findByEndDateLessThanOrderByEndDateDesc(LocalDate today); - List findByDateLessThanOrderByDateDesc(LocalDate date, Pageable pageable); + List findByStartDateGreaterThanEqualOrderByStartDateAsc( + LocalDate today, Pageable pageable); + + List findByEndDateLessThanOrderByEndDateDesc(LocalDate today, Pageable pageable); + + /* ========================= + * Admin list (paged) + * - status=null : 전체(ONGOING->UPCOMING->ENDED 우선 정렬) + * - status!=null : 필터 + (ONGOING: startDate desc / UPCOMING: startDate asc / ENDED: endDate desc) + * ========================= */ + + @Query( + """ + select e + from Event e + where + (:q is null or :q = '' + or lower(e.name) like lower(concat('%', :q, '%')) + or lower(e.location) like lower(concat('%', :q, '%')) + ) + order by + /* 1) 상태 우선순위: ONGOING(0) -> UPCOMING(1) -> ENDED(2) */ + case + when e.startDate <= :today and e.endDate >= :today then 0 + when e.startDate > :today then 1 + else 2 + end, + + /* 2) 상태별 내부 정렬 */ + /* UPCOMING: startDate asc */ + case when e.startDate > :today then e.startDate else null end asc, + + /* ONGOING: startDate desc */ + case when e.startDate <= :today and e.endDate >= :today then e.startDate else null end desc, + + /* ENDED: endDate desc */ + case when e.endDate < :today then e.endDate else null end desc, + + /* tie-breaker */ + e.id desc + """) + Page findAllSortedByStatus( + @Param("today") LocalDate today, @Param("q") String q, Pageable pageable); + + /* ===== status 별 (필터 적용 시) ===== */ + + // UPCOMING: startDate > today, startDate asc + @Query( + """ + select e from Event e + where e.startDate > :today + and (:q is null or :q = '' + or lower(e.name) like lower(concat('%', :q, '%')) + or lower(e.location) like lower(concat('%', :q, '%')) + ) + order by e.startDate asc, e.id desc + """) + Page findUpcomingSorted( + @Param("today") LocalDate today, @Param("q") String q, Pageable pageable); + + // ONGOING: startDate <= today <= endDate, startDate desc + @Query( + """ + select e from Event e + where e.startDate <= :today and e.endDate >= :today + and (:q is null or :q = '' + or lower(e.name) like lower(concat('%', :q, '%')) + or lower(e.location) like lower(concat('%', :q, '%')) + ) + order by e.startDate desc, e.id desc + """) + Page findOngoingSorted( + @Param("today") LocalDate today, @Param("q") String q, Pageable pageable); + + // ENDED: endDate < today, endDate desc + @Query( + """ + select e from Event e + where e.endDate < :today + and (:q is null or :q = '' + or lower(e.name) like lower(concat('%', :q, '%')) + or lower(e.location) like lower(concat('%', :q, '%')) + ) + order by e.endDate desc, e.id desc + """) + Page findEndedSorted( + @Param("today") LocalDate today, @Param("q") String q, Pageable pageable); } diff --git a/src/main/java/com/sku/refit/domain/event/service/EventService.java b/src/main/java/com/sku/refit/domain/event/service/EventService.java index 8b1acf6..e10b13d 100644 --- a/src/main/java/com/sku/refit/domain/event/service/EventService.java +++ b/src/main/java/com/sku/refit/domain/event/service/EventService.java @@ -9,6 +9,7 @@ import com.sku.refit.domain.event.dto.request.EventRequest.*; import com.sku.refit.domain.event.dto.response.EventResponse.*; +import com.sku.refit.domain.event.entity.EventStatus; /** * 행사(Event) 관련 주요 기능을 제공하는 서비스 인터페이스입니다. @@ -157,4 +158,15 @@ public interface EventService { */ EventReservationResponse reserveEvent( Long eventId, EventRsvRequest request, List clothImageList); + + /** + * 행사 리스트를 페이지네이션으로 조회합니다. + * + * @param page 페이지 번호(0부터 시작) + * @param size 페이지 크기 + * @param status 상태 필터(UPCOMING/ONGOING/ENDED), null이면 전체 + * @param q 검색어(행사명/장소 부분일치), null/blank면 미적용 + * @return 페이지 응답 + */ + EventPagedResponse getEvents(int page, int size, EventStatus status, String q); } diff --git a/src/main/java/com/sku/refit/domain/event/service/EventServiceImpl.java b/src/main/java/com/sku/refit/domain/event/service/EventServiceImpl.java index 1ed6152..6601f3d 100644 --- a/src/main/java/com/sku/refit/domain/event/service/EventServiceImpl.java +++ b/src/main/java/com/sku/refit/domain/event/service/EventServiceImpl.java @@ -6,16 +6,26 @@ import java.time.LocalDate; import java.util.List; +import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; -import com.sku.refit.domain.event.dto.request.EventRequest.*; -import com.sku.refit.domain.event.dto.response.EventResponse.*; +import com.sku.refit.domain.event.dto.request.EventRequest.EventInfoRequest; +import com.sku.refit.domain.event.dto.request.EventRequest.EventRsvRequest; +import com.sku.refit.domain.event.dto.response.EventResponse.EventCardResponse; +import com.sku.refit.domain.event.dto.response.EventResponse.EventDetailResponse; +import com.sku.refit.domain.event.dto.response.EventResponse.EventGroupResponse; +import com.sku.refit.domain.event.dto.response.EventResponse.EventImageResponse; +import com.sku.refit.domain.event.dto.response.EventResponse.EventPagedResponse; +import com.sku.refit.domain.event.dto.response.EventResponse.EventReservationResponse; +import com.sku.refit.domain.event.dto.response.EventResponse.EventSimpleResponse; import com.sku.refit.domain.event.entity.Event; import com.sku.refit.domain.event.entity.EventReservation; import com.sku.refit.domain.event.entity.EventReservationImage; +import com.sku.refit.domain.event.entity.EventStatus; import com.sku.refit.domain.event.exception.EventErrorCode; import com.sku.refit.domain.event.mapper.EventMapper; import com.sku.refit.domain.event.repository.EventRepository; @@ -55,11 +65,13 @@ public class EventServiceImpl implements EventService { @Transactional public EventDetailResponse createEvent(EventInfoRequest request, MultipartFile thumbnail) { + validateEventInfoRequest(request); + if (thumbnail == null || thumbnail.isEmpty()) { throw new CustomException(EventErrorCode.EVENT_THUMBNAIL_REQUIRED); } - String thumbnailUrl; + final String thumbnailUrl; try { thumbnailUrl = s3Service.uploadFileAsWebp(PathName.EVENT, thumbnail); } catch (CustomException e) { @@ -73,7 +85,6 @@ public EventDetailResponse createEvent(EventInfoRequest request, MultipartFile t Event event = eventMapper.toEvent(request, thumbnailUrl); eventRepository.save(event); return getEventDetail(event.getId()); - } catch (CustomException e) { throw e; } catch (Exception e) { @@ -87,6 +98,8 @@ public EventDetailResponse createEvent(EventInfoRequest request, MultipartFile t public EventDetailResponse updateEvent( Long id, EventInfoRequest request, MultipartFile thumbnail) { + validateEventInfoRequest(request); + Event event = eventRepository .findById(id) @@ -96,16 +109,17 @@ public EventDetailResponse updateEvent( event.update( request.getName(), request.getDescription(), - request.getDate(), + request.getStartDate(), + request.getEndDate(), request.getLocation(), - request.getDetailLink()); + request.getDetailLink(), + request.getCapacity()); if (thumbnail != null && !thumbnail.isEmpty()) { replaceThumbnail(event, id, thumbnail); } return getEventDetail(event.getId()); - } catch (CustomException e) { throw e; } catch (Exception e) { @@ -115,6 +129,7 @@ public EventDetailResponse updateEvent( } private void replaceThumbnail(Event event, Long eventId, MultipartFile thumbnail) { + String oldThumbUrl = event.getThumbnailUrl(); final String newThumbUrl; @@ -157,6 +172,7 @@ public void deleteEvent(Long id) { throw new CustomException(EventErrorCode.EVENT_THUMBNAIL_DELETE_FAILED); } } + List images = eventReservationImageRepository.findAllByReservation_Event_IdOrderByIdDesc(id); @@ -198,7 +214,9 @@ public void deleteEvent(Long id) { public List getUpcomingEvents() { LocalDate today = LocalDate.now(); - List upcoming = eventRepository.findByDateGreaterThanEqualOrderByDateAsc(today); + + List upcoming = + eventRepository.findByStartDateGreaterThanEqualOrderByStartDateAsc(today); return eventMapper.toUpcomingCardList(upcoming, today); } @@ -207,23 +225,28 @@ public List getUpcomingEvents() { public List getEndedEvents() { LocalDate today = LocalDate.now(); - List ended = eventRepository.findByDateLessThanOrderByDateDesc(today); + + List ended = eventRepository.findByEndDateLessThanOrderByEndDateDesc(today); return eventMapper.toSimpleList(ended); } @Override public EventGroupResponse getEventGroups() { + LocalDate today = LocalDate.now(); List top2Upcoming = - eventRepository.findByDateGreaterThanEqualOrderByDateAsc(today, PageRequest.of(0, 2)); + eventRepository.findByStartDateGreaterThanEqualOrderByStartDateAsc( + today, PageRequest.of(0, 2)); Event upcomingEvent = top2Upcoming.size() >= 1 ? top2Upcoming.get(0) : null; Event scheduledEvent = top2Upcoming.size() >= 2 ? top2Upcoming.get(1) : null; Event endedEvent = - eventRepository.findByDateLessThanOrderByDateDesc(today, PageRequest.of(0, 1)).stream() + eventRepository + .findByEndDateLessThanOrderByEndDateDesc(today, PageRequest.of(0, 1)) + .stream() .findFirst() .orElse(null); @@ -268,7 +291,6 @@ public EventDetailResponse getEventDetail(Long id) { .toList(); int totalUploadedClothCount = eventReservationImageRepository.countByReservation_Event_Id(id); - int clothCountExcept4 = Math.max(0, totalUploadedClothCount - 4); return eventMapper.toDetail(event, isReserved, recent4, clothCountExcept4); @@ -308,21 +330,32 @@ public EventReservationResponse reserveEvent( throw new CustomException(EventErrorCode.EVENT_ALREADY_RESERVED); } - EventReservation reservation = eventMapper.toReservation(event, user, request); + validateCapacityBeforeReserve(event); - eventReservationRepository.save(reservation); - ticketService.issueTicket( - TicketType.EVENT, - event.getId(), - user.getId(), - event.getDate() // 행사 필드에 종료 일자 추가시 종료 일자로 변경 필요 - ); + final EventReservation reservation; + try { + reservation = eventMapper.toReservation(event, user, request); + eventReservationRepository.save(reservation); + } catch (CustomException e) { + throw e; + } catch (Exception e) { + log.error("[EVENT] reserveEvent - reservation save failed, eventId={}", eventId, e); + throw new CustomException(EventErrorCode.EVENT_RESERVATION_CREATE_FAILED); + } + + try { + ticketService.issueTicket(TicketType.EVENT, event.getId(), user.getId(), event.getEndDate()); + } catch (CustomException e) { + throw e; + } catch (Exception e) { + log.error("[EVENT] reserveEvent - ticket issue failed, eventId={}", eventId, e); + throw new CustomException(EventErrorCode.EVENT_RESERVATION_CREATE_FAILED); + } if (clothImageList != null && !clothImageList.isEmpty()) { for (MultipartFile f : clothImageList) { try { String url = s3Service.uploadFileAsWebp(PathName.EVENT, f); - eventReservationImageRepository.save( EventReservationImage.builder().reservation(reservation).imageUrl(url).build()); } catch (CustomException e) { @@ -338,4 +371,88 @@ public EventReservationResponse reserveEvent( return eventMapper.toReservationResponse(event); } + + /* ========================= + * Validation + * ========================= */ + + private void validateEventInfoRequest(EventInfoRequest request) { + if (request == null) { + throw new CustomException(EventErrorCode.EVENT_CREATE_FAILED); + } + + LocalDate startDate = request.getStartDate(); + LocalDate endDate = request.getEndDate(); + + if (startDate != null && endDate != null && startDate.isAfter(endDate)) { + throw new CustomException(EventErrorCode.EVENT_INVALID_DATE_RANGE); + } + + Integer capacity = request.getCapacity(); + if (capacity != null && capacity < 1) { + throw new CustomException(EventErrorCode.EVENT_INVALID_CAPACITY); + } + } + + private void validateCapacityBeforeReserve(Event event) { + Integer capacity = event.getCapacity(); + Integer reserved = event.getTotalReservedCount(); + + if (capacity == null) { + return; + } + + int reservedCount = (reserved == null) ? 0 : reserved; + if (reservedCount >= capacity) { + throw new CustomException(EventErrorCode.EVENT_CAPACITY_EXCEEDED); + } + } + + @Override + public EventPagedResponse getEvents(int page, int size, EventStatus status, String q) { + + try { + LocalDate today = LocalDate.now(); + + Pageable pageable = PageRequest.of(page, size); + + String keyword = (q == null || q.isBlank()) ? null : q.trim(); + + Page eventPage; + + if (status == null) { + // 전체: ONGOING -> UPCOMING -> ENDED 우선 + startDate DESC + eventPage = eventRepository.findAllSortedByStatus(today, keyword, pageable); + + } else if (status == EventStatus.ONGOING) { + eventPage = eventRepository.findOngoingSorted(today, keyword, pageable); + + } else if (status == EventStatus.UPCOMING) { + eventPage = eventRepository.findUpcomingSorted(today, keyword, pageable); + + } else if (status == EventStatus.ENDED) { + eventPage = eventRepository.findEndedSorted(today, keyword, pageable); + + } else { + throw new CustomException(EventErrorCode.EVENT_LIST_FETCH_FAILED); + } + + log.info( + "[EVENT] getEvents page={}, size={}, status={}, q={}, totalElements={}", + page, + size, + status, + q, + eventPage.getTotalElements()); + + return eventMapper.toPagedResponse(eventPage, today); + + } catch (CustomException e) { + throw e; + } catch (Exception e) { + log.error( + "[EVENT] getEvents failed page={}, size={}, status={}, q={}", page, size, status, q, e); + throw new CustomException(EventErrorCode.EVENT_LIST_FETCH_FAILED); + } + } } diff --git a/src/main/java/com/sku/refit/domain/mypage/controller/MyPageController.java b/src/main/java/com/sku/refit/domain/mypage/controller/MyPageController.java index 92e321f..1aec38b 100644 --- a/src/main/java/com/sku/refit/domain/mypage/controller/MyPageController.java +++ b/src/main/java/com/sku/refit/domain/mypage/controller/MyPageController.java @@ -7,12 +7,9 @@ import org.springframework.web.bind.annotation.*; import com.sku.refit.domain.mypage.dto.response.MyPageResponse.*; -import com.sku.refit.domain.post.dto.response.PostDetailResponse; -import com.sku.refit.global.page.response.InfiniteResponse; import com.sku.refit.global.response.BaseResponse; import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.Parameter; import io.swagger.v3.oas.annotations.tags.Tag; @Tag(name = "마이페이지", description = "마이페이지 관련 API") @@ -50,30 +47,17 @@ ResponseEntity> getMyTickets( @GetMapping("/events/joined") @Operation( - summary = "참여한 행사 조회", - description = "행사 예약시 발급되는 티켓 중 사용 완료된 티켓을 기준으로 최신순으로 참여한 행사 목록을 반환합니다.") - ResponseEntity> getJoinedEvents(); + summary = "참가한 행사 조회", + description = "행사 예약시 발급되는 티켓 중 사용 완료된 티켓을 기준으로 최신순으로 참여한 행사 목록을 페이징하여 반환합니다.") + ResponseEntity> getJoinedEvents( + @RequestParam int page, @RequestParam int size); @GetMapping("/posts") @Operation( summary = "내가 작성한 글 조회", - description = - """ - 현재 로그인한 사용자가 작성한 게시글 목록을 커서 기반 무한스크롤로 조회합니다. - - ■ 커서 페이징 방식 - - 첫 조회: lastPostId 생략 - - 다음 조회: 직전 응답의 lastCursor 값을 lastPostId로 전달 - - 정렬: id DESC (최신글 먼저) - - hasNext: 다음 페이지 존재 여부 - - lastCursor: 다음 요청에 사용할 커서(마지막 항목의 postId) - """) - ResponseEntity>> getMyPosts( - @Parameter(description = "마지막으로 조회한 게시글 식별자(첫 조회 시 생략)", example = "10") - @RequestParam(required = false) - Long lastPostId, - @Parameter(description = "한 번에 조회할 게시글 개수", example = "10") @RequestParam(defaultValue = "10") - Integer size); + description = "현재 로그인한 사용자가 작성한 게시글 목록을 page/size 기반으로 페이징 조회합니다. 정렬: id DESC") + ResponseEntity> getMyPosts( + @RequestParam int page, @RequestParam int size); @GetMapping @Operation( diff --git a/src/main/java/com/sku/refit/domain/mypage/controller/MyPageControllerImpl.java b/src/main/java/com/sku/refit/domain/mypage/controller/MyPageControllerImpl.java index c5dbf9a..51f53e5 100644 --- a/src/main/java/com/sku/refit/domain/mypage/controller/MyPageControllerImpl.java +++ b/src/main/java/com/sku/refit/domain/mypage/controller/MyPageControllerImpl.java @@ -9,8 +9,6 @@ import com.sku.refit.domain.mypage.dto.response.MyPageResponse.*; import com.sku.refit.domain.mypage.service.MyPageService; -import com.sku.refit.domain.post.dto.response.PostDetailResponse; -import com.sku.refit.global.page.response.InfiniteResponse; import com.sku.refit.global.response.BaseResponse; import lombok.RequiredArgsConstructor; @@ -28,15 +26,15 @@ public ResponseEntity> getMyTickets( } @Override - public ResponseEntity> getJoinedEvents() { - return ResponseEntity.ok(BaseResponse.success(myPageService.getJoinedEvents())); + public ResponseEntity> getJoinedEvents( + @RequestParam int page, @RequestParam int size) { + return ResponseEntity.ok(BaseResponse.success(myPageService.getJoinedEvents(page, size))); } @Override - public ResponseEntity>> getMyPosts( - Long lastPostId, Integer size) { - - return ResponseEntity.ok(BaseResponse.success(myPageService.getMyPosts(lastPostId, size))); + public ResponseEntity> getMyPosts( + @RequestParam int page, @RequestParam int size) { + return ResponseEntity.ok(BaseResponse.success(myPageService.getMyPosts(page, size))); } @Override diff --git a/src/main/java/com/sku/refit/domain/mypage/dto/response/MyPageResponse.java b/src/main/java/com/sku/refit/domain/mypage/dto/response/MyPageResponse.java index 8447e1b..f9e4b0b 100644 --- a/src/main/java/com/sku/refit/domain/mypage/dto/response/MyPageResponse.java +++ b/src/main/java/com/sku/refit/domain/mypage/dto/response/MyPageResponse.java @@ -8,6 +8,7 @@ import java.util.List; import com.sku.refit.domain.mypage.constant.TicketUseStatus; +import com.sku.refit.domain.post.dto.response.PostDetailResponse; import com.sku.refit.domain.ticket.entity.TicketType; import com.sku.refit.domain.user.dto.response.UserDetailResponse; @@ -76,6 +77,11 @@ public static class MyTicketItem { @Getter @Builder public static class JoinedEventsResponse { + private int page; + private int size; + private long totalElements; + private int totalPages; + private boolean hasNext; private List items; } @@ -142,4 +148,19 @@ public static class CarbonChangeItem { @Schema(description = "변경량(g). 교환이면 +20", example = "20") private Long deltaG; } + + /* ========================= + * Posts + * ========================= */ + + @Getter + @Builder + public static class MyPostsResponse { + private int page; + private int size; + private long totalElements; + private int totalPages; + private boolean hasNext; + private List items; + } } diff --git a/src/main/java/com/sku/refit/domain/mypage/mapper/MyPageMapper.java b/src/main/java/com/sku/refit/domain/mypage/mapper/MyPageMapper.java index 7c6f6f3..9c9d995 100644 --- a/src/main/java/com/sku/refit/domain/mypage/mapper/MyPageMapper.java +++ b/src/main/java/com/sku/refit/domain/mypage/mapper/MyPageMapper.java @@ -15,6 +15,8 @@ import com.sku.refit.domain.mypage.constant.TicketUseStatus; import com.sku.refit.domain.mypage.dto.response.MyPageResponse.*; import com.sku.refit.domain.mypage.entity.CarbonReductionHistory; +import com.sku.refit.domain.post.dto.response.PostDetailResponse; +import com.sku.refit.domain.post.entity.Post; import com.sku.refit.domain.ticket.entity.Ticket; import com.sku.refit.domain.ticket.entity.TicketType; import com.sku.refit.domain.ticket.util.TicketQrPayloadFactory; @@ -88,38 +90,34 @@ public MyTicketItem toMyTicketItem(Ticket ticket, LocalDate today, Map events) { - - List items = events.stream().map(this::toJoinedEventItem).toList(); - - return JoinedEventsResponse.builder().items(items).build(); - } - public JoinedEventItem toJoinedEventItem(Event event) { return JoinedEventItem.builder() .eventId(event.getId()) .thumbnailUrl(event.getThumbnailUrl()) .name(event.getName()) .description(event.getDescription()) - .date(event.getDate()) + .date(event.getStartDate()) .location(event.getLocation()) .build(); } - /* ========================= - * Home - * ========================= */ + public JoinedEventsResponse toJoinedEventsResponse(Page eventIdPage, List events) { + List items = events.stream().map(this::toJoinedEventItem).toList(); - public MyHomeResponse toUnauthenticatedHome() { - return MyHomeResponse.builder() - .isLoggedIn(false) - .user(null) - .exchangeCount(null) - .totalReducedCarbonG(null) - .carbonChangeList(List.of()) + return JoinedEventsResponse.builder() + .page(eventIdPage.getNumber()) + .size(eventIdPage.getSize()) + .totalElements(eventIdPage.getTotalElements()) + .totalPages(eventIdPage.getTotalPages()) + .hasNext(eventIdPage.hasNext()) + .items(items) .build(); } + /* ========================= + * Home + * ========================= */ + public CarbonReductionHistory toCarbonHistory(User user, long deltaG, LocalDateTime now) { return CarbonReductionHistory.builder().user(user).changedAt(now).deltaG(deltaG).build(); } @@ -145,6 +143,21 @@ public MyHomeResponse toMyHomeResponse( .build(); } + /* ========================= + * Posts + * ========================= */ + + public MyPostsResponse toMyPostsResponse(Page postPage, List items) { + return MyPostsResponse.builder() + .page(postPage.getNumber()) + .size(postPage.getSize()) + .totalElements(postPage.getTotalElements()) + .totalPages(postPage.getTotalPages()) + .hasNext(postPage.hasNext()) + .items(items) + .build(); + } + /* ========================= * Private * ========================= */ diff --git a/src/main/java/com/sku/refit/domain/mypage/service/MyPageService.java b/src/main/java/com/sku/refit/domain/mypage/service/MyPageService.java index e5c594c..4bba3ad 100644 --- a/src/main/java/com/sku/refit/domain/mypage/service/MyPageService.java +++ b/src/main/java/com/sku/refit/domain/mypage/service/MyPageService.java @@ -4,72 +4,86 @@ package com.sku.refit.domain.mypage.service; import com.sku.refit.domain.mypage.dto.response.MyPageResponse.*; -import com.sku.refit.domain.post.dto.response.PostDetailResponse; -import com.sku.refit.global.page.response.InfiniteResponse; public interface MyPageService { - /* ========================= - * Tickets - * ========================= */ - /** - * 사용자의 현재 활성화된 티켓 목록을 페이징하여 조회합니다. + * 사용자의 현재 활성화된 티켓 목록을 페이지 단위로 조회합니다. * - *

활성 티켓의 기준은 다음과 같습니다. + *

활성 티켓 판단 기준 * *

    *
  • 아직 사용되지 않은 티켓 (usedAt == null) *
  • 만료되지 않은 티켓 (expiresAt == null 또는 expiresAt ≥ 오늘 날짜) *
* + *

결과는 발급일(createdAt) 기준 최신순으로 정렬됩니다. + * * @param page 조회할 페이지 번호 (0부터 시작) * @param size 한 페이지에 포함될 티켓 개수 - * @return 활성 티켓 목록과 페이징 정보를 포함한 응답 + * @return 활성 티켓 목록과 페이징 메타 정보를 포함한 응답 */ MyTicketsResponse getMyTickets(int page, int size); - /* ========================= - * Joined Events - * ========================= */ - /** - * 사용자가 실제로 참여(체크인)한 행사 목록을 조회합니다. + * 사용자가 실제로 참여(체크인)한 행사 목록을 페이지 단위로 조회합니다. * - *

이 메서드는 다음 조건을 만족하는 티켓을 기준으로 합니다. + *

조회 기준은 다음 조건을 만족하는 티켓입니다. * *

    *
  • 티켓 타입이 {@code EVENT} 인 경우 - *
  • 티켓이 이미 사용 처리된 경우 (usedAt != null) + *
  • 이미 사용 처리된 티켓 (usedAt != null) *
* - * @return 사용자가 참여한 행사 목록 응답 + *

행사 목록은 사용 시각(usedAt) 기준 최신순으로 반환됩니다. + * + * @param page 조회할 페이지 번호 (0부터 시작) + * @param size 한 페이지에 포함될 행사 개수 + * @return 참여한 행사 목록과 페이징 메타 정보를 포함한 응답 */ - JoinedEventsResponse getJoinedEvents(); + JoinedEventsResponse getJoinedEvents(int page, int size); /** - * 로그인한 사용자가 작성한 게시글 목록을 커서 기반 무한 스크롤 방식으로 조회합니다. + * 로그인한 사용자가 작성한 게시글 목록을 페이지 단위로 조회합니다. + * + *

게시글은 ID 기준 내림차순(최신 작성 글 우선)으로 정렬되며, 각 게시글에는 좋아요 수 및 사용자의 좋아요 여부가 함께 포함됩니다. * - * @param lastPostId 마지막으로 조회한 게시글 ID (첫 조회 시 {@code null}) - * @param size 한 번에 조회할 게시글 개수 - * @return 내가 작성한 게시글 무한 스크롤 응답 + * @param page 조회할 페이지 번호 (0부터 시작) + * @param size 한 페이지에 포함될 게시글 개수 + * @return 내가 작성한 게시글 목록과 페이징 메타 정보를 포함한 응답 */ - InfiniteResponse getMyPosts(Long lastPostId, Integer size); + MyPostsResponse getMyPosts(int page, int size); /** - * 마이페이지 홈 정보를 조회합니다. + * 마이페이지 홈 화면에 필요한 정보를 조회합니다. * - *

로그인 여부에 따라 다음 정보를 반환합니다. + *

로그인 여부에 따른 응답 구성 * *

    - *
  • 비로그인: 로그인 여부만 반환 - *
  • 로그인: 사용자 정보, 교환 횟수, 총 탄소 절감량, 탄소량 변경 이력 목록 + *
  • 비로그인 상태: 로그인 여부(false)만 반환 + *
  • 로그인 상태: + *
      + *
    • 사용자 기본 정보 + *
    • 누적 교환 횟수 + *
    • 총 줄인 탄소량(g) + *
    • 탄소량 변경 이력 목록 (그래프 표시용) + *
    *
* * @return 마이페이지 홈 응답 */ MyHomeResponse getMyHome(); - /** 교환 확정 시 호출: 탄소량 +20g, 교환횟수 +1, 이력 기록 */ + /** + * 교환 확정 시 호출되는 메서드입니다. + * + *

다음 작업을 하나의 트랜잭션으로 처리합니다. + * + *

    + *
  • 사용자의 교환 횟수 +1 + *
  • 총 줄인 탄소량 +20g + *
  • 탄소량 변경 이력(CarbonReductionHistory) 저장 + *
+ */ void addExchangeCarbon(); } diff --git a/src/main/java/com/sku/refit/domain/mypage/service/MyPageServiceImpl.java b/src/main/java/com/sku/refit/domain/mypage/service/MyPageServiceImpl.java index b3d1c7d..a66e529 100644 --- a/src/main/java/com/sku/refit/domain/mypage/service/MyPageServiceImpl.java +++ b/src/main/java/com/sku/refit/domain/mypage/service/MyPageServiceImpl.java @@ -35,7 +35,6 @@ import com.sku.refit.domain.user.service.UserService; import com.sku.refit.global.exception.CustomException; import com.sku.refit.global.page.mapper.InfiniteMapper; -import com.sku.refit.global.page.response.InfiniteResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -92,77 +91,43 @@ public MyTicketsResponse getMyTickets(int page, int size) { } @Override - public JoinedEventsResponse getJoinedEvents() { - + public JoinedEventsResponse getJoinedEvents(int page, int size) { Long userId = userService.getCurrentUser().getId(); - try { - List usedEventTickets = - ticketRepository.findAllByUserIdAndTypeAndUsedAtIsNotNullOrderByUsedAtDesc( - userId, TicketType.EVENT); - - if (usedEventTickets.isEmpty()) { - log.info("[MYPAGE] getJoinedEvents userId={}, resultCount=0", userId); - return JoinedEventsResponse.builder().items(List.of()).build(); - } - - LinkedHashSet orderedEventIds = new LinkedHashSet<>(); - for (Ticket t : usedEventTickets) { - orderedEventIds.add(t.getTargetId()); - } - - Map eventMap = - eventRepository.findAllById(orderedEventIds).stream() - .collect(Collectors.toMap(Event::getId, Function.identity())); + Pageable pageable = PageRequest.of(page, size); + Page eventIdPage = + ticketRepository.findJoinedEventIds(userId, TicketType.EVENT, pageable); - List orderedEvents = - orderedEventIds.stream().map(eventMap::get).filter(Objects::nonNull).toList(); + List eventIdsOrdered = eventIdPage.getContent(); + if (eventIdsOrdered.isEmpty()) { + return myPageMapper.toJoinedEventsResponse(Page.empty(pageable), List.of()); + } - log.info( - "[MYPAGE] getJoinedEvents userId={}, tickets={}, uniqueEvents={}", - userId, - usedEventTickets.size(), - orderedEvents.size()); + Map eventMap = + eventRepository.findAllById(eventIdsOrdered).stream() + .collect(Collectors.toMap(Event::getId, Function.identity())); - return myPageMapper.toJoinedEventsResponse(orderedEvents); + List orderedEvents = + eventIdsOrdered.stream().map(eventMap::get).filter(Objects::nonNull).toList(); - } catch (CustomException e) { - throw e; - } catch (Exception e) { - log.error("[MYPAGE] getJoinedEvents failed userId={}", userId, e); - throw new CustomException(MyPageErrorCode.JOINED_EVENTS_FETCH_FAILED); - } + return myPageMapper.toJoinedEventsResponse(eventIdPage, orderedEvents); } @Override - public InfiniteResponse getMyPosts(Long lastPostId, Integer size) { + public MyPostsResponse getMyPosts(int page, int size) { User user = userService.getCurrentUser(); try { - Pageable pageable = PageRequest.of(0, size + 1, Sort.by(Sort.Direction.DESC, "id")); - - List posts; - if (lastPostId == null) { - posts = postRepository.findAllByUser_Id(user.getId(), pageable).getContent(); - } else { - posts = - postRepository - .findAllByUser_IdAndIdLessThan(user.getId(), lastPostId, pageable) - .getContent(); - } - - boolean hasNext = posts.size() > size; - if (hasNext) { - posts = posts.subList(0, size); - } + Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "id")); + Page postPage = postRepository.findAllByUser_Id(user.getId(), pageable); + List posts = postPage.getContent(); List postIds = posts.stream().map(Post::getId).toList(); Map likeCountMap = new HashMap<>(); if (!postIds.isEmpty()) { - List likeCounts = postLikeRepository.countByPostIds(postIds); - for (Object[] row : likeCounts) { + for (Object[] row : postLikeRepository.countByPostIds(postIds)) { likeCountMap.put((Long) row[0], (Long) row[1]); } } @@ -172,7 +137,7 @@ public InfiniteResponse getMyPosts(Long lastPostId, Integer ? Set.of() : new HashSet<>(postLikeRepository.findLikedPostIds(postIds, user.getId())); - List responseList = + List items = posts.stream() .map( post -> @@ -183,27 +148,23 @@ public InfiniteResponse getMyPosts(Long lastPostId, Integer user)) .toList(); - Long newLastCursor = posts.isEmpty() ? null : posts.getLast().getId(); + MyPostsResponse response = myPageMapper.toMyPostsResponse(postPage, items); log.info( - "[MYPAGE] getMyPosts userId={}, lastPostId={}, size={}, resultCount={}, hasNext={}", + "[MYPAGE] getMyPosts userId={}, page={}, size={}, resultCount={}, hasNext={}", user.getId(), - lastPostId, + page, size, - responseList.size(), - hasNext); + items.size(), + response.isHasNext()); - return infiniteMapper.toInfiniteResponse(responseList, newLastCursor, hasNext, size); + return response; } catch (CustomException e) { throw e; } catch (Exception e) { log.error( - "[MYPAGE] getMyPosts failed userId={}, lastPostId={}, size={}", - user.getId(), - lastPostId, - size, - e); + "[MYPAGE] getMyPosts failed userId={}, page={}, size={}", user.getId(), page, size, e); throw new CustomException(MyPageErrorCode.MY_POSTS_FETCH_FAILED); } } diff --git a/src/main/java/com/sku/refit/domain/ticket/repository/TicketRepository.java b/src/main/java/com/sku/refit/domain/ticket/repository/TicketRepository.java index 11cad19..4645971 100644 --- a/src/main/java/com/sku/refit/domain/ticket/repository/TicketRepository.java +++ b/src/main/java/com/sku/refit/domain/ticket/repository/TicketRepository.java @@ -32,4 +32,17 @@ List findAllByUserIdAndTypeAndUsedAtIsNotNullOrderByUsedAtDesc( Optional findByTokenForUpdate(@Param("token") String token); Page findAllByUserId(Long userId, Pageable pageable); + + @Query( + """ + select t.targetId + from Ticket t + where t.userId = :userId + and t.type = :type + and t.usedAt is not null + group by t.targetId + order by max(t.usedAt) desc + """) + Page findJoinedEventIds( + @Param("userId") Long userId, @Param("type") TicketType type, Pageable pageable); }