From f906640111f9d5b466f5c8868c4c7c6a4b06a418 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=82=98=EA=B2=BD?= <1030n@naver.com> Date: Sat, 13 Dec 2025 08:39:34 +0900 Subject: [PATCH 1/5] =?UTF-8?q?:sparkles:=20Feat:=20=ED=8B=B0=EC=BC=93=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20API?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ticket/controller/TicketController.java | 46 ++++ .../controller/TicketControllerImpl.java | 54 +++++ .../ticket/dto/request/TicketRequest.java | 44 ++++ .../ticket/dto/response/TicketResponse.java | 72 +++++++ .../refit/domain/ticket/entity/Ticket.java | 87 ++++++++ .../domain/ticket/entity/TicketStatus.java | 17 ++ .../domain/ticket/entity/TicketType.java | 14 ++ .../ticket/exception/TicketErrorCode.java | 32 +++ .../domain/ticket/mapper/TicketMapper.java | 87 ++++++++ .../ticket/repository/TicketRepository.java | 22 ++ .../domain/ticket/service/TicketService.java | 96 +++++++++ .../ticket/service/TicketServiceImpl.java | 197 ++++++++++++++++++ .../ticket/util/TicketQrPayloadFactory.java | 25 +++ .../ticket/util/TicketTokenGenerator.java | 21 ++ 14 files changed, 814 insertions(+) create mode 100644 src/main/java/com/sku/refit/domain/ticket/controller/TicketController.java create mode 100644 src/main/java/com/sku/refit/domain/ticket/controller/TicketControllerImpl.java create mode 100644 src/main/java/com/sku/refit/domain/ticket/dto/request/TicketRequest.java create mode 100644 src/main/java/com/sku/refit/domain/ticket/dto/response/TicketResponse.java create mode 100644 src/main/java/com/sku/refit/domain/ticket/entity/Ticket.java create mode 100644 src/main/java/com/sku/refit/domain/ticket/entity/TicketStatus.java create mode 100644 src/main/java/com/sku/refit/domain/ticket/entity/TicketType.java create mode 100644 src/main/java/com/sku/refit/domain/ticket/exception/TicketErrorCode.java create mode 100644 src/main/java/com/sku/refit/domain/ticket/mapper/TicketMapper.java create mode 100644 src/main/java/com/sku/refit/domain/ticket/repository/TicketRepository.java create mode 100644 src/main/java/com/sku/refit/domain/ticket/service/TicketService.java create mode 100644 src/main/java/com/sku/refit/domain/ticket/service/TicketServiceImpl.java create mode 100644 src/main/java/com/sku/refit/domain/ticket/util/TicketQrPayloadFactory.java create mode 100644 src/main/java/com/sku/refit/domain/ticket/util/TicketTokenGenerator.java 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 new file mode 100644 index 0000000..2c2f5b5 --- /dev/null +++ b/src/main/java/com/sku/refit/domain/ticket/controller/TicketController.java @@ -0,0 +1,46 @@ +/* + * Copyright (c) SKU 다시입을Lab + */ +package com.sku.refit.domain.ticket.controller; + +import java.util.List; + +import jakarta.validation.Valid; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import com.sku.refit.domain.ticket.dto.request.TicketRequest.*; +import com.sku.refit.domain.ticket.dto.response.TicketResponse.*; +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 = "티켓 발급/검증/체크인 및 사용자 조회") +@RequestMapping("/api/tickets") +public interface TicketController { + + @PostMapping("/dev/issue") + @Operation(summary = "[개발자] 티켓 발급", description = "EVENT/CLOTH 티켓을 발급하고 token을 반환합니다.") + ResponseEntity> issueTicket( + @RequestBody @Valid IssueTicketRequest request); + + @PostMapping("/admin/verify") + @Operation(summary = "[개발자] 티켓 검증", description = "token으로 티켓 유효/사용 여부를 확인합니다. (사용 처리 X)") + ResponseEntity> verifyTicket( + @RequestBody @Valid VerifyTicketRequest request); + + @PostMapping("/admin/consume") + @Operation(summary = "[관리자] 티켓 사용 처리", description = "token으로 티켓을 사용 처리합니다. (멱등)") + ResponseEntity> consumeTicket( + @RequestBody @Valid ConsumeTicketRequest request); + + @GetMapping("/my/events") + @Operation(summary = "참가한 행사 조회", description = "사용자가 사용한 EVENT 티켓만 조회합니다.") + ResponseEntity>> getMyUsedEventTickets(); + + @GetMapping("/my/cloth") + @Operation(summary = "교환 내역 조회", description = "사용자가 받았던 CLOTH 티켓을 모두 조회합니다.") + ResponseEntity>> getMyClothTickets(); +} diff --git a/src/main/java/com/sku/refit/domain/ticket/controller/TicketControllerImpl.java b/src/main/java/com/sku/refit/domain/ticket/controller/TicketControllerImpl.java new file mode 100644 index 0000000..f9dcc05 --- /dev/null +++ b/src/main/java/com/sku/refit/domain/ticket/controller/TicketControllerImpl.java @@ -0,0 +1,54 @@ +/* + * Copyright (c) SKU 다시입을Lab + */ +package com.sku.refit.domain.ticket.controller; + +import java.util.List; + +import jakarta.validation.Valid; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +import com.sku.refit.domain.ticket.dto.request.TicketRequest.*; +import com.sku.refit.domain.ticket.dto.response.TicketResponse.*; +import com.sku.refit.domain.ticket.service.TicketService; +import com.sku.refit.global.response.BaseResponse; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequiredArgsConstructor +public class TicketControllerImpl implements TicketController { + + private final TicketService ticketService; + + @Override + public ResponseEntity> issueTicket( + @RequestBody @Valid IssueTicketRequest request) { + return ResponseEntity.ok(BaseResponse.success(ticketService.issueTicket(request))); + } + + @Override + public ResponseEntity> verifyTicket( + @RequestBody @Valid VerifyTicketRequest request) { + return ResponseEntity.ok(BaseResponse.success(ticketService.verifyTicket(request))); + } + + @Override + public ResponseEntity> consumeTicket( + @RequestBody @Valid ConsumeTicketRequest request) { + return ResponseEntity.ok(BaseResponse.success(ticketService.consumeTicket(request))); + } + + @Override + public ResponseEntity>> getMyUsedEventTickets() { + return ResponseEntity.ok(BaseResponse.success(ticketService.getMyTicketsUsedEvents())); + } + + @Override + public ResponseEntity>> getMyClothTickets() { + return ResponseEntity.ok(BaseResponse.success(ticketService.getMyClothTickets())); + } +} 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 new file mode 100644 index 0000000..748fa73 --- /dev/null +++ b/src/main/java/com/sku/refit/domain/ticket/dto/request/TicketRequest.java @@ -0,0 +1,44 @@ +/* + * Copyright (c) SKU 다시입을Lab + */ +package com.sku.refit.domain.ticket.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; + +import com.sku.refit.domain.ticket.entity.TicketType; + +import lombok.*; + +public class TicketRequest { + + @Getter + @NoArgsConstructor + @AllArgsConstructor + public static class IssueTicketRequest { + @NotNull private TicketType type; + @NotNull private Long targetId; + + /** + * 일반적으로는 "발급 대상"을 명확히 해야 해서 필요합니다. - 사용자 본인에게 발급: userId 생략하고 서버에서 currentUser 사용 - 관리자가 특정 + * 유저에게 발급: userId를 받음 + * + *

지금은 "단순 생성/검증 로직"이 목표이므로 옵션으로 둡니다. + */ + private Long userId; + } + + @Getter + @NoArgsConstructor + @AllArgsConstructor + public static class ConsumeTicketRequest { + @NotBlank private String token; + } + + @Getter + @NoArgsConstructor + @AllArgsConstructor + public static class VerifyTicketRequest { + @NotBlank private String token; + } +} 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 new file mode 100644 index 0000000..bb5bb6c --- /dev/null +++ b/src/main/java/com/sku/refit/domain/ticket/dto/response/TicketResponse.java @@ -0,0 +1,72 @@ +/* + * Copyright (c) SKU 다시입을Lab + */ +package com.sku.refit.domain.ticket.dto.response; + +import java.time.LocalDateTime; + +import com.sku.refit.domain.ticket.entity.TicketType; + +import lombok.*; + +public class TicketResponse { + + @Getter + @Builder + public static class TicketDetailResponse { + private Long ticketId; + private TicketType type; + private Long targetId; + + private String token; + private String qrPayload; + + private LocalDateTime issuedAt; + private LocalDateTime usedAt; + } + + @Getter + @Builder + public static class VerifyTicketResponse { + private boolean valid; + private boolean used; + + private Long ticketId; + private TicketType type; + private Long targetId; + + private String qrPayload; + + private LocalDateTime issuedAt; + private LocalDateTime usedAt; + } + + @Getter + @Builder + public static class ConsumeTicketResponse { + private boolean consumed; + + private Long ticketId; + private TicketType type; + private Long targetId; + + private String qrPayload; + + private LocalDateTime usedAt; + } + + /** 사용자 조회용: - EVENT: "사용한 행사"만 조회(usedAt != null) - CLOTH: "받은 티켓" 전체 조회(usedAt 상관 없음) */ + @Getter + @Builder + public static class MyTicketItemResponse { + private Long ticketId; + private TicketType type; + private Long targetId; + + private String qrPayload; + private String token; + + private LocalDateTime issuedAt; + private LocalDateTime usedAt; + } +} 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 new file mode 100644 index 0000000..8a1382e --- /dev/null +++ b/src/main/java/com/sku/refit/domain/ticket/entity/Ticket.java @@ -0,0 +1,87 @@ +/* + * Copyright (c) SKU 다시입을Lab + */ +package com.sku.refit.domain.ticket.entity; + +import java.time.LocalDateTime; + +import jakarta.persistence.*; + +import com.sku.refit.global.common.BaseTimeEntity; + +import lombok.*; + +@Entity +@Getter +@Builder +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@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") + }) +public class Ticket extends BaseTimeEntity { + + /** PK (외부 노출 금지) */ + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + /** 티켓 종류 (EVENT / CLOTH) */ + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private TicketType type; + + /** 티켓 대상 ID - EVENT → eventId - CLOTH → clothId */ + @Column(name = "target_id", nullable = false) + private Long targetId; + + /** 티켓 소유 사용자 */ + @Column(name = "user_id", nullable = false) + private Long userId; + + /** QR에 들어가는 검증용 토큰 - 외부 노출 OK - 의미 없는 랜덤 값 - unique 필수 */ + @Column(nullable = false, unique = true, length = 128) + private String token; + + /** 사용 시각 (null = 미사용) */ + @Column(name = "used_at") + private LocalDateTime usedAt; + + /* ========================= + * Domain Logic + * ========================= */ + + /** 사용 여부 */ + public boolean isUsed() { + return usedAt != null; + } + + /** 티켓 사용 처리 (멱등) */ + public void consume(LocalDateTime now) { + if (this.usedAt != null) { + return; + } + this.usedAt = now; + } + + /** 기본 도메인 유효성 */ + public void validate() { + if (type == null) { + throw new IllegalStateException("Ticket type은 필수입니다."); + } + if (targetId == null) { + throw new IllegalStateException("Ticket targetId는 필수입니다."); + } + if (userId == null) { + throw new IllegalStateException("Ticket userId는 필수입니다."); + } + if (token == null || token.isBlank()) { + throw new IllegalStateException("Ticket token은 필수입니다."); + } + } +} diff --git a/src/main/java/com/sku/refit/domain/ticket/entity/TicketStatus.java b/src/main/java/com/sku/refit/domain/ticket/entity/TicketStatus.java new file mode 100644 index 0000000..b4dcd9b --- /dev/null +++ b/src/main/java/com/sku/refit/domain/ticket/entity/TicketStatus.java @@ -0,0 +1,17 @@ +/* + * Copyright (c) SKU 다시입을Lab + */ +package com.sku.refit.domain.ticket.entity; + +import io.swagger.v3.oas.annotations.media.Schema; + +public enum TicketStatus { + @Schema(description = "발급됨(미사용)") + ISSUED, + + @Schema(description = "사용됨 (체크인/검증 완료 처리 등)") + CONSUMED, + + @Schema(description = "만료") + EXPIRED +} diff --git a/src/main/java/com/sku/refit/domain/ticket/entity/TicketType.java b/src/main/java/com/sku/refit/domain/ticket/entity/TicketType.java new file mode 100644 index 0000000..6c03a98 --- /dev/null +++ b/src/main/java/com/sku/refit/domain/ticket/entity/TicketType.java @@ -0,0 +1,14 @@ +/* + * Copyright (c) SKU 다시입을Lab + */ +package com.sku.refit.domain.ticket.entity; + +import io.swagger.v3.oas.annotations.media.Schema; + +public enum TicketType { + @Schema(description = "행사 체크인") + EVENT, + + @Schema(description = "의류 관련 티켓") + CLOTH +} 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 new file mode 100644 index 0000000..bbb12c4 --- /dev/null +++ b/src/main/java/com/sku/refit/domain/ticket/exception/TicketErrorCode.java @@ -0,0 +1,32 @@ +/* + * Copyright (c) SKU 다시입을Lab + */ +package com.sku.refit.domain.ticket.exception; + +import org.springframework.http.HttpStatus; + +import com.sku.refit.global.exception.model.BaseErrorCode; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum TicketErrorCode implements BaseErrorCode { + TICKET_BAD_REQUEST("TICKET001", "요청 값이 올바르지 않습니다.", HttpStatus.BAD_REQUEST), + TICKET_TOKEN_REQUIRED("TICKET002", "티켓 토큰이 필요합니다.", HttpStatus.BAD_REQUEST), + + TICKET_NOT_FOUND("TICKET0010", "티켓을 찾을 수 없습니다.", HttpStatus.NOT_FOUND), + TICKET_ALREADY_USED("TICKET020", "이미 사용된 티켓입니다.", HttpStatus.CONFLICT), + + TICKET_TOKEN_GENERATION_FAILED( + "TICKET030", "티켓 토큰 생성에 실패했습니다.", HttpStatus.INTERNAL_SERVER_ERROR), + TICKET_ISSUE_FAILED("TICKET031", "티켓 발급에 실패했습니다.", HttpStatus.INTERNAL_SERVER_ERROR), + TICKET_VERIFY_FAILED("TICKET032", "티켓 검증에 실패했습니다.", HttpStatus.INTERNAL_SERVER_ERROR), + TICKET_CONSUME_FAILED("TICKET033", "티켓 사용 처리에 실패했습니다.", HttpStatus.INTERNAL_SERVER_ERROR), + TICKET_MY_LIST_FAILED("TICKET034", "내 티켓 목록 조회에 실패했습니다.", 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/ticket/mapper/TicketMapper.java b/src/main/java/com/sku/refit/domain/ticket/mapper/TicketMapper.java new file mode 100644 index 0000000..c17608d --- /dev/null +++ b/src/main/java/com/sku/refit/domain/ticket/mapper/TicketMapper.java @@ -0,0 +1,87 @@ +/* + * Copyright (c) SKU 다시입을Lab + */ +package com.sku.refit.domain.ticket.mapper; + +import org.springframework.stereotype.Component; + +import com.sku.refit.domain.ticket.dto.request.TicketRequest.IssueTicketRequest; +import com.sku.refit.domain.ticket.dto.response.TicketResponse.*; +import com.sku.refit.domain.ticket.entity.Ticket; +import com.sku.refit.domain.ticket.util.TicketQrPayloadFactory; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class TicketMapper { + + private final TicketQrPayloadFactory qrPayloadFactory; + + /** 발급 요청 + userId + token -> Ticket 엔티티 */ + public Ticket toEntity(IssueTicketRequest request, Long userId, String token) { + return Ticket.builder() + .type(request.getType()) + .targetId(request.getTargetId()) + .userId(userId) + .token(token) + .build(); + } + + /** 발급 응답 */ + public TicketDetailResponse toDetail(Ticket ticket) { + return TicketDetailResponse.builder() + .ticketId(ticket.getId()) + .type(ticket.getType()) + .targetId(ticket.getTargetId()) + .token(ticket.getToken()) + .qrPayload(qrPayloadFactory.create(ticket.getToken())) + .issuedAt(ticket.getCreatedAt()) + .usedAt(ticket.getUsedAt()) + .build(); + } + + /** 사용자 목록용 응답 */ + public MyTicketItemResponse toMyItem(Ticket ticket) { + return MyTicketItemResponse.builder() + .ticketId(ticket.getId()) + .type(ticket.getType()) + .targetId(ticket.getTargetId()) + .token(ticket.getToken()) + .qrPayload(qrPayloadFactory.create(ticket.getToken())) + .issuedAt(ticket.getCreatedAt()) + .usedAt(ticket.getUsedAt()) + .build(); + } + + /** 검증 응답: 존재할 때 */ + public VerifyTicketResponse toVerifyFound(Ticket ticket) { + return VerifyTicketResponse.builder() + .valid(true) + .used(ticket.isUsed()) + .ticketId(ticket.getId()) + .type(ticket.getType()) + .targetId(ticket.getTargetId()) + .qrPayload(qrPayloadFactory.create(ticket.getToken())) + .issuedAt(ticket.getCreatedAt()) + .usedAt(ticket.getUsedAt()) + .build(); + } + + /** 검증 응답: 없을 때 */ + public VerifyTicketResponse toVerifyNotFound() { + return VerifyTicketResponse.builder().valid(false).used(false).build(); + } + + /** 사용(체크인) 응답 */ + public ConsumeTicketResponse toConsume(Ticket ticket, boolean consumed) { + return ConsumeTicketResponse.builder() + .consumed(consumed) + .ticketId(ticket.getId()) + .type(ticket.getType()) + .targetId(ticket.getTargetId()) + .qrPayload(qrPayloadFactory.create(ticket.getToken())) + .usedAt(ticket.getUsedAt()) + .build(); + } +} diff --git a/src/main/java/com/sku/refit/domain/ticket/repository/TicketRepository.java b/src/main/java/com/sku/refit/domain/ticket/repository/TicketRepository.java new file mode 100644 index 0000000..739e316 --- /dev/null +++ b/src/main/java/com/sku/refit/domain/ticket/repository/TicketRepository.java @@ -0,0 +1,22 @@ +/* + * Copyright (c) SKU 다시입을Lab + */ +package com.sku.refit.domain.ticket.repository; + +import java.util.List; +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.sku.refit.domain.ticket.entity.Ticket; +import com.sku.refit.domain.ticket.entity.TicketType; + +public interface TicketRepository extends JpaRepository { + + Optional findByToken(String token); + + List findAllByUserIdAndTypeOrderByCreatedAtDesc(Long userId, TicketType type); + + List findAllByUserIdAndTypeAndUsedAtIsNotNullOrderByUsedAtDesc( + Long userId, TicketType type); +} 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 new file mode 100644 index 0000000..03d0bb1 --- /dev/null +++ b/src/main/java/com/sku/refit/domain/ticket/service/TicketService.java @@ -0,0 +1,96 @@ +/* + * Copyright (c) SKU 다시입을Lab + */ +package com.sku.refit.domain.ticket.service; + +import java.util.List; + +import com.sku.refit.domain.ticket.dto.request.TicketRequest.*; +import com.sku.refit.domain.ticket.dto.response.TicketResponse.*; + +/** + * 티켓(Ticket) 관련 주요 기능을 제공하는 서비스 인터페이스입니다. + * + *

