From c60a764c58e3c5643f7c6019b7adab29fda5ebf6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=82=98=EA=B2=BD?= <1030n@naver.com> Date: Sun, 14 Dec 2025 00:41:39 +0900 Subject: [PATCH 01/10] =?UTF-8?q?:recycle:=20Refactor:=20Ticket=20api=20?= =?UTF-8?q?=EA=B0=9C=EB=B0=9C=EC=9E=90=EC=9A=A9=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sku/refit/domain/ticket/controller/TicketController.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/com/sku/refit/domain/ticket/controller/TicketController.java b/src/main/java/com/sku/refit/domain/ticket/controller/TicketController.java index e2991d3..1342e18 100644 --- a/src/main/java/com/sku/refit/domain/ticket/controller/TicketController.java +++ b/src/main/java/com/sku/refit/domain/ticket/controller/TicketController.java @@ -37,10 +37,10 @@ ResponseEntity> 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(); } From 3b45e9adf4e39ed35f94b8cc39dc3da63048b4b3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=82=98=EA=B2=BD?= <1030n@naver.com> Date: Sun, 14 Dec 2025 01:02:13 +0900 Subject: [PATCH 02/10] =?UTF-8?q?:sparkles:=20Feat:=20=ED=8B=B0=EC=BC=93?= =?UTF-8?q?=20expiresAt=20+=20=EA=B2=80=EC=A6=9D=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/TicketControllerImpl.java | 2 +- .../ticket/dto/request/TicketRequest.java | 2 + .../ticket/dto/response/TicketResponse.java | 11 +++-- .../refit/domain/ticket/entity/Ticket.java | 49 ++++++++++++------- .../ticket/exception/TicketErrorCode.java | 1 + .../domain/ticket/mapper/TicketMapper.java | 44 +++++++++++++++-- .../domain/ticket/service/TicketService.java | 4 +- .../ticket/service/TicketServiceImpl.java | 17 ++++--- 8 files changed, 92 insertions(+), 38 deletions(-) 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..5e22f2a 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,7 @@ 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..739b58a 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 @@ -8,6 +8,7 @@ import com.sku.refit.domain.ticket.entity.TicketType; +import java.time.LocalDateTime; import lombok.*; public class TicketRequest { @@ -20,6 +21,7 @@ public static class IssueTicketRequest { @NotNull private Long targetId; private Long userId; + private LocalDateTime 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..9f58bbf 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 @@ -1,5 +1,5 @@ -/* - * Copyright (c) SKU 다시입을Lab +/* + * Copyright (c) SKU 다시입을Lab */ package com.sku.refit.domain.ticket.dto.response; @@ -23,6 +23,7 @@ public static class TicketDetailResponse { private LocalDateTime issuedAt; private LocalDateTime usedAt; + private LocalDateTime expiresAt; } @Getter @@ -39,6 +40,7 @@ public static class VerifyTicketResponse { private LocalDateTime issuedAt; private LocalDateTime usedAt; + private LocalDateTime expiresAt; } @Getter @@ -53,9 +55,9 @@ public static class ConsumeTicketResponse { private String qrPayload; private LocalDateTime usedAt; + private LocalDateTime expiresAt; } - /** 사용자 조회용: - EVENT: "사용한 행사"만 조회(usedAt != null) - CLOTH: "받은 티켓" 전체 조회(usedAt 상관 없음) */ @Getter @Builder public static class MyTicketItemResponse { @@ -68,5 +70,6 @@ public static class MyTicketItemResponse { private LocalDateTime issuedAt; private LocalDateTime usedAt; + private LocalDateTime expiresAt; } -} +} \ No newline at end of file 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..1ee406f 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 @@ -1,5 +1,5 @@ -/* - * Copyright (c) SKU 다시입을Lab +/* + * Copyright (c) SKU 다시입을Lab */ package com.sku.refit.domain.ticket.entity; @@ -19,10 +19,11 @@ @Table( name = "ticket", indexes = { - @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_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_expires_at", columnList = "expires_at") }) public class Ticket extends BaseTimeEntity { @@ -52,6 +53,10 @@ public class Ticket extends BaseTimeEntity { @Column(name = "used_at") private LocalDateTime usedAt; + /** 만료 시각 (null = 만료 X) */ + @Column(name = "expires_at") + private LocalDateTime expiresAt; + /* ========================= * Domain Logic * ========================= */ @@ -61,27 +66,33 @@ public boolean isUsed() { return usedAt != null; } + /** 만료 여부 (미사용 + expiresAt 존재 + 현재 이후) */ + public boolean isExpired(LocalDateTime now) { + return usedAt == null && expiresAt != null && now.isAfter(expiresAt); + } + /** 티켓 사용 처리 (멱등) */ public void consume(LocalDateTime now) { if (this.usedAt != null) { return; } + // 만료된 티켓은 사용 처리 불가 + if (isExpired(now)) { + throw new IllegalStateException("만료된 티켓은 사용할 수 없습니다."); + } 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 (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은 필수입니다."); + } + + /** 만료시간 설정(발급 시) */ + public void updateExpiresAt(LocalDateTime expiresAt) { + this.expiresAt = expiresAt; } -} +} \ No newline at end of file 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..b0ae64b 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 @@ -1,8 +1,10 @@ -/* - * Copyright (c) SKU 다시입을Lab +/* + * Copyright (c) SKU 다시입을Lab */ package com.sku.refit.domain.ticket.mapper; +import java.time.LocalDateTime; + 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, LocalDateTime 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(); } -} +} \ No newline at end of file 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..3d569fb 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.LocalDateTime; import java.util.List; import com.sku.refit.domain.ticket.dto.request.TicketRequest.*; @@ -39,9 +40,10 @@ 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, LocalDateTime 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..dc540f0 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 @@ -41,7 +41,7 @@ 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, LocalDateTime expiresAt) { if (type == null || targetId == null) { throw new CustomException(TicketErrorCode.TICKET_BAD_REQUEST); @@ -63,7 +63,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 +88,17 @@ public TicketDetailResponse issueTicket(TicketType type, Long targetId, Long use public VerifyTicketResponse verifyTicket(VerifyTicketRequest request) { validateToken(request.getToken()); + LocalDateTime now = LocalDateTime.now(); try { return ticketRepository .findByTokenForUpdate(request.getToken()) - .map(ticketMapper::toVerifyFound) + .map(ticket -> { + if (ticket.isExpired(now)) { + return ticketMapper.toVerifyExpired(ticket); + } + return ticketMapper.toVerifyFound(ticket); + }) .orElseGet(ticketMapper::toVerifyNotFound); } catch (CustomException e) { @@ -188,11 +194,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()) { From 4ff013e28d0518f0200230ca9729f542b522f928 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=82=98=EA=B2=BD?= <1030n@naver.com> Date: Sun, 14 Dec 2025 01:14:01 +0900 Subject: [PATCH 03/10] =?UTF-8?q?:recycle:=20Refactor:=20expiresAt=20DateT?= =?UTF-8?q?ime=20=E2=86=92=20Date=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../controller/TicketControllerImpl.java | 5 +- .../ticket/dto/request/TicketRequest.java | 5 +- .../ticket/dto/response/TicketResponse.java | 15 +++--- .../refit/domain/ticket/entity/Ticket.java | 47 +++++++------------ .../domain/ticket/mapper/TicketMapper.java | 14 +++--- .../domain/ticket/service/TicketService.java | 5 +- .../ticket/service/TicketServiceImpl.java | 20 ++++---- 7 files changed, 54 insertions(+), 57 deletions(-) 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 5e22f2a..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.getExpiresAt())); + 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 739b58a..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,12 +3,13 @@ */ package com.sku.refit.domain.ticket.dto.request; +import java.time.LocalDate; + import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import com.sku.refit.domain.ticket.entity.TicketType; -import java.time.LocalDateTime; import lombok.*; public class TicketRequest { @@ -21,7 +22,7 @@ public static class IssueTicketRequest { @NotNull private Long targetId; private Long userId; - private LocalDateTime expiresAt; + 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 9f58bbf..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 @@ -1,8 +1,9 @@ -/* - * Copyright (c) SKU 다시입을Lab +/* + * Copyright (c) SKU 다시입을Lab */ 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,7 +24,7 @@ public static class TicketDetailResponse { private LocalDateTime issuedAt; private LocalDateTime usedAt; - private LocalDateTime expiresAt; + private LocalDate expiresAt; } @Getter @@ -40,7 +41,7 @@ public static class VerifyTicketResponse { private LocalDateTime issuedAt; private LocalDateTime usedAt; - private LocalDateTime expiresAt; + private LocalDate expiresAt; } @Getter @@ -55,7 +56,7 @@ public static class ConsumeTicketResponse { private String qrPayload; private LocalDateTime usedAt; - private LocalDateTime expiresAt; + private LocalDate expiresAt; } @Getter @@ -70,6 +71,6 @@ public static class MyTicketItemResponse { private LocalDateTime issuedAt; private LocalDateTime usedAt; - private LocalDateTime expiresAt; + private LocalDate expiresAt; } -} \ No newline at end of file +} 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 1ee406f..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 @@ -1,8 +1,9 @@ -/* - * Copyright (c) SKU 다시입을Lab +/* + * Copyright (c) SKU 다시입을Lab */ package com.sku.refit.domain.ticket.entity; +import java.time.LocalDate; import java.time.LocalDateTime; import jakarta.persistence.*; @@ -19,11 +20,11 @@ @Table( name = "ticket", indexes = { - @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_expires_at", columnList = "expires_at") + @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_expires_at", columnList = "expires_at") }) public class Ticket extends BaseTimeEntity { @@ -53,9 +54,9 @@ public class Ticket extends BaseTimeEntity { @Column(name = "used_at") private LocalDateTime usedAt; - /** 만료 시각 (null = 만료 X) */ + /** 만료일 (null = 만료 X) */ @Column(name = "expires_at") - private LocalDateTime expiresAt; + private LocalDate expiresAt; /* ========================= * Domain Logic @@ -66,33 +67,21 @@ public boolean isUsed() { return usedAt != null; } - /** 만료 여부 (미사용 + expiresAt 존재 + 현재 이후) */ - public boolean isExpired(LocalDateTime now) { - return usedAt == null && expiresAt != null && now.isAfter(expiresAt); + /** 만료 여부 (미사용 + expiresAt 존재 + 오늘 날짜 초과) */ + public boolean isExpired(LocalDate today) { + return usedAt == null && expiresAt != null && today.isAfter(expiresAt); } - /** 티켓 사용 처리 (멱등) */ public void consume(LocalDateTime now) { if (this.usedAt != null) { return; } - // 만료된 티켓은 사용 처리 불가 - if (isExpired(now)) { + + // 날짜 기준 만료 체크 + if (isExpired(now.toLocalDate())) { throw new IllegalStateException("만료된 티켓은 사용할 수 없습니다."); } - 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은 필수입니다."); - } - - /** 만료시간 설정(발급 시) */ - public void updateExpiresAt(LocalDateTime expiresAt) { - this.expiresAt = expiresAt; + this.usedAt = now; } -} \ No newline at end of file +} 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 b0ae64b..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 @@ -1,9 +1,9 @@ -/* - * Copyright (c) SKU 다시입을Lab +/* + * Copyright (c) SKU 다시입을Lab */ package com.sku.refit.domain.ticket.mapper; -import java.time.LocalDateTime; +import java.time.LocalDate; import org.springframework.stereotype.Component; @@ -27,7 +27,7 @@ public Ticket toEntity(TicketType type, Long targetId, Long userId, String token /** 발급 요청 + userId + token + expiresAt -> Ticket 엔티티 */ public Ticket toEntity( - TicketType type, Long targetId, Long userId, String token, LocalDateTime expiresAt) { + TicketType type, Long targetId, Long userId, String token, LocalDate expiresAt) { return Ticket.builder() .type(type) @@ -68,8 +68,8 @@ public MyTicketItemResponse toMyItem(Ticket ticket) { /** 검증 응답: 만료된 티켓 */ public VerifyTicketResponse toVerifyExpired(Ticket ticket) { return VerifyTicketResponse.builder() - .valid(false) // 만료 → 유효하지 않음 - .used(false) // 사용 불가 상태 + .valid(false) // 만료 → 유효하지 않음 + .used(false) // 사용 불가 상태 .ticketId(ticket.getId()) .type(ticket.getType()) .targetId(ticket.getTargetId()) @@ -112,4 +112,4 @@ public ConsumeTicketResponse toConsume(Ticket ticket, boolean consumed) { .expiresAt(ticket.getExpiresAt()) .build(); } -} \ No newline at end of file +} 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 3d569fb..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,7 +3,7 @@ */ package com.sku.refit.domain.ticket.service; -import java.time.LocalDateTime; +import java.time.LocalDate; import java.util.List; import com.sku.refit.domain.ticket.dto.request.TicketRequest.*; @@ -43,7 +43,8 @@ public interface TicketService { * @param expiresAt (옵션) 티켓 만료일 * @return 발급된 티켓 상세 정보 (type, targetId, token, 발급 시각 등) */ - TicketDetailResponse issueTicket(TicketType type, Long targetId, Long userId, LocalDateTime expiresAt); + 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 dc540f0..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, LocalDateTime expiresAt) { + public TicketDetailResponse issueTicket( + TicketType type, Long targetId, Long userId, LocalDate expiresAt) { if (type == null || targetId == null) { throw new CustomException(TicketErrorCode.TICKET_BAD_REQUEST); @@ -88,17 +90,18 @@ public TicketDetailResponse issueTicket(TicketType type, Long targetId, Long use public VerifyTicketResponse verifyTicket(VerifyTicketRequest request) { validateToken(request.getToken()); - LocalDateTime now = LocalDateTime.now(); + LocalDate today = LocalDate.now(); try { return ticketRepository .findByTokenForUpdate(request.getToken()) - .map(ticket -> { - if (ticket.isExpired(now)) { - return ticketMapper.toVerifyExpired(ticket); - } - return ticketMapper.toVerifyFound(ticket); - }) + .map( + ticket -> { + if (ticket.isExpired(today)) { + return ticketMapper.toVerifyExpired(ticket); + } + return ticketMapper.toVerifyFound(ticket); + }) .orElseGet(ticketMapper::toVerifyNotFound); } catch (CustomException e) { @@ -194,7 +197,6 @@ public List getMyClothTickets() { * Private * ========================= */ - private void validateToken(String token) { if (token == null || token.isBlank()) { throw new CustomException(TicketErrorCode.TICKET_TOKEN_REQUIRED); From 3a91713eaade1d499bf6ba6a6a5ed37edb6520e4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=82=98=EA=B2=BD?= <1030n@naver.com> Date: Sun, 14 Dec 2025 02:08:17 +0900 Subject: [PATCH 04/10] =?UTF-8?q?:sparkles:=20Feat:=20=EB=82=B4=20?= =?UTF-8?q?=ED=8B=B0=EC=BC=93=20=EC=A1=B0=ED=9A=8C(QR=20=ED=8F=AC=ED=95=A8?= =?UTF-8?q?)=20&=20=EC=B0=B8=EA=B0=80=ED=95=9C=20=ED=96=89=EC=82=AC=20API?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mypage/constant/TicketUseStatus.java | 10 ++ .../mypage/controller/MyPageController.java | 31 +++++ .../controller/MyPageControllerImpl.java | 32 +++++ .../mypage/dto/response/MyPageResponse.java | 104 +++++++++++++++ .../mypage/exception/MyPageErrorCode.java | 22 ++++ .../domain/mypage/mapper/MyPageMapper.java | 118 ++++++++++++++++++ .../domain/mypage/service/MyPageService.java | 47 +++++++ .../mypage/service/MyPageServiceImpl.java | 95 ++++++++++++++ .../ticket/repository/TicketRepository.java | 15 +++ 9 files changed, 474 insertions(+) create mode 100644 src/main/java/com/sku/refit/domain/mypage/constant/TicketUseStatus.java create mode 100644 src/main/java/com/sku/refit/domain/mypage/controller/MyPageController.java create mode 100644 src/main/java/com/sku/refit/domain/mypage/controller/MyPageControllerImpl.java create mode 100644 src/main/java/com/sku/refit/domain/mypage/dto/response/MyPageResponse.java create mode 100644 src/main/java/com/sku/refit/domain/mypage/exception/MyPageErrorCode.java create mode 100644 src/main/java/com/sku/refit/domain/mypage/mapper/MyPageMapper.java create mode 100644 src/main/java/com/sku/refit/domain/mypage/service/MyPageService.java create mode 100644 src/main/java/com/sku/refit/domain/mypage/service/MyPageServiceImpl.java 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..372671d --- /dev/null +++ b/src/main/java/com/sku/refit/domain/mypage/controller/MyPageController.java @@ -0,0 +1,31 @@ +/* + * 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.global.response.BaseResponse; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; + +@Tag(name = "마이페이지", description = "마이페이지 관련 API") +@RequestMapping("/api/my") +public interface MyPageController { + + @GetMapping("/tickets") + @Operation( + summary = "내 티켓 리스트 조회", + description = "사용여부(사용전/사용완료/사용만료), 티켓명, 위치, 유효기간, 설명, url(QR payload)을 반환합니다.") + ResponseEntity> getMyTickets( + @RequestParam int page, @RequestParam int size); + + @GetMapping("/events/joined") + @Operation( + summary = "참여한 행사 조회", + description = "Ticket usedAt != null 인 EVENT 티켓을 기준으로 최신순 참여한 행사 목록을 반환합니다.") + ResponseEntity> getJoinedEvents(); +} 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..2d55ca1 --- /dev/null +++ b/src/main/java/com/sku/refit/domain/mypage/controller/MyPageControllerImpl.java @@ -0,0 +1,32 @@ +/* + * 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.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())); + } +} 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..b6b23d6 --- /dev/null +++ b/src/main/java/com/sku/refit/domain/mypage/dto/response/MyPageResponse.java @@ -0,0 +1,104 @@ +/* + * 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 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; + } +} 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..d6287a5 --- /dev/null +++ b/src/main/java/com/sku/refit/domain/mypage/exception/MyPageErrorCode.java @@ -0,0 +1,22 @@ +/* + * 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); + + 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..a4f2a22 --- /dev/null +++ b/src/main/java/com/sku/refit/domain/mypage/mapper/MyPageMapper.java @@ -0,0 +1,118 @@ +/* + * Copyright (c) SKU 다시입을Lab + */ +package com.sku.refit.domain.mypage.mapper; + +import java.time.LocalDate; +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.ticket.entity.Ticket; +import com.sku.refit.domain.ticket.entity.TicketType; +import com.sku.refit.domain.ticket.util.TicketQrPayloadFactory; + +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(); + } + + /* ========================= + * 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/service/MyPageService.java b/src/main/java/com/sku/refit/domain/mypage/service/MyPageService.java new file mode 100644 index 0000000..dfd0ba0 --- /dev/null +++ b/src/main/java/com/sku/refit/domain/mypage/service/MyPageService.java @@ -0,0 +1,47 @@ +/* + * Copyright (c) SKU 다시입을Lab + */ +package com.sku.refit.domain.mypage.service; + +import com.sku.refit.domain.mypage.dto.response.MyPageResponse.*; + +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(); +} 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..c63fb04 --- /dev/null +++ b/src/main/java/com/sku/refit/domain/mypage/service/MyPageServiceImpl.java @@ -0,0 +1,95 @@ +/* + * Copyright (c) SKU 다시입을Lab + */ +package com.sku.refit.domain.mypage.service; + +import java.time.LocalDate; +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.mapper.MyPageMapper; +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.service.UserService; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class MyPageServiceImpl implements MyPageService { + + private final UserService userService; + private final TicketRepository ticketRepository; + private final EventRepository eventRepository; + private final MyPageMapper myPageMapper; + + @Override + public MyTicketsResponse getMyTickets(int page, int size) { + + Long userId = userService.getCurrentUser().getId(); + LocalDate today = LocalDate.now(); + + Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")); + Page ticketPage = ticketRepository.findActiveUnusedTickets(userId, today, pageable); + + Map eventMap = loadEventMap(ticketPage.getContent()); + + return myPageMapper.toMyTicketsResponse(ticketPage, today, eventMap); + } + + @Override + public JoinedEventsResponse getJoinedEvents() { + + Long userId = userService.getCurrentUser().getId(); + + List usedEventTickets = + ticketRepository.findAllByUserIdAndTypeAndUsedAtIsNotNullOrderByUsedAtDesc( + userId, TicketType.EVENT); + + if (usedEventTickets.isEmpty()) { + return JoinedEventsResponse.builder().items(List.of()).build(); + } + + // 최신 usedAt 기준 eventId 중복 제거 + 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(); + + return myPageMapper.toJoinedEventsResponse(orderedEvents); + } + + 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())); + } +} 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..d8f445c 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 @@ -3,11 +3,14 @@ */ package com.sku.refit.domain.ticket.repository; +import java.time.LocalDate; import java.util.List; import java.util.Optional; 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 +31,16 @@ List findAllByUserIdAndTypeAndUsedAtIsNotNullOrderByUsedAtDesc( @Lock(LockModeType.PESSIMISTIC_WRITE) @Query("SELECT t FROM Ticket t WHERE t.token = :token") Optional findByTokenForUpdate(@Param("token") String token); + + @Query( + """ + SELECT t + FROM Ticket t + WHERE t.userId = :userId + AND t.usedAt IS NULL + AND (t.expiresAt IS NULL OR t.expiresAt >= :today) + ORDER BY t.createdAt DESC + """) + Page findActiveUnusedTickets( + @Param("userId") Long userId, @Param("today") LocalDate today, Pageable pageable); } From 9d945782d2c7c8c024d48b4cc3aea7b13d3e1002 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=82=98=EA=B2=BD?= <1030n@naver.com> Date: Sun, 14 Dec 2025 02:17:23 +0900 Subject: [PATCH 05/10] =?UTF-8?q?:recycle:=20Refactor:=20=ED=8B=B0?= =?UTF-8?q?=EC=BC=93=20=EC=A1=B0=ED=9A=8C=EC=8B=9C=20status=20=EC=83=81?= =?UTF-8?q?=EA=B4=80=20=EC=97=86=EC=9D=B4=20=EB=AA=A8=EB=91=90=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mypage/controller/MyPageController.java | 26 +++++++++++++++++-- .../mypage/service/MyPageServiceImpl.java | 3 +-- .../ticket/repository/TicketRepository.java | 13 +--------- 3 files changed, 26 insertions(+), 16 deletions(-) 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 372671d..c5af5eb 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 @@ -19,13 +19,35 @@ public interface MyPageController { @GetMapping("/tickets") @Operation( summary = "내 티켓 리스트 조회", - description = "사용여부(사용전/사용완료/사용만료), 티켓명, 위치, 유효기간, 설명, url(QR payload)을 반환합니다.") + 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 = "Ticket usedAt != null 인 EVENT 티켓을 기준으로 최신순 참여한 행사 목록을 반환합니다.") + description = "행사 예약시 발급되는 티켓 중 사용 완료된 티켓을 기준으로 최신순으로 참여한 행사 목록을 반환합니다.") ResponseEntity> getJoinedEvents(); } 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 c63fb04..54d7bc6 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 @@ -40,7 +40,7 @@ public MyTicketsResponse getMyTickets(int page, int size) { LocalDate today = LocalDate.now(); Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")); - Page ticketPage = ticketRepository.findActiveUnusedTickets(userId, today, pageable); + Page ticketPage = ticketRepository.findAllByUserId(userId, pageable); Map eventMap = loadEventMap(ticketPage.getContent()); @@ -60,7 +60,6 @@ public JoinedEventsResponse getJoinedEvents() { return JoinedEventsResponse.builder().items(List.of()).build(); } - // 최신 usedAt 기준 eventId 중복 제거 LinkedHashSet orderedEventIds = new LinkedHashSet<>(); for (Ticket t : usedEventTickets) { orderedEventIds.add(t.getTargetId()); 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 d8f445c..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 @@ -3,7 +3,6 @@ */ package com.sku.refit.domain.ticket.repository; -import java.time.LocalDate; import java.util.List; import java.util.Optional; @@ -32,15 +31,5 @@ List findAllByUserIdAndTypeAndUsedAtIsNotNullOrderByUsedAtDesc( @Query("SELECT t FROM Ticket t WHERE t.token = :token") Optional findByTokenForUpdate(@Param("token") String token); - @Query( - """ - SELECT t - FROM Ticket t - WHERE t.userId = :userId - AND t.usedAt IS NULL - AND (t.expiresAt IS NULL OR t.expiresAt >= :today) - ORDER BY t.createdAt DESC - """) - Page findActiveUnusedTickets( - @Param("userId") Long userId, @Param("today") LocalDate today, Pageable pageable); + Page findAllByUserId(Long userId, Pageable pageable); } From aaa7124aa1e4681ff9fa61cc45303cd0dcb10c37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=82=98=EA=B2=BD?= <1030n@naver.com> Date: Sun, 14 Dec 2025 02:41:38 +0900 Subject: [PATCH 06/10] =?UTF-8?q?:sparkles:=20Feat:=20=EC=9E=91=EC=84=B1?= =?UTF-8?q?=ED=95=9C=20=EA=B2=8C=EC=8B=9C=EA=B8=80=20=EC=A1=B0=ED=9A=8C=20?= =?UTF-8?q?API=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mypage/controller/MyPageController.java | 24 +++++++++ .../controller/MyPageControllerImpl.java | 9 ++++ .../domain/mypage/service/MyPageService.java | 4 ++ .../mypage/service/MyPageServiceImpl.java | 50 +++++++++++++++++++ .../post/repository/PostRepository.java | 4 ++ 5 files changed, 91 insertions(+) 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 c5af5eb..83bdb58 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,9 +7,12 @@ 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,4 +53,25 @@ ResponseEntity> getMyTickets( 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); } 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 2d55ca1..448100e 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,6 +9,8 @@ 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; @@ -29,4 +31,11 @@ public ResponseEntity> getMyTickets( 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))); + } } 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 dfd0ba0..1c92a3f 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,6 +4,8 @@ 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 { @@ -44,4 +46,6 @@ public interface MyPageService { * @return 사용자가 참여한 행사 목록 응답 */ JoinedEventsResponse getJoinedEvents(); + + InfiniteResponse getMyPosts(Long lastPostId, Integer size); } 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 54d7bc6..674576f 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 @@ -16,13 +16,22 @@ import com.sku.refit.domain.event.repository.EventRepository; import com.sku.refit.domain.mypage.dto.response.MyPageResponse.*; import com.sku.refit.domain.mypage.mapper.MyPageMapper; +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.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.entity.User; import com.sku.refit.domain.user.service.UserService; +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) @@ -32,6 +41,9 @@ public class MyPageServiceImpl implements MyPageService { private final TicketRepository ticketRepository; private final EventRepository eventRepository; private final MyPageMapper myPageMapper; + private final PostRepository postRepository; + private final PostMapper postMapper; + private final InfiniteMapper infiniteMapper; @Override public MyTicketsResponse getMyTickets(int page, int size) { @@ -91,4 +103,42 @@ private Map loadEventMap(List tickets) { return eventRepository.findAllById(eventIds).stream() .collect(Collectors.toMap(Event::getId, Function.identity())); } + + @Override + public InfiniteResponse getMyPosts(Long lastPostId, Integer size) { + + User user = userService.getCurrentUser(); + + Pageable pageable = PageRequest.of(0, size + 1, Sort.by(Sort.Direction.DESC, "id")); + List posts; + + if (lastPostId == null) { + posts = postRepository.findAllByUserId(user.getId(), pageable).getContent(); + } else { + posts = + postRepository + .findAllByUserIdAndIdLessThan(user.getId(), lastPostId, pageable) + .getContent(); + } + + boolean hasNext = posts.size() > size; + if (hasNext) { + posts = posts.subList(0, size); + } + + List responseList = + posts.stream().map(post -> postMapper.toDetailResponse(post, user)).toList(); + + Long newLastCursor = posts.isEmpty() ? null : posts.getLast().getId(); + + log.info( + "[MY POST LIST] userId={}, lastPostId={}, size={}, resultCount={}, hasNext={}", + user.getId(), + lastPostId, + size, + responseList.size(), + hasNext); + + return infiniteMapper.toInfiniteResponse(responseList, newLastCursor, hasNext, size); + } } 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..e1fe43a 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 findAllByUserId(Long userId, Pageable pageable); + + Page findAllByUserIdAndIdLessThan(Long userId, Long lastPostId, Pageable pageable); } From 0e69c8b96cf84805921a71e0a6c2f816aafd7d1e Mon Sep 17 00:00:00 2001 From: HeeJun Yoon Date: Sun, 14 Dec 2025 00:13:41 +0900 Subject: [PATCH 07/10] =?UTF-8?q?:recycle:Refactor:=20=EA=B2=8C=EC=8B=9C?= =?UTF-8?q?=EA=B8=80=20=EC=A2=8B=EC=95=84=EC=9A=94=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80=20=EB=B0=8F=20=EB=8C=93=EA=B8=80=20=EC=88=98?= =?UTF-8?q?,=20=EC=A2=8B=EC=95=84=EC=9A=94=20=EC=88=98=20=EC=A1=B0?= =?UTF-8?q?=ED=9A=8C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../post/controller/PostController.java | 5 + .../post/controller/PostControllerImpl.java | 10 +- .../post/dto/response/PostDetailResponse.java | 9 ++ .../refit/domain/post/entity/PostLike.java | 34 +++++++ .../refit/domain/post/mapper/PostMapper.java | 6 +- .../post/repository/PostLikeRepository.java | 40 ++++++++ .../domain/post/service/PostService.java | 10 ++ .../domain/post/service/PostServiceImpl.java | 99 ++++++++++++++++++- 8 files changed, 205 insertions(+), 8 deletions(-) create mode 100644 src/main/java/com/sku/refit/domain/post/entity/PostLike.java create mode 100644 src/main/java/com/sku/refit/domain/post/repository/PostLikeRepository.java 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/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 Date: Sun, 14 Dec 2025 02:51:32 +0900 Subject: [PATCH 08/10] =?UTF-8?q?:recycle:=20Refactor:=20Post=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=EC=97=90=20=EB=94=B0=EB=A5=B8=20=EC=BD=94=EB=93=9C=20?= =?UTF-8?q?=EB=B3=B4=EC=99=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mypage/exception/MyPageErrorCode.java | 3 +- .../mypage/service/MyPageServiceImpl.java | 193 ++++++++++++------ .../post/repository/PostRepository.java | 4 +- 3 files changed, 138 insertions(+), 62 deletions(-) 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 index d6287a5..5d80633 100644 --- a/src/main/java/com/sku/refit/domain/mypage/exception/MyPageErrorCode.java +++ b/src/main/java/com/sku/refit/domain/mypage/exception/MyPageErrorCode.java @@ -14,7 +14,8 @@ @RequiredArgsConstructor public enum MyPageErrorCode implements BaseErrorCode { TICKETS_FETCH_FAILED("MYPAGE001", "티켓 목록 조회에 실패했습니다.", HttpStatus.INTERNAL_SERVER_ERROR), - JOINED_EVENTS_FETCH_FAILED("MYPAGE002", "참여한 행사 조회에 실패했습니다.", HttpStatus.INTERNAL_SERVER_ERROR); + JOINED_EVENTS_FETCH_FAILED("MYPAGE002", "참여한 행사 조회에 실패했습니다.", HttpStatus.INTERNAL_SERVER_ERROR), + MY_POSTS_FETCH_FAILED("MYPAGE003", "내가 작성한 글 조회에 실패했습니다.", HttpStatus.INTERNAL_SERVER_ERROR); private final String code; private final String message; 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 674576f..29e7435 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 @@ -15,16 +15,19 @@ 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.exception.MyPageErrorCode; import com.sku.refit.domain.mypage.mapper.MyPageMapper; 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.entity.User; 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; @@ -41,7 +44,9 @@ public class MyPageServiceImpl implements MyPageService { private final TicketRepository ticketRepository; private final EventRepository eventRepository; private final MyPageMapper myPageMapper; + private final PostRepository postRepository; + private final PostLikeRepository postLikeRepository; private final PostMapper postMapper; private final InfiniteMapper infiniteMapper; @@ -51,12 +56,27 @@ public MyTicketsResponse getMyTickets(int page, int size) { Long userId = userService.getCurrentUser().getId(); LocalDate today = LocalDate.now(); - Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")); - Page ticketPage = ticketRepository.findAllByUserId(userId, pageable); + try { + Pageable pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "createdAt")); + Page ticketPage = ticketRepository.findAllByUserId(userId, pageable); + + Map eventMap = loadEventMap(ticketPage.getContent()); - Map eventMap = loadEventMap(ticketPage.getContent()); + log.info( + "[MYPAGE] getMyTickets userId={}, page={}, size={}, totalElements={}", + userId, + page, + size, + ticketPage.getTotalElements()); - return myPageMapper.toMyTicketsResponse(ticketPage, today, eventMap); + 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 @@ -64,29 +84,122 @@ public JoinedEventsResponse getJoinedEvents() { Long userId = userService.getCurrentUser().getId(); - List usedEventTickets = - ticketRepository.findAllByUserIdAndTypeAndUsedAtIsNotNullOrderByUsedAtDesc( - userId, TicketType.EVENT); - - if (usedEventTickets.isEmpty()) { - return JoinedEventsResponse.builder().items(List.of()).build(); - } - - LinkedHashSet orderedEventIds = new LinkedHashSet<>(); - for (Ticket t : usedEventTickets) { - orderedEventIds.add(t.getTargetId()); + 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); } + } - Map eventMap = - eventRepository.findAllById(orderedEventIds).stream() - .collect(Collectors.toMap(Event::getId, Function.identity())); + @Override + public InfiniteResponse getMyPosts(Long lastPostId, Integer size) { - List orderedEvents = - orderedEventIds.stream().map(eventMap::get).filter(Objects::nonNull).toList(); + User user = userService.getCurrentUser(); - return myPageMapper.toJoinedEventsResponse(orderedEvents); + 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); + } } + /* ========================= + * Private + * ========================= */ + private Map loadEventMap(List tickets) { List eventIds = @@ -103,42 +216,4 @@ private Map loadEventMap(List tickets) { return eventRepository.findAllById(eventIds).stream() .collect(Collectors.toMap(Event::getId, Function.identity())); } - - @Override - public InfiniteResponse getMyPosts(Long lastPostId, Integer size) { - - User user = userService.getCurrentUser(); - - Pageable pageable = PageRequest.of(0, size + 1, Sort.by(Sort.Direction.DESC, "id")); - List posts; - - if (lastPostId == null) { - posts = postRepository.findAllByUserId(user.getId(), pageable).getContent(); - } else { - posts = - postRepository - .findAllByUserIdAndIdLessThan(user.getId(), lastPostId, pageable) - .getContent(); - } - - boolean hasNext = posts.size() > size; - if (hasNext) { - posts = posts.subList(0, size); - } - - List responseList = - posts.stream().map(post -> postMapper.toDetailResponse(post, user)).toList(); - - Long newLastCursor = posts.isEmpty() ? null : posts.getLast().getId(); - - log.info( - "[MY POST LIST] userId={}, lastPostId={}, size={}, resultCount={}, hasNext={}", - user.getId(), - lastPostId, - size, - responseList.size(), - hasNext); - - return infiniteMapper.toInfiniteResponse(responseList, newLastCursor, hasNext, size); - } } 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 e1fe43a..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 @@ -18,7 +18,7 @@ public interface PostRepository extends JpaRepository { Page findByPostCategoryContainingAndIdLessThan( String category, Long lastPostId, Pageable pageable); - Page findAllByUserId(Long userId, Pageable pageable); + Page findAllByUser_Id(Long userId, Pageable pageable); - Page findAllByUserIdAndIdLessThan(Long userId, Long lastPostId, Pageable pageable); + Page findAllByUser_IdAndIdLessThan(Long userId, Long lastPostId, Pageable pageable); } From 66e3924dca8a8d4dca7c094099ab4a63e02b7b76 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=82=98=EA=B2=BD?= <1030n@naver.com> Date: Sun, 14 Dec 2025 03:02:32 +0900 Subject: [PATCH 09/10] =?UTF-8?q?:sparkles:=20Feat:=20=ED=96=89=EC=82=AC?= =?UTF-8?q?=20=EC=98=88=EC=95=BD=EC=8B=9C=20=EB=AA=A8=EB=B0=94=EC=9D=BC=20?= =?UTF-8?q?=ED=8B=B0=EC=BC=93=20=EB=B0=9C=EA=B8=89=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../sku/refit/domain/event/service/EventServiceImpl.java | 9 +++++++++ .../sku/refit/domain/mypage/service/MyPageService.java | 9 ++++++++- 2 files changed, 17 insertions(+), 1 deletion(-) 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/service/MyPageService.java b/src/main/java/com/sku/refit/domain/mypage/service/MyPageService.java index 1c92a3f..4ee74a3 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 @@ -34,7 +34,7 @@ public interface MyPageService { * ========================= */ /** - * 사용자가 실제로 참여(체크인)한 행사 목록을 조회합니다. + * 사용자가 실제로 참여(체크인)한 행사 목록을 조회합니다. * *

이 메서드는 다음 조건을 만족하는 티켓을 기준으로 합니다. * @@ -47,5 +47,12 @@ public interface MyPageService { */ JoinedEventsResponse getJoinedEvents(); + /** + * 로그인한 사용자가 작성한 게시글 목록을 커서 기반 무한 스크롤 방식으로 조회합니다. + * + * @param lastPostId 마지막으로 조회한 게시글 ID (첫 조회 시 {@code null}) + * @param size 한 번에 조회할 게시글 개수 + * @return 내가 작성한 게시글 무한 스크롤 응답 + */ InfiniteResponse getMyPosts(Long lastPostId, Integer size); } From 97f1ce1a569866e0f7a03ff7722efad5792f580f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=82=98=EA=B2=BD?= <1030n@naver.com> Date: Sun, 14 Dec 2025 04:48:13 +0900 Subject: [PATCH 10/10] =?UTF-8?q?:sparkles:=20Feat:=20=EB=A7=88=EC=9D=B4?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=EC=A0=95=EB=B3=B4=20API=20=EA=B5=AC=ED=98=84=20+=20User=20?= =?UTF-8?q?=ED=95=84=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mypage/controller/MyPageController.java | 14 +++ .../controller/MyPageControllerImpl.java | 5 ++ .../mypage/dto/response/MyPageResponse.java | 41 +++++++++ .../mypage/entity/CarbonReductionHistory.java | 42 +++++++++ .../mypage/exception/MyPageErrorCode.java | 6 +- .../domain/mypage/mapper/MyPageMapper.java | 43 +++++++++ .../CarbonReductionHistoryRepository.java | 15 ++++ .../domain/mypage/service/MyPageService.java | 17 ++++ .../mypage/service/MyPageServiceImpl.java | 87 ++++++++++++++++++- .../sku/refit/domain/user/entity/User.java | 15 ++++ 10 files changed, 280 insertions(+), 5 deletions(-) create mode 100644 src/main/java/com/sku/refit/domain/mypage/entity/CarbonReductionHistory.java create mode 100644 src/main/java/com/sku/refit/domain/mypage/repository/CarbonReductionHistoryRepository.java 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 83bdb58..92e321f 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 @@ -74,4 +74,18 @@ ResponseEntity>> getMyPosts( 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 index 448100e..c5dbf9a 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 @@ -38,4 +38,9 @@ public ResponseEntity>> getMyP 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 index b6b23d6..8447e1b 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 @@ -9,6 +9,7 @@ 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.*; @@ -101,4 +102,44 @@ public static class JoinedEventItem { @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 index 5d80633..765090d 100644 --- a/src/main/java/com/sku/refit/domain/mypage/exception/MyPageErrorCode.java +++ b/src/main/java/com/sku/refit/domain/mypage/exception/MyPageErrorCode.java @@ -15,8 +15,10 @@ 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); - + 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 index a4f2a22..7c6f6f3 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 @@ -4,6 +4,7 @@ package com.sku.refit.domain.mypage.mapper; import java.time.LocalDate; +import java.time.LocalDateTime; import java.util.List; import java.util.Map; @@ -13,9 +14,12 @@ 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; @@ -102,6 +106,45 @@ public JoinedEventItem toJoinedEventItem(Event event) { .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 * ========================= */ 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 index 4ee74a3..e5c594c 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 @@ -55,4 +55,21 @@ public interface MyPageService { * @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 index 29e7435..b3d1c7d 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 @@ -4,6 +4,7 @@ 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; @@ -15,8 +16,10 @@ 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; @@ -25,7 +28,10 @@ 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; @@ -40,16 +46,22 @@ @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) { @@ -196,9 +208,48 @@ public InfiniteResponse getMyPosts(Long lastPostId, Integer } } - /* ========================= - * Private - * ========================= */ + @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) { @@ -216,4 +267,34 @@ private Map loadEventMap(List tickets) { 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/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)