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 8d253c5..1ed6152 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 @@ -21,6 +21,8 @@ import com.sku.refit.domain.event.repository.EventRepository; import com.sku.refit.domain.event.repository.EventReservationImageRepository; import com.sku.refit.domain.event.repository.EventReservationRepository; +import com.sku.refit.domain.ticket.entity.TicketType; +import com.sku.refit.domain.ticket.service.TicketService; import com.sku.refit.domain.user.entity.User; import com.sku.refit.domain.user.service.UserService; import com.sku.refit.global.exception.CustomException; @@ -43,6 +45,7 @@ public class EventServiceImpl implements EventService { private final S3Service s3Service; private final UserService userService; private final EventMapper eventMapper; + private final TicketService ticketService; /* ========================= * Admin @@ -308,6 +311,12 @@ public EventReservationResponse reserveEvent( EventReservation reservation = eventMapper.toReservation(event, user, request); eventReservationRepository.save(reservation); + ticketService.issueTicket( + TicketType.EVENT, + event.getId(), + user.getId(), + event.getDate() // 행사 필드에 종료 일자 추가시 종료 일자로 변경 필요 + ); if (clothImageList != null && !clothImageList.isEmpty()) { for (MultipartFile f : clothImageList) { diff --git a/src/main/java/com/sku/refit/domain/mypage/constant/TicketUseStatus.java b/src/main/java/com/sku/refit/domain/mypage/constant/TicketUseStatus.java new file mode 100644 index 0000000..001ce26 --- /dev/null +++ b/src/main/java/com/sku/refit/domain/mypage/constant/TicketUseStatus.java @@ -0,0 +1,10 @@ +/* + * Copyright (c) SKU 다시입을Lab + */ +package com.sku.refit.domain.mypage.constant; + +public enum TicketUseStatus { + UNUSED, // 사용전 + USED, // 사용완료 + EXPIRED // 사용만료 +} 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 new file mode 100644 index 0000000..92e321f --- /dev/null +++ b/src/main/java/com/sku/refit/domain/mypage/controller/MyPageController.java @@ -0,0 +1,91 @@ +/* + * Copyright (c) SKU 다시입을Lab + */ +package com.sku.refit.domain.mypage.controller; + +import org.springframework.http.ResponseEntity; +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") +@RequestMapping("/api/my") +public interface MyPageController { + + @GetMapping("/tickets") + @Operation( + summary = "내 티켓 리스트 조회", + description = + """ + 현재 로그인한 사용자의 티켓 목록을 페이징하여 조회합니다. + + ■ 반환 데이터 + - 티켓 ID + - 티켓 타입 (EVENT / CLOTH) + - 티켓 상태 (UNUSED / USED / EXPIRED) + - 티켓명 + - 위치 정보 + - 설명 + - QR payload URL + - 발급 시각 + - 사용 시각 (사용 완료된 경우) + - 만료일 + + ■ 정렬 기준 + - 발급 시각(createdAt) 기준 내림차순 (최신 발급 티켓 우선) + + ■ 페이징 + - page: 조회할 페이지 번호 (0부터 시작) + - size: 한 페이지에 포함될 티켓 개수 + """) + ResponseEntity> getMyTickets( + @RequestParam int page, @RequestParam int size); + + @GetMapping("/events/joined") + @Operation( + summary = "참여한 행사 조회", + description = "행사 예약시 발급되는 티켓 중 사용 완료된 티켓을 기준으로 최신순으로 참여한 행사 목록을 반환합니다.") + ResponseEntity> getJoinedEvents(); + + @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); + + @GetMapping + @Operation( + summary = "마이페이지 홈 조회", + description = + """ + 마이페이지 홈에 필요한 정보를 조회합니다. + + 로그인 여부, 사용자 정보, 교환 횟수, 총 누적 탄소 절감량과 + 탄소량 변경 이력[변경 시각, 변경 후 누적량, 변경량]을 반환합니다. (과거 → 최신순) + + 해당 이력 데이터는 그래프 시각화 용도로 사용됩니다. + """) + ResponseEntity> getMyHome(); +} 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 new file mode 100644 index 0000000..c5dbf9a --- /dev/null +++ b/src/main/java/com/sku/refit/domain/mypage/controller/MyPageControllerImpl.java @@ -0,0 +1,46 @@ +/* + * Copyright (c) SKU 다시입을Lab + */ +package com.sku.refit.domain.mypage.controller; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +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; + +@RestController +@RequiredArgsConstructor +public class MyPageControllerImpl implements MyPageController { + + private final MyPageService myPageService; + + @Override + public ResponseEntity> getMyTickets( + @RequestParam int page, @RequestParam int size) { + return ResponseEntity.ok(BaseResponse.success(myPageService.getMyTickets(page, size))); + } + + @Override + public ResponseEntity> getJoinedEvents() { + return ResponseEntity.ok(BaseResponse.success(myPageService.getJoinedEvents())); + } + + @Override + public ResponseEntity>> getMyPosts( + Long lastPostId, Integer size) { + + return ResponseEntity.ok(BaseResponse.success(myPageService.getMyPosts(lastPostId, size))); + } + + @Override + public ResponseEntity> getMyHome() { + return ResponseEntity.ok(BaseResponse.success(myPageService.getMyHome())); + } +} 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 new file mode 100644 index 0000000..8447e1b --- /dev/null +++ b/src/main/java/com/sku/refit/domain/mypage/dto/response/MyPageResponse.java @@ -0,0 +1,145 @@ +/* + * Copyright (c) SKU 다시입을Lab + */ +package com.sku.refit.domain.mypage.dto.response; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; + +import com.sku.refit.domain.mypage.constant.TicketUseStatus; +import com.sku.refit.domain.ticket.entity.TicketType; +import com.sku.refit.domain.user.dto.response.UserDetailResponse; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.*; + +public class MyPageResponse { + + /* ========================= + * Tickets (paged) + * ========================= */ + + @Getter + @Builder + public static class MyTicketsResponse { + private int page; + private int size; + private long totalElements; + private int totalPages; + private boolean hasNext; + private List items; + } + + @Getter + @Builder + @Schema(title = "MyTicketItem DTO", description = "마이페이지 티켓 리스트 아이템") + public static class MyTicketItem { + + @Schema(description = "티켓 ID", example = "10") + private Long ticketId; + + @Schema(description = "티켓 타입", example = "EVENT") + private TicketType type; + + @Schema(description = "사용 상태(사용전/사용완료/사용만료)", example = "UNUSED") + private TicketUseStatus status; + + @Schema(description = "티켓명(표시용)", example = "겨울 의류 나눔 행사") + private String ticketName; + + @Schema(description = "위치(표시용)", example = "서울 성동구") + private String location; + + @Schema(description = "설명(표시용)", example = "따뜻한 겨울을 위한 의류 기부 행사입니다.") + private String description; + + @Schema( + description = "QR payload(URL)", + example = "https://api/refitlab.site/ticket?v=1&token=xxx") + private String url; + + @Schema(description = "발급 시각", example = "2025-12-01T10:00:00") + private LocalDateTime issuedAt; + + @Schema(description = "사용 시각", example = "2025-12-24T12:30:00") + private LocalDateTime usedAt; + + @Schema(description = "유효기간", example = "2025-12-24T23:59:59") + private LocalDate expiresAt; + } + + /* ========================= + * Joined Events + * ========================= */ + + @Getter + @Builder + public static class JoinedEventsResponse { + private List items; + } + + @Getter + @Builder + @Schema(title = "JoinedEventItem DTO", description = "참가한 행사 응답") + public static class JoinedEventItem { + + @Schema(description = "행사 식별자", example = "1") + private Long eventId; + + @Schema(description = "썸네일 이미지 URL") + private String thumbnailUrl; + + @Schema(description = "행사명", example = "겨울 의류 나눔 행사") + private String name; + + @Schema(description = "행사 설명", example = "따뜻한 겨울을 위한 의류 기부 행사입니다.") + private String description; + + @Schema(description = "행사 날짜", example = "2025-12-24") + private LocalDate date; + + @Schema(description = "행사 장소", example = "서울 성동구") + private String location; + } + + /* ========================= + * Home + * ========================= */ + + @Getter + @Builder + @Schema(title = "MyPageHomeResponse DTO", description = "마이페이지 홈(/api/my) 응답") + public static class MyHomeResponse { + + @Schema(description = "로그인 여부", example = "true") + private Boolean isLoggedIn; + + @Schema(description = "사용자 정보 (비로그인 시 null)") + private UserDetailResponse user; + + @Schema(description = "나의 교환 횟수", example = "5") + private Integer exchangeCount; + + @Schema(description = "총 줄인 탄소량(g)", example = "750") + private Long totalReducedCarbonG; + + @Schema(description = "탄소량 변경 이력(최신순)") + private List carbonChangeList; + } + + @Getter + @Builder + @Schema(title = "CarbonChangeItem DTO", description = "탄소량 변경 이력 아이템") + public static class CarbonChangeItem { + + @Schema(description = "변경 일시", example = "2025-12-24T12:30:00") + private LocalDateTime changedAt; + + @Schema(description = "변경일까지의 누적값", example = "40") + private Long totalAfterG; + + @Schema(description = "변경량(g). 교환이면 +20", example = "20") + private Long deltaG; + } +} diff --git a/src/main/java/com/sku/refit/domain/mypage/entity/CarbonReductionHistory.java b/src/main/java/com/sku/refit/domain/mypage/entity/CarbonReductionHistory.java new file mode 100644 index 0000000..e5d146a --- /dev/null +++ b/src/main/java/com/sku/refit/domain/mypage/entity/CarbonReductionHistory.java @@ -0,0 +1,42 @@ +/* + * Copyright (c) SKU 다시입을Lab + */ +package com.sku.refit.domain.mypage.entity; + +import java.time.LocalDateTime; + +import jakarta.persistence.*; + +import com.sku.refit.domain.user.entity.User; +import com.sku.refit.global.common.BaseTimeEntity; + +import lombok.*; + +@Entity +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Table( + name = "carbon_reduction_history", + indexes = { + @Index(name = "idx_carbon_hist_user", columnList = "user_id"), + @Index(name = "idx_carbon_hist_changed_at", columnList = "changed_at") + }) +public class CarbonReductionHistory extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column(name = "changed_at", nullable = false) + private LocalDateTime changedAt; + + /** 변경량(g). 교환이면 +20 */ + @Column(name = "delta_g", nullable = false) + private Long deltaG; +} diff --git a/src/main/java/com/sku/refit/domain/mypage/exception/MyPageErrorCode.java b/src/main/java/com/sku/refit/domain/mypage/exception/MyPageErrorCode.java new file mode 100644 index 0000000..765090d --- /dev/null +++ b/src/main/java/com/sku/refit/domain/mypage/exception/MyPageErrorCode.java @@ -0,0 +1,25 @@ +/* + * Copyright (c) SKU 다시입을Lab + */ +package com.sku.refit.domain.mypage.exception; + +import org.springframework.http.HttpStatus; + +import com.sku.refit.global.exception.model.BaseErrorCode; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum MyPageErrorCode implements BaseErrorCode { + TICKETS_FETCH_FAILED("MYPAGE001", "티켓 목록 조회에 실패했습니다.", HttpStatus.INTERNAL_SERVER_ERROR), + JOINED_EVENTS_FETCH_FAILED("MYPAGE002", "참여한 행사 조회에 실패했습니다.", HttpStatus.INTERNAL_SERVER_ERROR), + MY_POSTS_FETCH_FAILED("MYPAGE003", "내가 작성한 글 조회에 실패했습니다.", HttpStatus.INTERNAL_SERVER_ERROR), + CARBON_ADD_FAILED("MYPAGE004", "탄소량 반영에 실패했습니다.", HttpStatus.INTERNAL_SERVER_ERROR), + MY_HOME_FETCH_FAILED("MYPAGE005", "내 홈 조회에 실패했습니다.", HttpStatus.INTERNAL_SERVER_ERROR), + ; + private final String code; + private final String message; + private final HttpStatus status; +} 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 new file mode 100644 index 0000000..7c6f6f3 --- /dev/null +++ b/src/main/java/com/sku/refit/domain/mypage/mapper/MyPageMapper.java @@ -0,0 +1,161 @@ +/* + * Copyright (c) SKU 다시입을Lab + */ +package com.sku.refit.domain.mypage.mapper; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +import org.springframework.data.domain.Page; +import org.springframework.stereotype.Component; + +import com.sku.refit.domain.event.entity.Event; +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.ticket.entity.Ticket; +import com.sku.refit.domain.ticket.entity.TicketType; +import com.sku.refit.domain.ticket.util.TicketQrPayloadFactory; +import com.sku.refit.domain.user.dto.response.UserDetailResponse; +import com.sku.refit.domain.user.entity.User; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class MyPageMapper { + + private final TicketQrPayloadFactory qrPayloadFactory; + + /* ========================= + * Tickets Response + * ========================= */ + + public MyTicketsResponse toMyTicketsResponse( + Page ticketPage, LocalDate today, Map eventMap) { + + List items = + ticketPage.getContent().stream() + .map(ticket -> toMyTicketItem(ticket, today, eventMap)) + .toList(); + + return MyTicketsResponse.builder() + .page(ticketPage.getNumber()) + .size(ticketPage.getSize()) + .totalElements(ticketPage.getTotalElements()) + .totalPages(ticketPage.getTotalPages()) + .hasNext(ticketPage.hasNext()) + .items(items) + .build(); + } + + public MyTicketItem toMyTicketItem(Ticket ticket, LocalDate today, Map eventMap) { + + TicketUseStatus status = resolveStatus(ticket, today); + + String ticketName = null; + String location = null; + String description = null; + + if (ticket.getType() == TicketType.EVENT) { + Event event = eventMap.get(ticket.getTargetId()); + if (event != null) { + ticketName = event.getName(); + location = event.getLocation(); + description = event.getDescription(); + } + } else if (ticket.getType() == TicketType.CLOTH) { + ticketName = "의류 티켓"; + } + + return MyTicketItem.builder() + .ticketId(ticket.getId()) + .type(ticket.getType()) + .status(status) + .ticketName(ticketName) + .location(location) + .description(description) + .url(qrPayloadFactory.create(ticket.getToken())) + .issuedAt(ticket.getCreatedAt()) + .usedAt(ticket.getUsedAt()) + .expiresAt(ticket.getExpiresAt()) + .build(); + } + + /* ========================= + * Joined Events Response + * ========================= */ + + public JoinedEventsResponse toJoinedEventsResponse(List 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()) + .location(event.getLocation()) + .build(); + } + + /* ========================= + * Home + * ========================= */ + + public MyHomeResponse toUnauthenticatedHome() { + return MyHomeResponse.builder() + .isLoggedIn(false) + .user(null) + .exchangeCount(null) + .totalReducedCarbonG(null) + .carbonChangeList(List.of()) + .build(); + } + + public CarbonReductionHistory toCarbonHistory(User user, long deltaG, LocalDateTime now) { + return CarbonReductionHistory.builder().user(user).changedAt(now).deltaG(deltaG).build(); + } + + public CarbonChangeItem toCarbonChangeItem(CarbonReductionHistory h, Long totalAfterG) { + return CarbonChangeItem.builder() + .changedAt(h.getChangedAt()) + .deltaG(h.getDeltaG()) + .totalAfterG(totalAfterG) + .build(); + } + + public MyHomeResponse toMyHomeResponse( + User user, UserDetailResponse userDetail, List carbonChangeList) { + + return MyHomeResponse.builder() + .isLoggedIn(true) + .user(userDetail) + .exchangeCount(user.getExchangeCount() == null ? 0 : user.getExchangeCount()) + .totalReducedCarbonG( + user.getTotalReducedCarbonG() == null ? 0L : user.getTotalReducedCarbonG()) + .carbonChangeList(carbonChangeList) + .build(); + } + + /* ========================= + * Private + * ========================= */ + + private TicketUseStatus resolveStatus(Ticket ticket, LocalDate today) { + if (ticket.isUsed()) { + return TicketUseStatus.USED; + } + if (ticket.isExpired(today)) { + return TicketUseStatus.EXPIRED; + } + return TicketUseStatus.UNUSED; + } +} diff --git a/src/main/java/com/sku/refit/domain/mypage/repository/CarbonReductionHistoryRepository.java b/src/main/java/com/sku/refit/domain/mypage/repository/CarbonReductionHistoryRepository.java new file mode 100644 index 0000000..e66d1c6 --- /dev/null +++ b/src/main/java/com/sku/refit/domain/mypage/repository/CarbonReductionHistoryRepository.java @@ -0,0 +1,15 @@ +/* + * Copyright (c) SKU 다시입을Lab + */ +package com.sku.refit.domain.mypage.repository; + +import java.util.List; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.sku.refit.domain.mypage.entity.CarbonReductionHistory; + +public interface CarbonReductionHistoryRepository + extends JpaRepository { + List findByUser_IdOrderByChangedAtDesc(Long userId); +} 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 new file mode 100644 index 0000000..e5c594c --- /dev/null +++ b/src/main/java/com/sku/refit/domain/mypage/service/MyPageService.java @@ -0,0 +1,75 @@ +/* + * Copyright (c) SKU 다시입을Lab + */ +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 ≥ 오늘 날짜) + *
+ * + * @param page 조회할 페이지 번호 (0부터 시작) + * @param size 한 페이지에 포함될 티켓 개수 + * @return 활성 티켓 목록과 페이징 정보를 포함한 응답 + */ + MyTicketsResponse getMyTickets(int page, int size); + + /* ========================= + * Joined Events + * ========================= */ + + /** + * 사용자가 실제로 참여(체크인)한 행사 목록을 조회합니다. + * + *

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

    + *
  • 티켓 타입이 {@code EVENT} 인 경우 + *
  • 티켓이 이미 사용 처리된 경우 (usedAt != null) + *
+ * + * @return 사용자가 참여한 행사 목록 응답 + */ + JoinedEventsResponse getJoinedEvents(); + + /** + * 로그인한 사용자가 작성한 게시글 목록을 커서 기반 무한 스크롤 방식으로 조회합니다. + * + * @param lastPostId 마지막으로 조회한 게시글 ID (첫 조회 시 {@code null}) + * @param size 한 번에 조회할 게시글 개수 + * @return 내가 작성한 게시글 무한 스크롤 응답 + */ + InfiniteResponse getMyPosts(Long lastPostId, Integer size); + + /** + * 마이페이지 홈 정보를 조회합니다. + * + *

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

    + *
  • 비로그인: 로그인 여부만 반환 + *
  • 로그인: 사용자 정보, 교환 횟수, 총 탄소 절감량, 탄소량 변경 이력 목록 + *
+ * + * @return 마이페이지 홈 응답 + */ + MyHomeResponse getMyHome(); + + /** 교환 확정 시 호출: 탄소량 +20g, 교환횟수 +1, 이력 기록 */ + 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 new file mode 100644 index 0000000..b3d1c7d --- /dev/null +++ b/src/main/java/com/sku/refit/domain/mypage/service/MyPageServiceImpl.java @@ -0,0 +1,300 @@ +/* + * Copyright (c) SKU 다시입을Lab + */ +package com.sku.refit.domain.mypage.service; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.*; +import java.util.function.Function; +import java.util.stream.Collectors; + +import org.springframework.data.domain.*; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.sku.refit.domain.event.entity.Event; +import com.sku.refit.domain.event.repository.EventRepository; +import com.sku.refit.domain.mypage.dto.response.MyPageResponse.*; +import com.sku.refit.domain.mypage.entity.CarbonReductionHistory; +import com.sku.refit.domain.mypage.exception.MyPageErrorCode; +import com.sku.refit.domain.mypage.mapper.MyPageMapper; +import com.sku.refit.domain.mypage.repository.CarbonReductionHistoryRepository; +import com.sku.refit.domain.post.dto.response.PostDetailResponse; +import com.sku.refit.domain.post.entity.Post; +import com.sku.refit.domain.post.mapper.PostMapper; +import com.sku.refit.domain.post.repository.PostLikeRepository; +import com.sku.refit.domain.post.repository.PostRepository; +import com.sku.refit.domain.ticket.entity.Ticket; +import com.sku.refit.domain.ticket.entity.TicketType; +import com.sku.refit.domain.ticket.repository.TicketRepository; +import com.sku.refit.domain.user.dto.response.UserDetailResponse; +import com.sku.refit.domain.user.entity.User; +import com.sku.refit.domain.user.mapper.UserMapper; +import com.sku.refit.domain.user.repository.UserRepository; +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; + +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class MyPageServiceImpl implements MyPageService { + + private static final long EXCHANGE_CARBON_DELTA_G = 20L; + + private final UserService userService; + private final TicketRepository ticketRepository; + private final EventRepository eventRepository; + private final MyPageMapper myPageMapper; + private final UserMapper userMapper; + + private final PostRepository postRepository; + private final PostLikeRepository postLikeRepository; + private final UserRepository userRepository; + private final PostMapper postMapper; + private final InfiniteMapper infiniteMapper; + + private final CarbonReductionHistoryRepository carbonHistoryRepository; + + @Override + public MyTicketsResponse getMyTickets(int page, int size) { + + Long userId = userService.getCurrentUser().getId(); + LocalDate today = LocalDate.now(); + + try { + Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")); + Page ticketPage = ticketRepository.findAllByUserId(userId, pageable); + + Map eventMap = loadEventMap(ticketPage.getContent()); + + log.info( + "[MYPAGE] getMyTickets userId={}, page={}, size={}, totalElements={}", + userId, + page, + size, + ticketPage.getTotalElements()); + + return myPageMapper.toMyTicketsResponse(ticketPage, today, eventMap); + + } catch (CustomException e) { + throw e; + } catch (Exception e) { + log.error("[MYPAGE] getMyTickets failed userId={}, page={}, size={}", userId, page, size, e); + throw new CustomException(MyPageErrorCode.TICKETS_FETCH_FAILED); + } + } + + @Override + public JoinedEventsResponse getJoinedEvents() { + + 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())); + + List orderedEvents = + orderedEventIds.stream().map(eventMap::get).filter(Objects::nonNull).toList(); + + log.info( + "[MYPAGE] getJoinedEvents userId={}, tickets={}, uniqueEvents={}", + userId, + usedEventTickets.size(), + orderedEvents.size()); + + return myPageMapper.toJoinedEventsResponse(orderedEvents); + + } catch (CustomException e) { + throw e; + } catch (Exception e) { + log.error("[MYPAGE] getJoinedEvents failed userId={}", userId, e); + throw new CustomException(MyPageErrorCode.JOINED_EVENTS_FETCH_FAILED); + } + } + + @Override + public InfiniteResponse getMyPosts(Long lastPostId, Integer 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); + } + + List postIds = posts.stream().map(Post::getId).toList(); + + Map likeCountMap = new HashMap<>(); + if (!postIds.isEmpty()) { + List likeCounts = postLikeRepository.countByPostIds(postIds); + for (Object[] row : likeCounts) { + likeCountMap.put((Long) row[0], (Long) row[1]); + } + } + + Set likedPostIds = + postIds.isEmpty() + ? Set.of() + : new HashSet<>(postLikeRepository.findLikedPostIds(postIds, user.getId())); + + List responseList = + posts.stream() + .map( + post -> + postMapper.toDetailResponse( + post, + likeCountMap.getOrDefault(post.getId(), 0L), + likedPostIds.contains(post.getId()), + user)) + .toList(); + + Long newLastCursor = posts.isEmpty() ? null : posts.getLast().getId(); + + log.info( + "[MYPAGE] getMyPosts userId={}, lastPostId={}, size={}, resultCount={}, hasNext={}", + user.getId(), + lastPostId, + size, + responseList.size(), + hasNext); + + return infiniteMapper.toInfiniteResponse(responseList, newLastCursor, hasNext, size); + + } catch (CustomException e) { + throw e; + } catch (Exception e) { + log.error( + "[MYPAGE] getMyPosts failed userId={}, lastPostId={}, size={}", + user.getId(), + lastPostId, + size, + e); + throw new CustomException(MyPageErrorCode.MY_POSTS_FETCH_FAILED); + } + } + + @Override + public MyHomeResponse getMyHome() { + + final User user = userService.getCurrentUser(); + + try { + UserDetailResponse userDetail = userMapper.toUserDetailResponse(user); + + List histories = + carbonHistoryRepository.findByUser_IdOrderByChangedAtDesc(user.getId()); + + long runningTotal = + (user.getTotalReducedCarbonG() == null) ? 0L : user.getTotalReducedCarbonG(); + + List changeList = new ArrayList<>(histories.size()); + + for (CarbonReductionHistory h : histories) { + CarbonChangeItem item = myPageMapper.toCarbonChangeItem(h, runningTotal); + changeList.add(item); + + Long delta = (h.getDeltaG() == null) ? 0L : h.getDeltaG(); + runningTotal -= delta; + } + + Collections.reverse(changeList); + + log.info( + "[MYPAGE] getMyHome - userId={}, exchangeCount={}, totalReducedCarbonG={}, historySize={}", + user.getId(), + user.getExchangeCount(), + user.getTotalReducedCarbonG(), + changeList.size()); + + return myPageMapper.toMyHomeResponse(user, userDetail, changeList); + + } catch (CustomException e) { + throw e; + } catch (Exception e) { + log.error("[MYPAGE] getMyHome - failed, userId={}", user.getId(), e); + throw new CustomException(MyPageErrorCode.MY_HOME_FETCH_FAILED); + } + } + + private Map loadEventMap(List tickets) { + + List eventIds = + tickets.stream() + .filter(t -> t.getType() == TicketType.EVENT) + .map(Ticket::getTargetId) + .distinct() + .toList(); + + if (eventIds.isEmpty()) { + return Map.of(); + } + + return eventRepository.findAllById(eventIds).stream() + .collect(Collectors.toMap(Event::getId, Function.identity())); + } + + @Override + @Transactional + public void addExchangeCarbon() { + + User user = userService.getCurrentUser(); + + try { + user.addExchangeCarbon(EXCHANGE_CARBON_DELTA_G); + + CarbonReductionHistory history = + myPageMapper.toCarbonHistory(user, EXCHANGE_CARBON_DELTA_G, LocalDateTime.now()); + + carbonHistoryRepository.save(history); + userRepository.save(user); + + log.info( + "[MYPAGE] addExchangeCarbon - userId={}, +{}g, exchangeCount={}, totalReducedCarbonG={}", + user.getId(), + EXCHANGE_CARBON_DELTA_G, + user.getExchangeCount(), + user.getTotalReducedCarbonG()); + + } catch (CustomException e) { + throw e; + } catch (Exception e) { + log.error("[MYPAGE] addExchangeCarbon - failed, userId={}", user.getId(), e); + throw new CustomException(MyPageErrorCode.CARBON_ADD_FAILED); + } + } +} diff --git a/src/main/java/com/sku/refit/domain/post/controller/PostController.java b/src/main/java/com/sku/refit/domain/post/controller/PostController.java index 0efa1fa..c076b20 100644 --- a/src/main/java/com/sku/refit/domain/post/controller/PostController.java +++ b/src/main/java/com/sku/refit/domain/post/controller/PostController.java @@ -48,6 +48,11 @@ ResponseEntity> createPost( @RequestPart(value = "imageList", required = false) List imageList); + @PostMapping("/{postId}/like") + @Operation(summary = "게시글 좋아요 토글", description = "게시글 좋아요를 등록/취소합니다.") + ResponseEntity> togglePostLike( + @Parameter(description = "게시글 ID", example = "1") @PathVariable Long postId); + @GetMapping("/admin") @Operation(summary = "[관리자] 게시글 전체 조회", description = "전체 게시글 리스트를 조회합니다.") ResponseEntity>> getAllPosts(); diff --git a/src/main/java/com/sku/refit/domain/post/controller/PostControllerImpl.java b/src/main/java/com/sku/refit/domain/post/controller/PostControllerImpl.java index 325d411..3487769 100644 --- a/src/main/java/com/sku/refit/domain/post/controller/PostControllerImpl.java +++ b/src/main/java/com/sku/refit/domain/post/controller/PostControllerImpl.java @@ -8,6 +8,7 @@ import jakarta.validation.Valid; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; @@ -18,11 +19,9 @@ import com.sku.refit.global.response.BaseResponse; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; @RestController @RequiredArgsConstructor -@Slf4j public class PostControllerImpl implements PostController { private final PostService postService; @@ -35,6 +34,13 @@ public ResponseEntity> createPost( return ResponseEntity.ok(BaseResponse.success(response)); } + @Override + public ResponseEntity> togglePostLike(@PathVariable Long postId) { + + boolean liked = postService.togglePostLike(postId); + return ResponseEntity.ok(BaseResponse.success(liked)); + } + @Override public ResponseEntity>> getAllPosts() { diff --git a/src/main/java/com/sku/refit/domain/post/dto/response/PostDetailResponse.java b/src/main/java/com/sku/refit/domain/post/dto/response/PostDetailResponse.java index c9aae55..07b94f5 100644 --- a/src/main/java/com/sku/refit/domain/post/dto/response/PostDetailResponse.java +++ b/src/main/java/com/sku/refit/domain/post/dto/response/PostDetailResponse.java @@ -32,6 +32,12 @@ public class PostDetailResponse { @Schema(description = "게시글 조회수", example = "100") private Long views; + @Schema(description = "게시글 좋아요수", example = "100") + private Long likes; + + @Schema(description = "게시글 댓글수", example = "100") + private Long comments; + @Schema(description = "게시글 작성 시간", example = "2025-12-03T14:37:17") private LocalDateTime createdAt; @@ -41,6 +47,9 @@ public class PostDetailResponse { @Schema(description = "작성자 본인 여부", example = "true") private Boolean isAuthor; + @Schema(description = "내가 좋아요를 눌렀는지 여부", example = "true") + private Boolean isLiked; + @Schema(description = "이미지 URL 리스트") private List imageUrlList; diff --git a/src/main/java/com/sku/refit/domain/post/entity/PostLike.java b/src/main/java/com/sku/refit/domain/post/entity/PostLike.java new file mode 100644 index 0000000..1198cbc --- /dev/null +++ b/src/main/java/com/sku/refit/domain/post/entity/PostLike.java @@ -0,0 +1,34 @@ +/* + * Copyright (c) SKU 다시입을Lab + */ +package com.sku.refit.domain.post.entity; + +import jakarta.persistence.*; + +import com.sku.refit.domain.user.entity.User; +import com.sku.refit.global.common.BaseTimeEntity; + +import lombok.*; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +@Table( + name = "post_like", + uniqueConstraints = {@UniqueConstraint(columnNames = {"post_id", "user_id"})}) +public class PostLike extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "post_id", nullable = false) + private Post post; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; +} diff --git a/src/main/java/com/sku/refit/domain/post/mapper/PostMapper.java b/src/main/java/com/sku/refit/domain/post/mapper/PostMapper.java index 1f595f5..3e084dd 100644 --- a/src/main/java/com/sku/refit/domain/post/mapper/PostMapper.java +++ b/src/main/java/com/sku/refit/domain/post/mapper/PostMapper.java @@ -29,16 +29,20 @@ public Post toPost( .build(); } - public PostDetailResponse toDetailResponse(Post post, User user) { + public PostDetailResponse toDetailResponse( + Post post, Long likeCount, Boolean isLiked, User user) { return PostDetailResponse.builder() .postId(post.getId()) .title(post.getTitle()) .content(post.getContent()) .views(post.getViews()) + .likes(likeCount) + .comments((long) post.getCommentList().size()) .createdAt(post.getCreatedAt()) .nickname(post.getUser().getNickname()) .isAuthor(user != null && post.getUser().getUsername().equals(user.getUsername())) + .isLiked(isLiked) .category(post.getPostCategory()) .commentIdList(post.getCommentList().stream().map(Comment::getId).toList()) .build(); diff --git a/src/main/java/com/sku/refit/domain/post/repository/PostLikeRepository.java b/src/main/java/com/sku/refit/domain/post/repository/PostLikeRepository.java new file mode 100644 index 0000000..274b686 --- /dev/null +++ b/src/main/java/com/sku/refit/domain/post/repository/PostLikeRepository.java @@ -0,0 +1,40 @@ +/* + * Copyright (c) SKU 다시입을Lab + */ +package com.sku.refit.domain.post.repository; + +import java.util.List; +import java.util.Optional; + +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.post.entity.PostLike; + +public interface PostLikeRepository extends JpaRepository { + + Optional findByPostIdAndUserId(Long postId, Long userId); + + boolean existsByPostIdAndUserId(Long postId, Long userId); + + long countByPostId(Long postId); + + @Query( + """ + select pl.post.id, count(pl) + from PostLike pl + where pl.post.id in :postIds + group by pl.post.id +""") + List countByPostIds(@Param("postIds") List postIds); + + @Query( + """ + select pl.post.id + from PostLike pl + where pl.post.id in :postIds + and pl.user.id = :userId +""") + List findLikedPostIds(@Param("postIds") List postIds, @Param("userId") Long userId); +} diff --git a/src/main/java/com/sku/refit/domain/post/repository/PostRepository.java b/src/main/java/com/sku/refit/domain/post/repository/PostRepository.java index af0b2e6..1190285 100644 --- a/src/main/java/com/sku/refit/domain/post/repository/PostRepository.java +++ b/src/main/java/com/sku/refit/domain/post/repository/PostRepository.java @@ -17,4 +17,8 @@ public interface PostRepository extends JpaRepository { Page findByPostCategoryContainingAndIdLessThan( String category, Long lastPostId, Pageable pageable); + + Page findAllByUser_Id(Long userId, Pageable pageable); + + Page findAllByUser_IdAndIdLessThan(Long userId, Long lastPostId, Pageable pageable); } diff --git a/src/main/java/com/sku/refit/domain/post/service/PostService.java b/src/main/java/com/sku/refit/domain/post/service/PostService.java index 16cd69d..c1443c9 100644 --- a/src/main/java/com/sku/refit/domain/post/service/PostService.java +++ b/src/main/java/com/sku/refit/domain/post/service/PostService.java @@ -36,6 +36,16 @@ public interface PostService { */ PostDetailResponse createPost(PostRequest request, List images); + /** + * 게시글 좋아요를 토글합니다. + * + *

이미 좋아요가 되어 있으면 취소하고, 좋아요가 없으면 새로 생성합니다. + * + * @param postId 게시글 ID + * @return true: 좋아요 상태 / false: 좋아요 취소 상태 + */ + boolean togglePostLike(Long postId); + /** * 모든 게시글 목록을 조회합니다. * diff --git a/src/main/java/com/sku/refit/domain/post/service/PostServiceImpl.java b/src/main/java/com/sku/refit/domain/post/service/PostServiceImpl.java index f4a89c0..3f034db 100644 --- a/src/main/java/com/sku/refit/domain/post/service/PostServiceImpl.java +++ b/src/main/java/com/sku/refit/domain/post/service/PostServiceImpl.java @@ -4,7 +4,11 @@ package com.sku.refit.domain.post.service; import java.util.ArrayList; +import java.util.HashMap; +import java.util.HashSet; import java.util.List; +import java.util.Map; +import java.util.Set; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; @@ -17,8 +21,10 @@ import com.sku.refit.domain.post.dto.response.PostDetailResponse; import com.sku.refit.domain.post.entity.Post; import com.sku.refit.domain.post.entity.PostCategory; +import com.sku.refit.domain.post.entity.PostLike; import com.sku.refit.domain.post.exception.PostErrorCode; import com.sku.refit.domain.post.mapper.PostMapper; +import com.sku.refit.domain.post.repository.PostLikeRepository; import com.sku.refit.domain.post.repository.PostRepository; import com.sku.refit.domain.user.entity.User; import com.sku.refit.domain.user.service.UserService; @@ -37,6 +43,7 @@ public class PostServiceImpl implements PostService { private final PostRepository postRepository; + private final PostLikeRepository postLikeRepository; private final S3Service s3Service; private final UserService userService; private final PostMapper postMapper; @@ -72,7 +79,35 @@ public PostDetailResponse createPost(PostRequest request, List im user.getId(), imageUrlList.size()); - return postMapper.toDetailResponse(post, user); + return postMapper.toDetailResponse(post, 0L, false, user); + } + + @Override + @Transactional + public boolean togglePostLike(Long postId) { + + User user = userService.getCurrentUser(); + Post post = + postRepository + .findById(postId) + .orElseThrow(() -> new CustomException(PostErrorCode.POST_NOT_FOUND)); + + return postLikeRepository + .findByPostIdAndUserId(postId, user.getId()) + .map( + like -> { + postLikeRepository.delete(like); + log.info("[POST LIKE CANCEL] postId={}, userId={}", postId, user.getId()); + return false; + }) + .orElseGet( + () -> { + PostLike postLike = PostLike.builder().post(post).user(user).build(); + + postLikeRepository.save(postLike); + log.info("[POST LIKE CREATE] postId={}, userId={}", postId, user.getId()); + return true; + }); } @Override @@ -82,9 +117,32 @@ public List getAllPosts() { User user = userService.getCurrentUser(); List posts = postRepository.findAll(); + if (posts.isEmpty()) { + return List.of(); + } + + List postIds = posts.stream().map(Post::getId).toList(); + + Map likeCountMap = new HashMap<>(); + List likeCounts = postLikeRepository.countByPostIds(postIds); + for (Object[] row : likeCounts) { + likeCountMap.put((Long) row[0], (Long) row[1]); + } + + Set likedPostIds = + new HashSet<>(postLikeRepository.findLikedPostIds(postIds, user.getId())); + log.info("[POST LIST] userId={}, postCount={}", user.getId(), posts.size()); - return posts.stream().map(post -> postMapper.toDetailResponse(post, user)).toList(); + return posts.stream() + .map( + post -> + postMapper.toDetailResponse( + post, + likeCountMap.getOrDefault(post.getId(), 0L), + likedPostIds.contains(post.getId()), + user)) + .toList(); } @Override @@ -111,8 +169,33 @@ public InfiniteResponse getPostsByCategory( posts = posts.subList(0, size); } + List postIds = posts.stream().map(Post::getId).toList(); + + Map likeCountMap = new HashMap<>(); + if (!postIds.isEmpty()) { + List likeCounts = postLikeRepository.countByPostIds(postIds); + for (Object[] row : likeCounts) { + Long postId = (Long) row[0]; + Long count = (Long) row[1]; + likeCountMap.put(postId, count); + } + } + + Set likedPostIds = + postIds.isEmpty() + ? Set.of() + : new HashSet<>(postLikeRepository.findLikedPostIds(postIds, user.getId())); + List postResponseList = - posts.stream().map(post -> postMapper.toDetailResponse(post, user)).toList(); + posts.stream() + .map( + post -> + postMapper.toDetailResponse( + post, + likeCountMap.getOrDefault(post.getId(), 0L), + likedPostIds.contains(post.getId()), + user)) + .toList(); Long newLastCursor = posts.isEmpty() ? null : posts.getLast().getId(); @@ -142,7 +225,10 @@ public PostDetailResponse getPostById(Long id) { user.getId(), post.getViews()); - return postMapper.toDetailResponse(post, user); + long likeCount = postLikeRepository.countByPostId(post.getId()); + boolean isLiked = postLikeRepository.existsByPostIdAndUserId(post.getId(), user.getId()); + + return postMapper.toDetailResponse(post, likeCount, isLiked, user); } @Override @@ -190,9 +276,12 @@ public PostDetailResponse updatePost(Long id, PostRequest request, List> consumeTicket( @RequestBody @Valid ConsumeTicketRequest request); @GetMapping("/my/events") - @Operation(summary = "참가한 행사 조회", description = "사용자가 사용한 EVENT 티켓만 조회합니다.") + @Operation(summary = "[개발자] 참가한 행사 티켓 조회", description = "사용자가 사용한 EVENT 티켓만 조회합니다.") ResponseEntity>> getMyUsedEventTickets(); @GetMapping("/my/cloth") - @Operation(summary = "교환 내역 조회", description = "사용자가 받았던 CLOTH 티켓을 모두 조회합니다.") + @Operation(summary = "[개발자] 교환 내역 티켓 조회", description = "사용자가 받았던 CLOTH 티켓을 모두 조회합니다.") ResponseEntity>> getMyClothTickets(); } diff --git a/src/main/java/com/sku/refit/domain/ticket/controller/TicketControllerImpl.java b/src/main/java/com/sku/refit/domain/ticket/controller/TicketControllerImpl.java index 200d13c..ade8033 100644 --- a/src/main/java/com/sku/refit/domain/ticket/controller/TicketControllerImpl.java +++ b/src/main/java/com/sku/refit/domain/ticket/controller/TicketControllerImpl.java @@ -30,7 +30,10 @@ public ResponseEntity> issueTicket( return ResponseEntity.ok( BaseResponse.success( ticketService.issueTicket( - request.getType(), request.getTargetId(), request.getUserId()))); + request.getType(), + request.getTargetId(), + request.getUserId(), + request.getExpiresAt()))); } @Override diff --git a/src/main/java/com/sku/refit/domain/ticket/dto/request/TicketRequest.java b/src/main/java/com/sku/refit/domain/ticket/dto/request/TicketRequest.java index 90c5873..097bcbc 100644 --- a/src/main/java/com/sku/refit/domain/ticket/dto/request/TicketRequest.java +++ b/src/main/java/com/sku/refit/domain/ticket/dto/request/TicketRequest.java @@ -3,6 +3,8 @@ */ package com.sku.refit.domain.ticket.dto.request; +import java.time.LocalDate; + import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; @@ -20,6 +22,7 @@ public static class IssueTicketRequest { @NotNull private Long targetId; private Long userId; + private LocalDate expiresAt; } @Getter diff --git a/src/main/java/com/sku/refit/domain/ticket/dto/response/TicketResponse.java b/src/main/java/com/sku/refit/domain/ticket/dto/response/TicketResponse.java index bb5bb6c..c162814 100644 --- a/src/main/java/com/sku/refit/domain/ticket/dto/response/TicketResponse.java +++ b/src/main/java/com/sku/refit/domain/ticket/dto/response/TicketResponse.java @@ -3,6 +3,7 @@ */ package com.sku.refit.domain.ticket.dto.response; +import java.time.LocalDate; import java.time.LocalDateTime; import com.sku.refit.domain.ticket.entity.TicketType; @@ -23,6 +24,7 @@ public static class TicketDetailResponse { private LocalDateTime issuedAt; private LocalDateTime usedAt; + private LocalDate expiresAt; } @Getter @@ -39,6 +41,7 @@ public static class VerifyTicketResponse { private LocalDateTime issuedAt; private LocalDateTime usedAt; + private LocalDate expiresAt; } @Getter @@ -53,9 +56,9 @@ public static class ConsumeTicketResponse { private String qrPayload; private LocalDateTime usedAt; + private LocalDate expiresAt; } - /** 사용자 조회용: - EVENT: "사용한 행사"만 조회(usedAt != null) - CLOTH: "받은 티켓" 전체 조회(usedAt 상관 없음) */ @Getter @Builder public static class MyTicketItemResponse { @@ -68,5 +71,6 @@ public static class MyTicketItemResponse { private LocalDateTime issuedAt; private LocalDateTime usedAt; + private LocalDate expiresAt; } } diff --git a/src/main/java/com/sku/refit/domain/ticket/entity/Ticket.java b/src/main/java/com/sku/refit/domain/ticket/entity/Ticket.java index 8a1382e..b54deee 100644 --- a/src/main/java/com/sku/refit/domain/ticket/entity/Ticket.java +++ b/src/main/java/com/sku/refit/domain/ticket/entity/Ticket.java @@ -3,6 +3,7 @@ */ package com.sku.refit.domain.ticket.entity; +import java.time.LocalDate; import java.time.LocalDateTime; import jakarta.persistence.*; @@ -22,7 +23,8 @@ @Index(name = "idx_ticket_user", columnList = "user_id"), @Index(name = "idx_ticket_type_target", columnList = "type,target_id"), @Index(name = "idx_ticket_token", columnList = "token"), - @Index(name = "idx_ticket_used_at", columnList = "used_at") + @Index(name = "idx_ticket_used_at", columnList = "used_at"), + @Index(name = "idx_ticket_expires_at", columnList = "expires_at") }) public class Ticket extends BaseTimeEntity { @@ -52,6 +54,10 @@ public class Ticket extends BaseTimeEntity { @Column(name = "used_at") private LocalDateTime usedAt; + /** 만료일 (null = 만료 X) */ + @Column(name = "expires_at") + private LocalDate expiresAt; + /* ========================= * Domain Logic * ========================= */ @@ -61,27 +67,21 @@ public boolean isUsed() { return usedAt != null; } - /** 티켓 사용 처리 (멱등) */ + /** 만료 여부 (미사용 + expiresAt 존재 + 오늘 날짜 초과) */ + public boolean isExpired(LocalDate today) { + return usedAt == null && expiresAt != null && today.isAfter(expiresAt); + } + public void consume(LocalDateTime now) { if (this.usedAt != null) { return; } - this.usedAt = now; - } - /** 기본 도메인 유효성 */ - public void validate() { - if (type == null) { - throw new IllegalStateException("Ticket type은 필수입니다."); - } - if (targetId == null) { - throw new IllegalStateException("Ticket targetId는 필수입니다."); - } - if (userId == null) { - throw new IllegalStateException("Ticket userId는 필수입니다."); - } - if (token == null || token.isBlank()) { - throw new IllegalStateException("Ticket token은 필수입니다."); + // 날짜 기준 만료 체크 + if (isExpired(now.toLocalDate())) { + throw new IllegalStateException("만료된 티켓은 사용할 수 없습니다."); } + + this.usedAt = now; } } diff --git a/src/main/java/com/sku/refit/domain/ticket/exception/TicketErrorCode.java b/src/main/java/com/sku/refit/domain/ticket/exception/TicketErrorCode.java index bbb12c4..ae3e8f3 100644 --- a/src/main/java/com/sku/refit/domain/ticket/exception/TicketErrorCode.java +++ b/src/main/java/com/sku/refit/domain/ticket/exception/TicketErrorCode.java @@ -18,6 +18,7 @@ public enum TicketErrorCode implements BaseErrorCode { TICKET_NOT_FOUND("TICKET0010", "티켓을 찾을 수 없습니다.", HttpStatus.NOT_FOUND), TICKET_ALREADY_USED("TICKET020", "이미 사용된 티켓입니다.", HttpStatus.CONFLICT), + TICKET_EXPIRED("TICKET021", "만료된 티켓입니다.", HttpStatus.GONE), TICKET_TOKEN_GENERATION_FAILED( "TICKET030", "티켓 토큰 생성에 실패했습니다.", HttpStatus.INTERNAL_SERVER_ERROR), diff --git a/src/main/java/com/sku/refit/domain/ticket/mapper/TicketMapper.java b/src/main/java/com/sku/refit/domain/ticket/mapper/TicketMapper.java index ca931c8..91fe147 100644 --- a/src/main/java/com/sku/refit/domain/ticket/mapper/TicketMapper.java +++ b/src/main/java/com/sku/refit/domain/ticket/mapper/TicketMapper.java @@ -3,6 +3,8 @@ */ package com.sku.refit.domain.ticket.mapper; +import java.time.LocalDate; + import org.springframework.stereotype.Component; import com.sku.refit.domain.ticket.dto.response.TicketResponse.*; @@ -18,9 +20,22 @@ public class TicketMapper { private final TicketQrPayloadFactory qrPayloadFactory; - /** 발급 요청 + userId + token -> Ticket 엔티티 */ + /** 발급 요청 + userId + token -> Ticket 엔티티 (하위호환) */ public Ticket toEntity(TicketType type, Long targetId, Long userId, String token) { - return Ticket.builder().type(type).targetId(targetId).userId(userId).token(token).build(); + return toEntity(type, targetId, userId, token, null); + } + + /** 발급 요청 + userId + token + expiresAt -> Ticket 엔티티 */ + public Ticket toEntity( + TicketType type, Long targetId, Long userId, String token, LocalDate expiresAt) { + + return Ticket.builder() + .type(type) + .targetId(targetId) + .userId(userId) + .token(token) + .expiresAt(expiresAt) + .build(); } public TicketDetailResponse toDetail(Ticket ticket) { @@ -32,6 +47,7 @@ public TicketDetailResponse toDetail(Ticket ticket) { .qrPayload(qrPayloadFactory.create(ticket.getToken())) .issuedAt(ticket.getCreatedAt()) .usedAt(ticket.getUsedAt()) + .expiresAt(ticket.getExpiresAt()) .build(); } @@ -45,6 +61,22 @@ public MyTicketItemResponse toMyItem(Ticket ticket) { .qrPayload(qrPayloadFactory.create(ticket.getToken())) .issuedAt(ticket.getCreatedAt()) .usedAt(ticket.getUsedAt()) + .expiresAt(ticket.getExpiresAt()) + .build(); + } + + /** 검증 응답: 만료된 티켓 */ + public VerifyTicketResponse toVerifyExpired(Ticket ticket) { + return VerifyTicketResponse.builder() + .valid(false) // 만료 → 유효하지 않음 + .used(false) // 사용 불가 상태 + .ticketId(ticket.getId()) + .type(ticket.getType()) + .targetId(ticket.getTargetId()) + .qrPayload(qrPayloadFactory.create(ticket.getToken())) + .issuedAt(ticket.getCreatedAt()) + .usedAt(ticket.getUsedAt()) + .expiresAt(ticket.getExpiresAt()) .build(); } @@ -59,6 +91,7 @@ public VerifyTicketResponse toVerifyFound(Ticket ticket) { .qrPayload(qrPayloadFactory.create(ticket.getToken())) .issuedAt(ticket.getCreatedAt()) .usedAt(ticket.getUsedAt()) + .expiresAt(ticket.getExpiresAt()) .build(); } @@ -76,6 +109,7 @@ public ConsumeTicketResponse toConsume(Ticket ticket, boolean consumed) { .targetId(ticket.getTargetId()) .qrPayload(qrPayloadFactory.create(ticket.getToken())) .usedAt(ticket.getUsedAt()) + .expiresAt(ticket.getExpiresAt()) .build(); } } 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 b6ceb64..11cad19 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 @@ -8,6 +8,8 @@ import jakarta.persistence.LockModeType; +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.Lock; import org.springframework.data.jpa.repository.Query; @@ -28,4 +30,6 @@ List findAllByUserIdAndTypeAndUsedAtIsNotNullOrderByUsedAtDesc( @Lock(LockModeType.PESSIMISTIC_WRITE) @Query("SELECT t FROM Ticket t WHERE t.token = :token") Optional findByTokenForUpdate(@Param("token") String token); + + Page findAllByUserId(Long userId, Pageable pageable); } diff --git a/src/main/java/com/sku/refit/domain/ticket/service/TicketService.java b/src/main/java/com/sku/refit/domain/ticket/service/TicketService.java index 13040d1..aba5078 100644 --- a/src/main/java/com/sku/refit/domain/ticket/service/TicketService.java +++ b/src/main/java/com/sku/refit/domain/ticket/service/TicketService.java @@ -3,6 +3,7 @@ */ package com.sku.refit.domain.ticket.service; +import java.time.LocalDate; import java.util.List; import com.sku.refit.domain.ticket.dto.request.TicketRequest.*; @@ -39,9 +40,11 @@ public interface TicketService { * @param type 티켓 타입 * @param targetId 대상 ID (EVENT/CLOTH의 식별자) * @param userId (옵션) 관리자 발급 시 대상 사용자 ID, null이면 현재 사용자 + * @param expiresAt (옵션) 티켓 만료일 * @return 발급된 티켓 상세 정보 (type, targetId, token, 발급 시각 등) */ - TicketDetailResponse issueTicket(TicketType type, Long targetId, Long userId); + TicketDetailResponse issueTicket( + TicketType type, Long targetId, Long userId, LocalDate expiresAt); /* ========================= * Verify diff --git a/src/main/java/com/sku/refit/domain/ticket/service/TicketServiceImpl.java b/src/main/java/com/sku/refit/domain/ticket/service/TicketServiceImpl.java index 7cfefdb..448534b 100644 --- a/src/main/java/com/sku/refit/domain/ticket/service/TicketServiceImpl.java +++ b/src/main/java/com/sku/refit/domain/ticket/service/TicketServiceImpl.java @@ -3,6 +3,7 @@ */ package com.sku.refit.domain.ticket.service; +import java.time.LocalDate; import java.time.LocalDateTime; import java.util.List; @@ -41,7 +42,8 @@ public class TicketServiceImpl implements TicketService { @Override @Transactional - public TicketDetailResponse issueTicket(TicketType type, Long targetId, Long userId) { + public TicketDetailResponse issueTicket( + TicketType type, Long targetId, Long userId, LocalDate expiresAt) { if (type == null || targetId == null) { throw new CustomException(TicketErrorCode.TICKET_BAD_REQUEST); @@ -63,7 +65,7 @@ public TicketDetailResponse issueTicket(TicketType type, Long targetId, Long use } try { - Ticket ticket = ticketMapper.toEntity(type, targetId, issueUserId, token); + Ticket ticket = ticketMapper.toEntity(type, targetId, issueUserId, token, expiresAt); Ticket saved = ticketRepository.save(ticket); return ticketMapper.toDetail(saved); @@ -88,11 +90,18 @@ public TicketDetailResponse issueTicket(TicketType type, Long targetId, Long use public VerifyTicketResponse verifyTicket(VerifyTicketRequest request) { validateToken(request.getToken()); + LocalDate today = LocalDate.now(); try { return ticketRepository .findByTokenForUpdate(request.getToken()) - .map(ticketMapper::toVerifyFound) + .map( + ticket -> { + if (ticket.isExpired(today)) { + return ticketMapper.toVerifyExpired(ticket); + } + return ticketMapper.toVerifyFound(ticket); + }) .orElseGet(ticketMapper::toVerifyNotFound); } catch (CustomException e) { @@ -188,12 +197,6 @@ public List getMyClothTickets() { * Private * ========================= */ - private void validateIssueRequest(IssueTicketRequest request) { - if (request == null || request.getType() == null || request.getTargetId() == null) { - throw new CustomException(TicketErrorCode.TICKET_BAD_REQUEST); - } - } - private void validateToken(String token) { if (token == null || token.isBlank()) { throw new CustomException(TicketErrorCode.TICKET_TOKEN_REQUIRED); diff --git a/src/main/java/com/sku/refit/domain/user/entity/User.java b/src/main/java/com/sku/refit/domain/user/entity/User.java index 07b59d7..294aeca 100644 --- a/src/main/java/com/sku/refit/domain/user/entity/User.java +++ b/src/main/java/com/sku/refit/domain/user/entity/User.java @@ -56,6 +56,21 @@ public class User extends BaseTimeEntity { @Builder.Default private Role role = Role.ROLE_USER; + @Column(name = "exchange_count", nullable = false) + @Builder.Default + private Integer exchangeCount = 0; + + @Column(name = "total_reduced_carbon_g", nullable = false) + @Builder.Default + private Long totalReducedCarbonG = 0L; + + public void addExchangeCarbon(long deltaG) { + if (deltaG <= 0) return; + this.exchangeCount = (this.exchangeCount == null ? 0 : this.exchangeCount) + 1; + this.totalReducedCarbonG = + (this.totalReducedCarbonG == null ? 0L : this.totalReducedCarbonG) + deltaG; + } + public static User fromOAuth(String email, String nickname, String profileImageUrl) { return User.builder() .username(email)