티켓은 QR 코드로 사용자에게 전달되며, QR에는 오직 {@code token} 값만 포함됩니다. 서버는 해당 토큰을 기준으로 티켓의 유효성 및 사용 여부를 검증합니다. + * + *

주요 기능: + * + *

    + *
  • 티켓 발급 (EVENT / CLOTH 공용) + *
  • 티켓 검증 (사용 처리 없음) + *
  • 티켓 사용 처리 (체크인, 멱등 보장) + *
  • 사용자 기준 티켓 조회 + *
+ */ +public interface TicketService { + + /* ========================= + * Issue + * ========================= */ + + /** + * 새로운 티켓을 발급합니다. + * + *

티켓은 {@code EVENT}, {@code CLOTH} 등 타입에 관계없이 동일한 발급 로직을 사용하며, 발급 시 고유한 {@code token} 이 생성됩니다. + * + *

생성된 토큰은 QR 코드로 사용자에게 전달되며, 이후 모든 검증/체크인 로직은 해당 토큰을 기준으로 수행됩니다. + * + * @param request 티켓 발급 요청 정보 (티켓 타입, 대상 ID, 발급 대상 사용자) + * @return 발급된 티켓 상세 정보 (type, targetId, token, 발급 시각 등) + */ + TicketDetailResponse issueTicket(IssueTicketRequest request); + + /* ========================= + * Verify + * ========================= */ + + /** + * 티켓을 검증합니다. (사용 처리 없음) + * + *

주어진 {@code token} 에 해당하는 티켓이 존재하는지, 그리고 이미 사용된 티켓인지 여부를 확인합니다. + * + *

이 메서드는 티켓을 실제로 사용 처리하지 않으며, QR 스캔 시 사전 검증 용도로 사용됩니다. + * + * @param request 티켓 검증 요청 정보 (token) + * @return 티켓 유효성 및 사용 여부 응답 + */ + VerifyTicketResponse verifyTicket(VerifyTicketRequest request); + + /* ========================= + * Consume (Check-in) + * ========================= */ + + /** + * 티켓을 사용 처리합니다. (체크인) + * + *

해당 메서드는 멱등성을 보장하며, 이미 사용된 티켓에 대해 다시 호출되더라도 예외 없이 동일한 상태를 반환합니다. + * + *

일반적으로 관리자 또는 키오스크에서 QR 스캔 후 실제 입장/수거/처리 시점에 호출됩니다. + * + * @param request 티켓 사용 요청 정보 (token) + * @return 티켓 사용 처리 결과 응답 + */ + ConsumeTicketResponse consumeTicket(ConsumeTicketRequest request); + + /* ========================= + * Query (My Page) + * ========================= */ + + /** + * 사용자가 사용 완료한 행사(EVENT) 티켓만 조회합니다. + * + *

행사 티켓의 경우, 실제 체크인이 완료된 행사만 사용자에게 "참여한 행사"로 간주하여 반환합니다. + * + * @return 사용자가 사용한 행사 티켓 목록 + */ + List getMyTicketsUsedEvents(); + + /** + * 사용자가 발급받은 CLOTH 티켓 전체를 조회합니다. + * + *

CLOTH 티켓은 사용 여부와 무관하게, 티켓을 발급받은 시점부터 사용자에게 노출됩니다. + * + * @return 사용자가 발급받은 CLOTH 티켓 목록 + */ + List getMyClothTickets(); +} 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 new file mode 100644 index 0000000..7613871 --- /dev/null +++ b/src/main/java/com/sku/refit/domain/ticket/service/TicketServiceImpl.java @@ -0,0 +1,197 @@ +/* + * Copyright (c) SKU 다시입을Lab + */ +package com.sku.refit.domain.ticket.service; + +import java.time.LocalDateTime; +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.sku.refit.domain.ticket.dto.request.TicketRequest.*; +import com.sku.refit.domain.ticket.dto.response.TicketResponse.*; +import com.sku.refit.domain.ticket.entity.Ticket; +import com.sku.refit.domain.ticket.entity.TicketType; +import com.sku.refit.domain.ticket.exception.TicketErrorCode; +import com.sku.refit.domain.ticket.mapper.TicketMapper; +import com.sku.refit.domain.ticket.repository.TicketRepository; +import com.sku.refit.domain.ticket.util.TicketTokenGenerator; +import com.sku.refit.domain.user.entity.User; +import com.sku.refit.domain.user.service.UserService; +import com.sku.refit.global.exception.CustomException; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Service +@RequiredArgsConstructor +@Slf4j +@Transactional(readOnly = true) +public class TicketServiceImpl implements TicketService { + + private final TicketRepository ticketRepository; + private final UserService userService; + + private final TicketMapper ticketMapper; + private final TicketTokenGenerator tokenGenerator; + + /* ========================= + * Issue + * ========================= */ + + @Override + @Transactional + public TicketDetailResponse issueTicket(IssueTicketRequest request) { + + validateIssueRequest(request); + + User user = userService.getCurrentUser(); + Long userId = user.getId(); + + String token; + try { + token = tokenGenerator.generate(); + } catch (Exception e) { + log.error("[TICKET] issueTicket - token generation failed, userId={}", userId, e); + throw new CustomException(TicketErrorCode.TICKET_TOKEN_GENERATION_FAILED); + } + + try { + Ticket ticket = ticketMapper.toEntity(request, userId, token); + Ticket saved = ticketRepository.save(ticket); + return ticketMapper.toDetail(saved); + + } catch (CustomException e) { + throw e; + } catch (Exception e) { + log.error( + "[TICKET] issueTicket - save failed, userId={}, type={}, targetId={}", + userId, + request.getType(), + request.getTargetId(), + e); + throw new CustomException(TicketErrorCode.TICKET_ISSUE_FAILED); + } + } + + /* ========================= + * Verify + * ========================= */ + + @Override + public VerifyTicketResponse verifyTicket(VerifyTicketRequest request) { + + validateToken(request.getToken()); + + try { + return ticketRepository + .findByToken(request.getToken()) + .map(ticketMapper::toVerifyFound) + .orElseGet(ticketMapper::toVerifyNotFound); + + } catch (CustomException e) { + throw e; + } catch (Exception e) { + log.error("[TICKET] verifyTicket - failed", e); + throw new CustomException(TicketErrorCode.TICKET_VERIFY_FAILED); + } + } + + /* ========================= + * Consume + * ========================= */ + + @Override + @Transactional + public ConsumeTicketResponse consumeTicket(ConsumeTicketRequest request) { + + validateToken(request.getToken()); + + Ticket ticket = + ticketRepository + .findByToken(request.getToken()) + .orElseThrow(() -> new CustomException(TicketErrorCode.TICKET_NOT_FOUND)); + + try { + boolean alreadyUsed = ticket.isUsed(); + + if (!alreadyUsed) { + ticket.consume(LocalDateTime.now()); // 멱등 처리 + } + + return ticketMapper.toConsume(ticket, !alreadyUsed); + + } catch (CustomException e) { + throw e; + } catch (Exception e) { + log.error( + "[TICKET] consumeTicket - failed, ticketId={}, type={}, targetId={}", + ticket.getId(), + ticket.getType(), + ticket.getTargetId(), + e); + throw new CustomException(TicketErrorCode.TICKET_CONSUME_FAILED); + } + } + + /* ========================= + * My Tickets + * ========================= */ + + @Override + public List getMyTicketsUsedEvents() { + + Long userId = userService.getCurrentUser().getId(); + + try { + return ticketRepository + .findAllByUserIdAndTypeAndUsedAtIsNotNullOrderByUsedAtDesc(userId, TicketType.EVENT) + .stream() + .map(ticketMapper::toMyItem) + .toList(); + + } catch (CustomException e) { + throw e; + } catch (Exception e) { + log.error("[TICKET] getMyTicketsUsedEvents - failed, userId={}", userId, e); + throw new CustomException(TicketErrorCode.TICKET_MY_LIST_FAILED); + } + } + + @Override + public List getMyClothTickets() { + + Long userId = userService.getCurrentUser().getId(); + + try { + return ticketRepository + .findAllByUserIdAndTypeOrderByCreatedAtDesc(userId, TicketType.CLOTH) + .stream() + .map(ticketMapper::toMyItem) + .toList(); + + } catch (CustomException e) { + throw e; + } catch (Exception e) { + log.error("[TICKET] getMyClothTickets - failed, userId={}", userId, e); + throw new CustomException(TicketErrorCode.TICKET_MY_LIST_FAILED); + } + } + + /* ========================= + * Private + * ========================= */ + + private void validateIssueRequest(IssueTicketRequest request) { + if (request == null || request.getType() == null || request.getTargetId() == null) { + throw new CustomException(TicketErrorCode.TICKET_BAD_REQUEST); + } + } + + private void validateToken(String token) { + if (token == null || token.isBlank()) { + throw new CustomException(TicketErrorCode.TICKET_TOKEN_REQUIRED); + } + } +} diff --git a/src/main/java/com/sku/refit/domain/ticket/util/TicketQrPayloadFactory.java b/src/main/java/com/sku/refit/domain/ticket/util/TicketQrPayloadFactory.java new file mode 100644 index 0000000..89c527d --- /dev/null +++ b/src/main/java/com/sku/refit/domain/ticket/util/TicketQrPayloadFactory.java @@ -0,0 +1,25 @@ +/* + * Copyright (c) SKU 다시입을Lab + */ +package com.sku.refit.domain.ticket.util; + +import org.springframework.stereotype.Component; + +@Component +public class TicketQrPayloadFactory { + + private static final String QR_BASE_URL = "https://api.refitlab.site/ticket"; + private static final String QR_VERSION = "1"; + + /** + * QR에 인코딩될 payload URL을 생성합니다. + * + *

예시: https://api.refitlab.site/ticket?v=1&token=xxxx + * + * @param token 티켓 고유 토큰 + * @return QR payload URL + */ + public String create(String token) { + return QR_BASE_URL + "?v=" + QR_VERSION + "&token=" + token; + } +} diff --git a/src/main/java/com/sku/refit/domain/ticket/util/TicketTokenGenerator.java b/src/main/java/com/sku/refit/domain/ticket/util/TicketTokenGenerator.java new file mode 100644 index 0000000..244a789 --- /dev/null +++ b/src/main/java/com/sku/refit/domain/ticket/util/TicketTokenGenerator.java @@ -0,0 +1,21 @@ +/* + * Copyright (c) SKU 다시입을Lab + */ +package com.sku.refit.domain.ticket.util; + +import java.security.SecureRandom; +import java.util.Base64; + +import org.springframework.stereotype.Component; + +@Component +public class TicketTokenGenerator { + + private static final SecureRandom RANDOM = new SecureRandom(); + + public String generate() { + byte[] bytes = new byte[32]; + RANDOM.nextBytes(bytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes); + } +} From 756c2c9192d687017d96501f22f94ec0305c0cca Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=82=98=EA=B2=BD?= <1030n@naver.com> Date: Sat, 13 Dec 2025 08:41:12 +0900 Subject: [PATCH 2/5] =?UTF-8?q?:recycle:=20Refactor:=20swagger=20=EC=84=A4?= =?UTF-8?q?=EB=AA=85=20=EC=9D=BC=EB=B6=80=20=EC=88=98=EC=A0=95?= 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 2c2f5b5..652886e 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 @@ -17,7 +17,7 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; -@Tag(name = "티켓", description = "티켓 발급/검증/체크인 및 사용자 조회") +@Tag(name = "티켓", description = "티켓 관련 API") @RequestMapping("/api/tickets") public interface TicketController { @@ -32,7 +32,7 @@ ResponseEntity> verifyTicket( @RequestBody @Valid VerifyTicketRequest request); @PostMapping("/admin/consume") - @Operation(summary = "[관리자] 티켓 사용 처리", description = "token으로 티켓을 사용 처리합니다. (멱등)") + @Operation(summary = "[관리자] 티켓 사용 처리", description = "token으로 티켓을 사용 처리합니다. (멱등 사용 처리)") ResponseEntity> consumeTicket( @RequestBody @Valid ConsumeTicketRequest request); From c0438f52788fa8afdd9eb9a4b45160cabfa5b82f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=82=98=EA=B2=BD?= <1030n@naver.com> Date: Sat, 13 Dec 2025 09:02:08 +0900 Subject: [PATCH 3/5] =?UTF-8?q?:recycle:=20Refactor:=20=EB=8F=99=EC=8B=9C?= =?UTF-8?q?=20=ED=8B=B0=EC=BC=93=20=EC=86=8C=EB=B9=84=EC=97=90=20=EB=94=B0?= =?UTF-8?q?=EB=A5=B8=20=EB=8F=99=EC=8B=9C=EC=84=B1=20=EC=9D=B4=EC=8A=88=20?= =?UTF-8?q?=ED=95=B4=EA=B2=B0=20+=20=EB=A9=94=EC=84=9C=EB=93=9C=20?= =?UTF-8?q?=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ticket/controller/TicketController.java | 2 +- .../controller/TicketControllerImpl.java | 6 +++- .../ticket/dto/request/TicketRequest.java | 6 ---- .../domain/ticket/entity/TicketStatus.java | 17 ----------- .../domain/ticket/mapper/TicketMapper.java | 8 ++--- .../ticket/repository/TicketRepository.java | 8 +++++ .../domain/ticket/service/TicketService.java | 3 +- .../ticket/service/TicketServiceImpl.java | 29 ++++++++++--------- 8 files changed, 36 insertions(+), 43 deletions(-) delete mode 100644 src/main/java/com/sku/refit/domain/ticket/entity/TicketStatus.java 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 652886e..e2991d3 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 @@ -27,7 +27,7 @@ ResponseEntity> issueTicket( @RequestBody @Valid IssueTicketRequest request); @PostMapping("/admin/verify") - @Operation(summary = "[개발자] 티켓 검증", description = "token으로 티켓 유효/사용 여부를 확인합니다. (사용 처리 X)") + @Operation(summary = "[관리자] 티켓 검증", description = "token으로 티켓 유효/사용 여부를 확인합니다. (사용 처리 X)") ResponseEntity> verifyTicket( @RequestBody @Valid VerifyTicketRequest request); 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 f9dcc05..a5ea559 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 @@ -27,7 +27,11 @@ public class TicketControllerImpl implements TicketController { @Override public ResponseEntity> issueTicket( @RequestBody @Valid IssueTicketRequest request) { - return ResponseEntity.ok(BaseResponse.success(ticketService.issueTicket(request))); + return ResponseEntity.ok( + BaseResponse.success( + ticketService.issueTicket(request.getType(), request.getTargetId(), request.getUserId()) + ) + ); } @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 748fa73..90c5873 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 @@ -19,12 +19,6 @@ public static class IssueTicketRequest { @NotNull private TicketType type; @NotNull private Long targetId; - /** - * 일반적으로는 "발급 대상"을 명확히 해야 해서 필요합니다. - 사용자 본인에게 발급: userId 생략하고 서버에서 currentUser 사용 - 관리자가 특정 - * 유저에게 발급: userId를 받음 - * - *

지금은 "단순 생성/검증 로직"이 목표이므로 옵션으로 둡니다. - */ private Long userId; } diff --git a/src/main/java/com/sku/refit/domain/ticket/entity/TicketStatus.java b/src/main/java/com/sku/refit/domain/ticket/entity/TicketStatus.java deleted file mode 100644 index b4dcd9b..0000000 --- a/src/main/java/com/sku/refit/domain/ticket/entity/TicketStatus.java +++ /dev/null @@ -1,17 +0,0 @@ -/* - * Copyright (c) SKU 다시입을Lab - */ -package com.sku.refit.domain.ticket.entity; - -import io.swagger.v3.oas.annotations.media.Schema; - -public enum TicketStatus { - @Schema(description = "발급됨(미사용)") - ISSUED, - - @Schema(description = "사용됨 (체크인/검증 완료 처리 등)") - CONSUMED, - - @Schema(description = "만료") - EXPIRED -} 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 c17608d..4ce008c 100644 --- a/src/main/java/com/sku/refit/domain/ticket/mapper/TicketMapper.java +++ b/src/main/java/com/sku/refit/domain/ticket/mapper/TicketMapper.java @@ -3,6 +3,7 @@ */ package com.sku.refit.domain.ticket.mapper; +import com.sku.refit.domain.ticket.entity.TicketType; import org.springframework.stereotype.Component; import com.sku.refit.domain.ticket.dto.request.TicketRequest.IssueTicketRequest; @@ -19,16 +20,15 @@ public class TicketMapper { private final TicketQrPayloadFactory qrPayloadFactory; /** 발급 요청 + userId + token -> Ticket 엔티티 */ - public Ticket toEntity(IssueTicketRequest request, Long userId, String token) { + public Ticket toEntity(TicketType type, Long targetId, Long userId, String token) { return Ticket.builder() - .type(request.getType()) - .targetId(request.getTargetId()) + .type(type) + .targetId(targetId) .userId(userId) .token(token) .build(); } - /** 발급 응답 */ public TicketDetailResponse toDetail(Ticket ticket) { return TicketDetailResponse.builder() .ticketId(ticket.getId()) 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 739e316..c3a2c48 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,6 +3,7 @@ */ package com.sku.refit.domain.ticket.repository; +import jakarta.persistence.LockModeType; import java.util.List; import java.util.Optional; @@ -10,6 +11,9 @@ import com.sku.refit.domain.ticket.entity.Ticket; import com.sku.refit.domain.ticket.entity.TicketType; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface TicketRepository extends JpaRepository { @@ -19,4 +23,8 @@ public interface TicketRepository extends JpaRepository { List findAllByUserIdAndTypeAndUsedAtIsNotNullOrderByUsedAtDesc( Long userId, TicketType type); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT t FROM Ticket t WHERE t.token = :token") + Optional findByTokenForUpdate(@Param("token") String token); } 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 03d0bb1..cd20a20 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 com.sku.refit.domain.ticket.entity.TicketType; import java.util.List; import com.sku.refit.domain.ticket.dto.request.TicketRequest.*; @@ -38,7 +39,7 @@ public interface TicketService { * @param request 티켓 발급 요청 정보 (티켓 타입, 대상 ID, 발급 대상 사용자) * @return 발급된 티켓 상세 정보 (type, targetId, token, 발급 시각 등) */ - TicketDetailResponse issueTicket(IssueTicketRequest request); + TicketDetailResponse issueTicket(TicketType type, Long targetId, Long userId); /* ========================= * 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 7613871..ac60a4d 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 @@ -42,23 +42,29 @@ public class TicketServiceImpl implements TicketService { @Override @Transactional - public TicketDetailResponse issueTicket(IssueTicketRequest request) { + public TicketDetailResponse issueTicket(TicketType type, Long targetId, Long userId) { - validateIssueRequest(request); + if (type == null || targetId == null) { + throw new CustomException(TicketErrorCode.TICKET_BAD_REQUEST); + } - User user = userService.getCurrentUser(); - Long userId = user.getId(); + Long issueUserId; + if (userId != null) { + issueUserId = userId; + } else { + issueUserId = userService.getCurrentUser().getId(); + } String token; try { token = tokenGenerator.generate(); } catch (Exception e) { - log.error("[TICKET] issueTicket - token generation failed, userId={}", userId, e); + log.error("[TICKET] issueTicket - token generation failed, userId={}", issueUserId, e); throw new CustomException(TicketErrorCode.TICKET_TOKEN_GENERATION_FAILED); } try { - Ticket ticket = ticketMapper.toEntity(request, userId, token); + Ticket ticket = ticketMapper.toEntity(type, targetId, issueUserId, token); Ticket saved = ticketRepository.save(ticket); return ticketMapper.toDetail(saved); @@ -67,14 +73,11 @@ public TicketDetailResponse issueTicket(IssueTicketRequest request) { } catch (Exception e) { log.error( "[TICKET] issueTicket - save failed, userId={}, type={}, targetId={}", - userId, - request.getType(), - request.getTargetId(), - e); + issueUserId, type, targetId, e + ); throw new CustomException(TicketErrorCode.TICKET_ISSUE_FAILED); } } - /* ========================= * Verify * ========================= */ @@ -86,7 +89,7 @@ public VerifyTicketResponse verifyTicket(VerifyTicketRequest request) { try { return ticketRepository - .findByToken(request.getToken()) + .findByTokenForUpdate(request.getToken()) .map(ticketMapper::toVerifyFound) .orElseGet(ticketMapper::toVerifyNotFound); @@ -117,7 +120,7 @@ public ConsumeTicketResponse consumeTicket(ConsumeTicketRequest request) { boolean alreadyUsed = ticket.isUsed(); if (!alreadyUsed) { - ticket.consume(LocalDateTime.now()); // 멱등 처리 + ticket.consume(LocalDateTime.now()); } return ticketMapper.toConsume(ticket, !alreadyUsed); From df7943c746f137d7c104b2992e075484ac0e46df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=82=98=EA=B2=BD?= <1030n@naver.com> Date: Sat, 13 Dec 2025 09:02:41 +0900 Subject: [PATCH 4/5] =?UTF-8?q?:recycle:=20Refactor:=20spotless=20?= =?UTF-8?q?=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/ticket/controller/TicketControllerImpl.java | 5 ++--- .../sku/refit/domain/ticket/mapper/TicketMapper.java | 10 ++-------- .../domain/ticket/repository/TicketRepository.java | 9 +++++---- .../sku/refit/domain/ticket/service/TicketService.java | 2 +- .../refit/domain/ticket/service/TicketServiceImpl.java | 8 +++++--- 5 files changed, 15 insertions(+), 19 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 a5ea559..200d13c 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 @@ -29,9 +29,8 @@ public ResponseEntity> issueTicket( @RequestBody @Valid IssueTicketRequest request) { return ResponseEntity.ok( BaseResponse.success( - ticketService.issueTicket(request.getType(), request.getTargetId(), request.getUserId()) - ) - ); + ticketService.issueTicket( + request.getType(), request.getTargetId(), request.getUserId()))); } @Override 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 4ce008c..ca931c8 100644 --- a/src/main/java/com/sku/refit/domain/ticket/mapper/TicketMapper.java +++ b/src/main/java/com/sku/refit/domain/ticket/mapper/TicketMapper.java @@ -3,12 +3,11 @@ */ package com.sku.refit.domain.ticket.mapper; -import com.sku.refit.domain.ticket.entity.TicketType; import org.springframework.stereotype.Component; -import com.sku.refit.domain.ticket.dto.request.TicketRequest.IssueTicketRequest; import com.sku.refit.domain.ticket.dto.response.TicketResponse.*; 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; @@ -21,12 +20,7 @@ public class TicketMapper { /** 발급 요청 + 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 Ticket.builder().type(type).targetId(targetId).userId(userId).token(token).build(); } public TicketDetailResponse toDetail(Ticket ticket) { 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 c3a2c48..b6ceb64 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,18 +3,19 @@ */ package com.sku.refit.domain.ticket.repository; -import jakarta.persistence.LockModeType; import java.util.List; import java.util.Optional; -import org.springframework.data.jpa.repository.JpaRepository; +import jakarta.persistence.LockModeType; -import com.sku.refit.domain.ticket.entity.Ticket; -import com.sku.refit.domain.ticket.entity.TicketType; +import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; +import com.sku.refit.domain.ticket.entity.Ticket; +import com.sku.refit.domain.ticket.entity.TicketType; + public interface TicketRepository extends JpaRepository { Optional findByToken(String token); 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 cd20a20..ae53e57 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,11 +3,11 @@ */ package com.sku.refit.domain.ticket.service; -import com.sku.refit.domain.ticket.entity.TicketType; import java.util.List; import com.sku.refit.domain.ticket.dto.request.TicketRequest.*; import com.sku.refit.domain.ticket.dto.response.TicketResponse.*; +import com.sku.refit.domain.ticket.entity.TicketType; /** * 티켓(Ticket) 관련 주요 기능을 제공하는 서비스 인터페이스입니다. 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 ac60a4d..7cfefdb 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 @@ -17,7 +17,6 @@ import com.sku.refit.domain.ticket.mapper.TicketMapper; import com.sku.refit.domain.ticket.repository.TicketRepository; import com.sku.refit.domain.ticket.util.TicketTokenGenerator; -import com.sku.refit.domain.user.entity.User; import com.sku.refit.domain.user.service.UserService; import com.sku.refit.global.exception.CustomException; @@ -73,11 +72,14 @@ public TicketDetailResponse issueTicket(TicketType type, Long targetId, Long use } catch (Exception e) { log.error( "[TICKET] issueTicket - save failed, userId={}, type={}, targetId={}", - issueUserId, type, targetId, e - ); + issueUserId, + type, + targetId, + e); throw new CustomException(TicketErrorCode.TICKET_ISSUE_FAILED); } } + /* ========================= * Verify * ========================= */ From 943406e474740846fab0c1933b30efc96c5858de Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EB=82=98=EA=B2=BD?= <1030n@naver.com> Date: Sat, 13 Dec 2025 13:53:42 +0900 Subject: [PATCH 5/5] =?UTF-8?q?:recycle:=20Refactor:=20javadoc=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/sku/refit/domain/ticket/service/TicketService.java | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 ae53e57..13040d1 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 @@ -36,7 +36,9 @@ public interface TicketService { * *

생성된 토큰은 QR 코드로 사용자에게 전달되며, 이후 모든 검증/체크인 로직은 해당 토큰을 기준으로 수행됩니다. * - * @param request 티켓 발급 요청 정보 (티켓 타입, 대상 ID, 발급 대상 사용자) + * @param type 티켓 타입 + * @param targetId 대상 ID (EVENT/CLOTH의 식별자) + * @param userId (옵션) 관리자 발급 시 대상 사용자 ID, null이면 현재 사용자 * @return 발급된 티켓 상세 정보 (type, targetId, token, 발급 시각 등) */ TicketDetailResponse issueTicket(TicketType type, Long targetId, Long userId);