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..e2991d3 --- /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 = "티켓 관련 API") +@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..200d13c --- /dev/null +++ b/src/main/java/com/sku/refit/domain/ticket/controller/TicketControllerImpl.java @@ -0,0 +1,57 @@ +/* + * 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.getType(), request.getTargetId(), request.getUserId()))); + } + + @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..90c5873 --- /dev/null +++ b/src/main/java/com/sku/refit/domain/ticket/dto/request/TicketRequest.java @@ -0,0 +1,38 @@ +/* + * 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; + + 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/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..ca931c8 --- /dev/null +++ b/src/main/java/com/sku/refit/domain/ticket/mapper/TicketMapper.java @@ -0,0 +1,81 @@ +/* + * Copyright (c) SKU 다시입을Lab + */ +package com.sku.refit.domain.ticket.mapper; + +import org.springframework.stereotype.Component; + +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; + +@Component +@RequiredArgsConstructor +public class TicketMapper { + + private final TicketQrPayloadFactory qrPayloadFactory; + + /** 발급 요청 + 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(); + } + + 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..b6ceb64 --- /dev/null +++ b/src/main/java/com/sku/refit/domain/ticket/repository/TicketRepository.java @@ -0,0 +1,31 @@ +/* + * Copyright (c) SKU 다시입을Lab + */ +package com.sku.refit.domain.ticket.repository; + +import java.util.List; +import java.util.Optional; + +import jakarta.persistence.LockModeType; + +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); + + List findAllByUserIdAndTypeOrderByCreatedAtDesc(Long userId, TicketType type); + + 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 new file mode 100644 index 0000000..13040d1 --- /dev/null +++ b/src/main/java/com/sku/refit/domain/ticket/service/TicketService.java @@ -0,0 +1,99 @@ +/* + * 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.*; +import com.sku.refit.domain.ticket.entity.TicketType; + +/** + * 티켓(Ticket) 관련 주요 기능을 제공하는 서비스 인터페이스입니다. + * + *

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

주요 기능: + * + *

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

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

생성된 토큰은 QR 코드로 사용자에게 전달되며, 이후 모든 검증/체크인 로직은 해당 토큰을 기준으로 수행됩니다. + * + * @param type 티켓 타입 + * @param targetId 대상 ID (EVENT/CLOTH의 식별자) + * @param userId (옵션) 관리자 발급 시 대상 사용자 ID, null이면 현재 사용자 + * @return 발급된 티켓 상세 정보 (type, targetId, token, 발급 시각 등) + */ + TicketDetailResponse issueTicket(TicketType type, Long targetId, Long userId); + + /* ========================= + * 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..7cfefdb --- /dev/null +++ b/src/main/java/com/sku/refit/domain/ticket/service/TicketServiceImpl.java @@ -0,0 +1,202 @@ +/* + * 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.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(TicketType type, Long targetId, Long userId) { + + if (type == null || targetId == null) { + throw new CustomException(TicketErrorCode.TICKET_BAD_REQUEST); + } + + 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={}", issueUserId, e); + throw new CustomException(TicketErrorCode.TICKET_TOKEN_GENERATION_FAILED); + } + + try { + Ticket ticket = ticketMapper.toEntity(type, targetId, issueUserId, 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={}", + issueUserId, + type, + targetId, + e); + throw new CustomException(TicketErrorCode.TICKET_ISSUE_FAILED); + } + } + + /* ========================= + * Verify + * ========================= */ + + @Override + public VerifyTicketResponse verifyTicket(VerifyTicketRequest request) { + + validateToken(request.getToken()); + + try { + return ticketRepository + .findByTokenForUpdate(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); + } +}