Skip to content

Commit 011e28f

Browse files
authored
feat: 티켓 양도기능 추가
* feat: 티켓 양도기능 추가 * feat: 티켓 양도기능 api 구현 * refactor: 불필요 주석, 로깅 제거 * feat: ticket 필드 추가에 따른 flyway마이그레이션파일 작성
1 parent 9c3b2c2 commit 011e28f

11 files changed

Lines changed: 118 additions & 18 deletions

File tree

backend/src/main/java/com/back/api/payment/order/service/OrderService.java

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,6 @@ public V2_Order v2_getOrderForPayment(String orderId, Long userId, Long clientAm
177177
}
178178

179179
if (!order.getAmount().equals(clientAmount)) {
180-
//TODO 금액 불일치 로직 : paymentService.amountNotEqual()
181180
throw new ErrorException(PaymentErrorCode.AMOUNT_VERIFICATION_FAILED);
182181
}
183182

backend/src/main/java/com/back/api/payment/payment/service/TossPaymentService.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,7 @@ public class TossPaymentService {
4747
*/
4848
@CircuitBreaker(name = CIRCUIT_BREAKER_NAME, fallbackMethod = "handleFailure")
4949
public TossPaymentResponse confirmPayment(V2_PaymentConfirmRequest request) {
50-
log.info("[Toss] 결제 승인 요청 - orderId: {}, amount: {}", request.orderId(), request.amount());
50+
// log.info("[Toss] 결제 승인 요청 - orderId: {}, amount: {}", request.orderId(), request.amount());
5151

5252
TossPaymentResponse response = tossRestClient.post()
5353
.uri("/v1/payments/confirm")
@@ -59,7 +59,7 @@ public TossPaymentResponse confirmPayment(V2_PaymentConfirmRequest request) {
5959
.retrieve()
6060
.body(TossPaymentResponse.class);
6161

62-
log.info("[Toss] 결제 승인 완료 - orderId: {}, status: {}", request.orderId(), response.status());
62+
// log.info("[Toss] 결제 승인 완료 - orderId: {}, status: {}", request.orderId(), response.status());
6363

6464
return response;
6565
}

backend/src/main/java/com/back/api/ticket/controller/TicketApi.java

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,11 @@
33
import java.util.List;
44

55
import org.springframework.web.bind.annotation.PathVariable;
6+
import org.springframework.web.bind.annotation.RequestBody;
67

8+
import com.back.api.ticket.dto.request.TransferRequest;
9+
10+
import jakarta.validation.Valid;
711
import com.back.api.ticket.dto.response.TicketResponse;
812
import com.back.global.config.swagger.ApiErrorCode;
913
import com.back.global.response.ApiResponse;
@@ -33,4 +37,22 @@ ApiResponse<TicketResponse> getMyTicketDetails(
3337
@Parameter(description = "조회할 티켓 ID", example = "1")
3438
@PathVariable Long ticketId
3539
);
40+
41+
@Operation(
42+
summary = "티켓 양도",
43+
description = "ISSUED 상태의 티켓을 다른 사용자에게 양도합니다. 티켓당 1회만 양도 가능합니다."
44+
)
45+
@ApiErrorCode({
46+
"TICKET_NOT_FOUND",
47+
"UNAUTHORIZED_TICKET_ACCESS",
48+
"TICKET_NOT_TRANSFERABLE",
49+
"TICKET_ALREADY_TRANSFERRED",
50+
"CANNOT_TRANSFER_TO_SELF",
51+
"TRANSFER_TARGET_NOT_FOUND"
52+
})
53+
ApiResponse<Void> transferTicket(
54+
@Parameter(description = "양도할 티켓 ID", example = "1")
55+
@PathVariable Long ticketId,
56+
@Valid @RequestBody TransferRequest request
57+
);
3658
}

backend/src/main/java/com/back/api/ticket/controller/TicketController.java

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,19 @@
44

55
import org.springframework.web.bind.annotation.GetMapping;
66
import org.springframework.web.bind.annotation.PathVariable;
7+
import org.springframework.web.bind.annotation.PostMapping;
8+
import org.springframework.web.bind.annotation.RequestBody;
79
import org.springframework.web.bind.annotation.RequestMapping;
810
import org.springframework.web.bind.annotation.RestController;
911

12+
import com.back.api.ticket.dto.request.TransferRequest;
1013
import com.back.api.ticket.dto.response.TicketResponse;
1114
import com.back.api.ticket.service.TicketService;
1215
import com.back.domain.ticket.entity.Ticket;
1316
import com.back.global.http.HttpRequestContext;
1417
import com.back.global.response.ApiResponse;
1518

19+
import jakarta.validation.Valid;
1620
import lombok.RequiredArgsConstructor;
1721

1822
@RestController
@@ -47,4 +51,17 @@ public ApiResponse<TicketResponse> getMyTicketDetails(
4751
TicketResponse.from(ticket)
4852
);
4953
}
54+
55+
@Override
56+
@PostMapping("/{ticketId}/transfer")
57+
public ApiResponse<Void> transferTicket(
58+
@PathVariable Long ticketId,
59+
@RequestBody @Valid TransferRequest request
60+
) {
61+
Long userId = httpRequestContext.getUserId();
62+
63+
ticketService.transferTicket(ticketId, userId, request.targetNickname());
64+
65+
return ApiResponse.noContent("티켓 양도 완료");
66+
}
5067
}
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package com.back.api.ticket.dto.request;
2+
3+
import io.swagger.v3.oas.annotations.media.Schema;
4+
import jakarta.validation.constraints.NotBlank;
5+
import jakarta.validation.constraints.Size;
6+
7+
@Schema(description = "티켓 양도 요청 DTO")
8+
public record TransferRequest(
9+
@NotBlank(message = "양도 대상 닉네임을 입력해주세요.")
10+
@Size(max = 20, message = "닉네임은 20자 이하여야 합니다.")
11+
@Schema(
12+
description = "양도 대상 사용자 닉네임",
13+
example = "friend123"
14+
)
15+
String targetNickname
16+
) {
17+
}

