Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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<BaseResponse<TicketDetailResponse>> issueTicket(
@RequestBody @Valid IssueTicketRequest request);

@PostMapping("/admin/verify")
@Operation(summary = "[관리자] 티켓 검증", description = "token으로 티켓 유효/사용 여부를 확인합니다. (사용 처리 X)")
ResponseEntity<BaseResponse<VerifyTicketResponse>> verifyTicket(
@RequestBody @Valid VerifyTicketRequest request);

@PostMapping("/admin/consume")
@Operation(summary = "[관리자] 티켓 사용 처리", description = "token으로 티켓을 사용 처리합니다. (멱등 사용 처리)")
ResponseEntity<BaseResponse<ConsumeTicketResponse>> consumeTicket(
@RequestBody @Valid ConsumeTicketRequest request);

@GetMapping("/my/events")
@Operation(summary = "참가한 행사 조회", description = "사용자가 사용한 EVENT 티켓만 조회합니다.")
ResponseEntity<BaseResponse<List<MyTicketItemResponse>>> getMyUsedEventTickets();

@GetMapping("/my/cloth")
@Operation(summary = "교환 내역 조회", description = "사용자가 받았던 CLOTH 티켓을 모두 조회합니다.")
ResponseEntity<BaseResponse<List<MyTicketItemResponse>>> getMyClothTickets();
}
Original file line number Diff line number Diff line change
@@ -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<BaseResponse<TicketDetailResponse>> issueTicket(
@RequestBody @Valid IssueTicketRequest request) {
return ResponseEntity.ok(
BaseResponse.success(
ticketService.issueTicket(
request.getType(), request.getTargetId(), request.getUserId())));
}

@Override
public ResponseEntity<BaseResponse<VerifyTicketResponse>> verifyTicket(
@RequestBody @Valid VerifyTicketRequest request) {
return ResponseEntity.ok(BaseResponse.success(ticketService.verifyTicket(request)));
}

@Override
public ResponseEntity<BaseResponse<ConsumeTicketResponse>> consumeTicket(
@RequestBody @Valid ConsumeTicketRequest request) {
return ResponseEntity.ok(BaseResponse.success(ticketService.consumeTicket(request)));
}

@Override
public ResponseEntity<BaseResponse<List<MyTicketItemResponse>>> getMyUsedEventTickets() {
return ResponseEntity.ok(BaseResponse.success(ticketService.getMyTicketsUsedEvents()));
}

@Override
public ResponseEntity<BaseResponse<List<MyTicketItemResponse>>> getMyClothTickets() {
return ResponseEntity.ok(BaseResponse.success(ticketService.getMyClothTickets()));
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
Original file line number Diff line number Diff line change
@@ -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;
}
}
87 changes: 87 additions & 0 deletions src/main/java/com/sku/refit/domain/ticket/entity/Ticket.java
Original file line number Diff line number Diff line change
@@ -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은 필수입니다.");
}
}
}
14 changes: 14 additions & 0 deletions src/main/java/com/sku/refit/domain/ticket/entity/TicketType.java
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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;
}
Loading