backend/src/main/java/com/back/api/ticket/service/TicketService.java

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -206,4 +206,20 @@ public void releaseDraftTicketAndSeat(Long eventId, Long userId) {
206206
);
207207
}
208208
}
209+
210+
@Transactional
211+
public void transferTicket(Long ticketId, Long userId, String targetNickname) {
212+
// 비관락으로 조회
213+
Ticket ticket = ticketRepository.findByIdForUpdate(ticketId)
214+
.orElseThrow(() -> new ErrorException(TicketErrorCode.TICKET_NOT_FOUND));
215+
216+
if (!ticket.getOwner().getId().equals(userId)) {
217+
throw new ErrorException(TicketErrorCode.UNAUTHORIZED_TICKET_ACCESS);
218+
}
219+
220+
User target = userRepository.findByNickname(targetNickname)
221+
.orElseThrow(() -> new ErrorException(TicketErrorCode.TRANSFER_TARGET_NOT_FOUND));
222+
223+
ticket.transferTo(target);
224+
}
209225
}

backend/src/main/java/com/back/domain/ticket/entity/Ticket.java

Lines changed: 20 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -64,14 +64,26 @@ public class Ticket extends BaseEntity {
6464
@Column(name = "used_at")
6565
private LocalDateTime usedAt;
6666

67-
public static Ticket issue(User owner, Seat seat, Event event, String verificationHash) {
68-
Ticket ticket = new Ticket();
69-
ticket.owner = owner;
70-
ticket.seat = seat;
71-
ticket.event = event;
72-
ticket.ticketStatus = TicketStatus.ISSUED;
73-
ticket.issuedAt = LocalDateTime.now();
74-
return ticket;
67+
@Builder.Default
68+
@Column(name = "transferred", nullable = false)
69+
private boolean transferred = false;
70+
71+
public void transferTo(User newOwner) {
72+
// 상태 검증
73+
if (this.ticketStatus != TicketStatus.ISSUED) {
74+
throw new ErrorException(TicketErrorCode.TICKET_NOT_TRANSFERABLE);
75+
}
76+
// 양도 1회 제한
77+
if (this.transferred) {
78+
throw new ErrorException(TicketErrorCode.TICKET_ALREADY_TRANSFERRED);
79+
}
80+
// 자기 자신 방지
81+
if (this.owner.getId().equals(newOwner.getId())) {
82+
throw new ErrorException(TicketErrorCode.CANNOT_TRANSFER_TO_SELF);
83+
}
84+
85+
this.owner = newOwner;
86+
this.transferred = true;
7587
}
7688

7789
/**

backend/src/main/java/com/back/domain/ticket/repository/TicketRepository.java

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -7,22 +7,23 @@
77
import org.springframework.data.domain.Page;
88
import org.springframework.data.domain.Pageable;
99
import org.springframework.data.jpa.repository.JpaRepository;
10+
import org.springframework.data.jpa.repository.Lock;
1011
import org.springframework.data.jpa.repository.Query;
1112
import org.springframework.data.repository.query.Param;
1213

1314
import com.back.domain.ticket.entity.Ticket;
1415
import com.back.domain.ticket.entity.TicketStatus;
1516

17+
import jakarta.persistence.LockModeType;
18+
1619
public interface TicketRepository extends JpaRepository<Ticket, Long>, TicketRepositoryCustom {
1720

1821
List<Ticket> findByOwnerId(Long userId);
1922

20-
boolean existsBySeatIdAndTicketStatus(Long seatId, TicketStatus status);
21-
22-
boolean existsBySeatIdAndTicketStatusIn(Long seatId, List<TicketStatus> paid);
23+
@Lock(LockModeType.PESSIMISTIC_WRITE)
24+
@Query("SELECT t FROM Ticket t JOIN FETCH t.owner WHERE t.id = :ticketId")
25+
Optional<Ticket> findByIdForUpdate(@Param("ticketId") Long ticketId);
2326

24-
boolean existsByEventIdAndOwnerIdAndTicketStatusIn(Long eventId, Long userId, List<TicketStatus> statuses);
25-
2627
@Query(
2728
value = """
2829
SELECT t
@@ -52,5 +53,5 @@ Page<Ticket> findExpiredDraftTickets(
5253
+ "LEFT JOIN FETCH t.event e "
5354
+ "LEFT JOIN FETCH t.seat s "
5455
+ "WHERE t.id = :ticketId")
55-
Optional <Ticket> findByIdWithDetails(@Param("ticketId") Long ticketId);
56+
Optional<Ticket> findByIdWithDetails(@Param("ticketId") Long ticketId);
5657
}

backend/src/main/java/com/back/domain/user/repository/UserRepository.java

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,4 +21,6 @@ public interface UserRepository extends JpaRepository<User, Long> {
2121
Optional<User> findIncludingDeletedById(@Param("id") Long id);
2222

2323
Optional<User> findByProviderId(String providerId);
24+
25+
Optional<User> findByNickname(String nickname);
2426
}

backend/src/main/java/com/back/global/error/code/TicketErrorCode.java

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,11 @@ public enum TicketErrorCode implements ErrorCode {
2020
INVALID_TICKET_QR_TOKEN(HttpStatus.BAD_REQUEST, "유효하지 않은 티켓 QR 토큰입니다."),
2121
TICKET_ALREADY_USED(HttpStatus.BAD_REQUEST, "이미 사용된 티켓입니다."),
2222
EVENT_NOT_STARTED(HttpStatus.BAD_REQUEST, "이벤트 시작 전에는 QR이 발급되지 않습니다."),
23-
TICKET_ALREADY_ENTERED(HttpStatus.BAD_REQUEST, "이미 입장처 티켓입니다.");
23+
TICKET_ALREADY_ENTERED(HttpStatus.BAD_REQUEST, "이미 입장처 티켓입니다."),
24+
TICKET_NOT_TRANSFERABLE(HttpStatus.BAD_REQUEST, "아직 발급되지 않은 티켓입니다. 양도가 불가합니다."),
25+
TICKET_ALREADY_TRANSFERRED(HttpStatus.BAD_REQUEST, "양도는 티켓당 1회만 가능합니다. 이미 양도가 발생한 티켓입니다."),
26+
CANNOT_TRANSFER_TO_SELF(HttpStatus.BAD_REQUEST, "자기자신에게 양도는 불가합니다."),
27+
TRANSFER_TARGET_NOT_FOUND(HttpStatus.BAD_REQUEST, "양도 대상 유저를 찾을 수 없습니다.");
2428

2529
private final HttpStatus httpStatus;
2630
private final String message;

0 commit comments

Comments
 (0)