feat: implement reservation MVP backend
This commit is contained in:
parent
85a6f1fbbc
commit
6fda098137
48 changed files with 1974 additions and 39 deletions
|
|
@ -9,5 +9,4 @@ public class DemoApplication {
|
||||||
public static void main(String[] args) {
|
public static void main(String[] args) {
|
||||||
SpringApplication.run(DemoApplication.class, args);
|
SpringApplication.run(DemoApplication.class, args);
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,32 @@
|
||||||
|
package com.reservation.demo.api.auth;
|
||||||
|
|
||||||
|
import com.reservation.demo.api.auth.AuthDtos.AuthResponse;
|
||||||
|
import com.reservation.demo.api.auth.AuthDtos.LoginRequest;
|
||||||
|
import com.reservation.demo.api.auth.AuthDtos.SignupRequest;
|
||||||
|
import com.reservation.demo.service.AuthService;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/auth")
|
||||||
|
public class AuthController {
|
||||||
|
|
||||||
|
private final AuthService authService;
|
||||||
|
|
||||||
|
public AuthController(AuthService authService) {
|
||||||
|
this.authService = authService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/signup")
|
||||||
|
public AuthResponse signup(@Valid @RequestBody SignupRequest request) {
|
||||||
|
return authService.signup(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/login")
|
||||||
|
public AuthResponse login(@Valid @RequestBody LoginRequest request) {
|
||||||
|
return authService.login(request);
|
||||||
|
}
|
||||||
|
}
|
||||||
36
src/main/java/com/reservation/demo/api/auth/AuthDtos.java
Normal file
36
src/main/java/com/reservation/demo/api/auth/AuthDtos.java
Normal file
|
|
@ -0,0 +1,36 @@
|
||||||
|
package com.reservation.demo.api.auth;
|
||||||
|
|
||||||
|
import com.reservation.demo.domain.user.Role;
|
||||||
|
import jakarta.validation.constraints.Email;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
|
||||||
|
public final class AuthDtos {
|
||||||
|
|
||||||
|
private AuthDtos() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record SignupRequest(
|
||||||
|
@Email @NotBlank String email,
|
||||||
|
@NotBlank @Size(min = 8, max = 100) String password,
|
||||||
|
@NotBlank @Size(max = 100) String name,
|
||||||
|
@NotNull Role role
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record LoginRequest(
|
||||||
|
@Email @NotBlank String email,
|
||||||
|
@NotBlank String password
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record AuthResponse(
|
||||||
|
Long userId,
|
||||||
|
String email,
|
||||||
|
String name,
|
||||||
|
Role role,
|
||||||
|
String accessToken
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,37 @@
|
||||||
|
package com.reservation.demo.api.concert;
|
||||||
|
|
||||||
|
import com.reservation.demo.api.concert.ConcertDtos.ConcertDetailResponse;
|
||||||
|
import com.reservation.demo.api.concert.ConcertDtos.ConcertSummaryResponse;
|
||||||
|
import com.reservation.demo.api.concert.ConcertDtos.SeatMapResponse;
|
||||||
|
import com.reservation.demo.service.ConcertService;
|
||||||
|
import java.util.List;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/concerts")
|
||||||
|
public class ConcertController {
|
||||||
|
|
||||||
|
private final ConcertService concertService;
|
||||||
|
|
||||||
|
public ConcertController(ConcertService concertService) {
|
||||||
|
this.concertService = concertService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public List<ConcertSummaryResponse> getConcerts() {
|
||||||
|
return concertService.getConcerts();
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/{concertId}")
|
||||||
|
public ConcertDetailResponse getConcert(@PathVariable Long concertId) {
|
||||||
|
return concertService.getConcertDetail(concertId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/performances/{performanceId}/seats")
|
||||||
|
public SeatMapResponse getSeatMap(@PathVariable Long performanceId) {
|
||||||
|
return concertService.getSeatMap(performanceId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
package com.reservation.demo.api.concert;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public final class ConcertDtos {
|
||||||
|
|
||||||
|
private ConcertDtos() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record ConcertSummaryResponse(
|
||||||
|
Long id,
|
||||||
|
String title,
|
||||||
|
String venue,
|
||||||
|
String organizerName
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record PerformanceResponse(
|
||||||
|
Long id,
|
||||||
|
LocalDateTime performanceAt
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record ConcertDetailResponse(
|
||||||
|
Long id,
|
||||||
|
String title,
|
||||||
|
String venue,
|
||||||
|
String description,
|
||||||
|
String organizerName,
|
||||||
|
List<PerformanceResponse> performances
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record SeatMapItemResponse(
|
||||||
|
Long seatInventoryId,
|
||||||
|
Long seatId,
|
||||||
|
String section,
|
||||||
|
String rowLabel,
|
||||||
|
Integer seatNumber,
|
||||||
|
String status
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record SeatMapResponse(
|
||||||
|
Long performanceId,
|
||||||
|
Long concertId,
|
||||||
|
String concertTitle,
|
||||||
|
LocalDateTime performanceAt,
|
||||||
|
List<SeatMapItemResponse> seats
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
package com.reservation.demo.api.organizer;
|
||||||
|
|
||||||
|
import com.reservation.demo.api.organizer.OrganizerDtos.CreateConcertRequest;
|
||||||
|
import com.reservation.demo.api.organizer.OrganizerDtos.CreateConcertResponse;
|
||||||
|
import com.reservation.demo.common.security.SecurityUtils;
|
||||||
|
import com.reservation.demo.service.OrganizerService;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/organizer")
|
||||||
|
public class OrganizerController {
|
||||||
|
|
||||||
|
private final OrganizerService organizerService;
|
||||||
|
|
||||||
|
public OrganizerController(OrganizerService organizerService) {
|
||||||
|
this.organizerService = organizerService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/concerts")
|
||||||
|
public CreateConcertResponse createConcert(@Valid @RequestBody CreateConcertRequest request) {
|
||||||
|
Long organizerId = SecurityUtils.getCurrentUserId();
|
||||||
|
return organizerService.createConcert(organizerId, request);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
package com.reservation.demo.api.organizer;
|
||||||
|
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import jakarta.validation.constraints.Future;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.NotEmpty;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import jakarta.validation.constraints.Positive;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public final class OrganizerDtos {
|
||||||
|
|
||||||
|
private OrganizerDtos() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record SeatTemplateRequest(
|
||||||
|
@NotBlank String section,
|
||||||
|
@NotBlank String rowLabel,
|
||||||
|
@NotNull @Positive Integer seatNumber
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record PerformanceTemplateRequest(
|
||||||
|
@NotNull @Future LocalDateTime performanceAt
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record CreateConcertRequest(
|
||||||
|
@NotBlank String title,
|
||||||
|
@NotBlank String venue,
|
||||||
|
String description,
|
||||||
|
@NotEmpty @Valid List<PerformanceTemplateRequest> performances,
|
||||||
|
@NotEmpty @Valid List<SeatTemplateRequest> seats
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record CreateConcertResponse(
|
||||||
|
Long concertId,
|
||||||
|
int performanceCount,
|
||||||
|
int seatCount
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,43 @@
|
||||||
|
package com.reservation.demo.api.reservation;
|
||||||
|
|
||||||
|
import com.reservation.demo.api.reservation.ReservationDtos.CreateReservationRequest;
|
||||||
|
import com.reservation.demo.api.reservation.ReservationDtos.ReservationResponse;
|
||||||
|
import com.reservation.demo.common.security.SecurityUtils;
|
||||||
|
import com.reservation.demo.service.ReservationService;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import java.util.List;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/v1/reservations")
|
||||||
|
public class ReservationController {
|
||||||
|
|
||||||
|
private final ReservationService reservationService;
|
||||||
|
|
||||||
|
public ReservationController(ReservationService reservationService) {
|
||||||
|
this.reservationService = reservationService;
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
public ReservationResponse createReservation(@Valid @RequestBody CreateReservationRequest request) {
|
||||||
|
Long userId = SecurityUtils.getCurrentUserId();
|
||||||
|
return reservationService.createReservation(userId, request);
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/{reservationId}/cancel")
|
||||||
|
public ReservationResponse cancelReservation(@PathVariable Long reservationId) {
|
||||||
|
Long userId = SecurityUtils.getCurrentUserId();
|
||||||
|
return reservationService.cancelReservation(userId, reservationId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/me")
|
||||||
|
public List<ReservationResponse> getMyReservations() {
|
||||||
|
Long userId = SecurityUtils.getCurrentUserId();
|
||||||
|
return reservationService.getMyReservations(userId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
package com.reservation.demo.api.reservation;
|
||||||
|
|
||||||
|
import com.reservation.demo.domain.reservation.ReservationStatus;
|
||||||
|
import jakarta.validation.constraints.NotEmpty;
|
||||||
|
import jakarta.validation.constraints.NotNull;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
public final class ReservationDtos {
|
||||||
|
|
||||||
|
private ReservationDtos() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record CreateReservationRequest(
|
||||||
|
@NotNull Long performanceId,
|
||||||
|
@NotEmpty List<Long> seatInventoryIds
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record ReservationSeatResponse(
|
||||||
|
Long seatInventoryId,
|
||||||
|
String section,
|
||||||
|
String rowLabel,
|
||||||
|
Integer seatNumber,
|
||||||
|
Long price
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
public record ReservationResponse(
|
||||||
|
Long reservationId,
|
||||||
|
Long performanceId,
|
||||||
|
String concertTitle,
|
||||||
|
LocalDateTime performanceAt,
|
||||||
|
ReservationStatus status,
|
||||||
|
Long totalAmount,
|
||||||
|
LocalDateTime createdAt,
|
||||||
|
List<ReservationSeatResponse> seats
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,19 @@
|
||||||
|
package com.reservation.demo.common.exception;
|
||||||
|
|
||||||
|
import lombok.Getter;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
public class BusinessException extends RuntimeException {
|
||||||
|
|
||||||
|
private final ErrorCode errorCode;
|
||||||
|
|
||||||
|
public BusinessException(ErrorCode errorCode) {
|
||||||
|
super(errorCode.getMessage());
|
||||||
|
this.errorCode = errorCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BusinessException(ErrorCode errorCode, String message) {
|
||||||
|
super(message);
|
||||||
|
this.errorCode = errorCode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
package com.reservation.demo.common.exception;
|
||||||
|
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public enum ErrorCode {
|
||||||
|
|
||||||
|
INVALID_REQUEST(HttpStatus.BAD_REQUEST, "잘못된 요청입니다."),
|
||||||
|
UNAUTHORIZED(HttpStatus.UNAUTHORIZED, "인증이 필요합니다."),
|
||||||
|
FORBIDDEN(HttpStatus.FORBIDDEN, "권한이 없습니다."),
|
||||||
|
NOT_FOUND(HttpStatus.NOT_FOUND, "리소스를 찾을 수 없습니다."),
|
||||||
|
DUPLICATE_EMAIL(HttpStatus.CONFLICT, "이미 사용 중인 이메일입니다."),
|
||||||
|
SEAT_NOT_AVAILABLE(HttpStatus.CONFLICT, "선택한 좌석을 예약할 수 없습니다."),
|
||||||
|
INTERNAL_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "서버 오류가 발생했습니다.");
|
||||||
|
|
||||||
|
private final HttpStatus status;
|
||||||
|
private final String message;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,28 @@
|
||||||
|
package com.reservation.demo.common.exception;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
public record ErrorResponse(
|
||||||
|
LocalDateTime timestamp,
|
||||||
|
int status,
|
||||||
|
String code,
|
||||||
|
String message
|
||||||
|
) {
|
||||||
|
public static ErrorResponse of(ErrorCode errorCode) {
|
||||||
|
return new ErrorResponse(
|
||||||
|
LocalDateTime.now(),
|
||||||
|
errorCode.getStatus().value(),
|
||||||
|
errorCode.name(),
|
||||||
|
errorCode.getMessage()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static ErrorResponse of(ErrorCode errorCode, String message) {
|
||||||
|
return new ErrorResponse(
|
||||||
|
LocalDateTime.now(),
|
||||||
|
errorCode.getStatus().value(),
|
||||||
|
errorCode.name(),
|
||||||
|
message
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
package com.reservation.demo.common.exception;
|
||||||
|
|
||||||
|
import org.slf4j.Logger;
|
||||||
|
import org.slf4j.LoggerFactory;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.validation.FieldError;
|
||||||
|
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||||
|
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||||
|
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||||
|
|
||||||
|
@RestControllerAdvice
|
||||||
|
public class GlobalExceptionHandler {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
|
||||||
|
|
||||||
|
@ExceptionHandler(BusinessException.class)
|
||||||
|
public ResponseEntity<ErrorResponse> handleBusinessException(BusinessException exception) {
|
||||||
|
ErrorCode errorCode = exception.getErrorCode();
|
||||||
|
return ResponseEntity
|
||||||
|
.status(errorCode.getStatus())
|
||||||
|
.body(ErrorResponse.of(errorCode, exception.getMessage()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||||
|
public ResponseEntity<ErrorResponse> handleValidationException(MethodArgumentNotValidException exception) {
|
||||||
|
String message = exception.getBindingResult().getAllErrors().stream()
|
||||||
|
.findFirst()
|
||||||
|
.map(error -> {
|
||||||
|
if (error instanceof FieldError fieldError) {
|
||||||
|
return fieldError.getField() + ": " + fieldError.getDefaultMessage();
|
||||||
|
}
|
||||||
|
return error.getDefaultMessage();
|
||||||
|
})
|
||||||
|
.orElse(ErrorCode.INVALID_REQUEST.getMessage());
|
||||||
|
|
||||||
|
return ResponseEntity
|
||||||
|
.badRequest()
|
||||||
|
.body(ErrorResponse.of(ErrorCode.INVALID_REQUEST, message));
|
||||||
|
}
|
||||||
|
|
||||||
|
@ExceptionHandler(Exception.class)
|
||||||
|
public ResponseEntity<ErrorResponse> handleException(Exception exception) {
|
||||||
|
log.error("Unhandled exception", exception);
|
||||||
|
return ResponseEntity
|
||||||
|
.internalServerError()
|
||||||
|
.body(ErrorResponse.of(ErrorCode.INTERNAL_ERROR));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
package com.reservation.demo.common.security;
|
||||||
|
|
||||||
|
import com.reservation.demo.common.exception.BusinessException;
|
||||||
|
import com.reservation.demo.common.exception.ErrorCode;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
|
|
||||||
|
public final class SecurityUtils {
|
||||||
|
|
||||||
|
private SecurityUtils() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public static Long getCurrentUserId() {
|
||||||
|
Authentication authentication = SecurityContextHolder.getContext().getAuthentication();
|
||||||
|
if (authentication == null || authentication.getPrincipal() == null) {
|
||||||
|
throw new BusinessException(ErrorCode.UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (Long) authentication.getPrincipal();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,53 @@
|
||||||
|
package com.reservation.demo.config;
|
||||||
|
|
||||||
|
import com.reservation.demo.domain.user.Role;
|
||||||
|
import jakarta.servlet.FilterChain;
|
||||||
|
import jakarta.servlet.ServletException;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import java.io.IOException;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||||
|
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
|
import org.springframework.security.oauth2.jwt.JwtException;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.filter.OncePerRequestFilter;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||||
|
|
||||||
|
private final JwtTokenProvider jwtTokenProvider;
|
||||||
|
|
||||||
|
public JwtAuthenticationFilter(JwtTokenProvider jwtTokenProvider) {
|
||||||
|
this.jwtTokenProvider = jwtTokenProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void doFilterInternal(
|
||||||
|
HttpServletRequest request,
|
||||||
|
HttpServletResponse response,
|
||||||
|
FilterChain filterChain
|
||||||
|
) throws ServletException, IOException {
|
||||||
|
String authorization = request.getHeader(HttpHeaders.AUTHORIZATION);
|
||||||
|
|
||||||
|
if (authorization != null && authorization.startsWith("Bearer ")) {
|
||||||
|
try {
|
||||||
|
String token = authorization.substring(7);
|
||||||
|
Long userId = jwtTokenProvider.getUserId(token);
|
||||||
|
Role role = jwtTokenProvider.getRole(token);
|
||||||
|
|
||||||
|
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
|
||||||
|
userId,
|
||||||
|
null,
|
||||||
|
java.util.List.of(new SimpleGrantedAuthority("ROLE_" + role.name()))
|
||||||
|
);
|
||||||
|
SecurityContextHolder.getContext().setAuthentication(authentication);
|
||||||
|
} catch (JwtException ex) {
|
||||||
|
SecurityContextHolder.clearContext();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
filterChain.doFilter(request, response);
|
||||||
|
}
|
||||||
|
}
|
||||||
34
src/main/java/com/reservation/demo/config/JwtConfig.java
Normal file
34
src/main/java/com/reservation/demo/config/JwtConfig.java
Normal file
|
|
@ -0,0 +1,34 @@
|
||||||
|
package com.reservation.demo.config;
|
||||||
|
|
||||||
|
import com.nimbusds.jose.jwk.source.ImmutableSecret;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import javax.crypto.SecretKey;
|
||||||
|
import javax.crypto.spec.SecretKeySpec;
|
||||||
|
import org.springframework.boot.context.properties.EnableConfigurationProperties;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.security.oauth2.jwt.JwtDecoder;
|
||||||
|
import org.springframework.security.oauth2.jwt.JwtEncoder;
|
||||||
|
import org.springframework.security.oauth2.jwt.NimbusJwtDecoder;
|
||||||
|
import org.springframework.security.oauth2.jwt.NimbusJwtEncoder;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
@EnableConfigurationProperties(JwtProperties.class)
|
||||||
|
public class JwtConfig {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
SecretKey jwtSecretKey(JwtProperties jwtProperties) {
|
||||||
|
byte[] keyBytes = jwtProperties.secret().getBytes(StandardCharsets.UTF_8);
|
||||||
|
return new SecretKeySpec(keyBytes, "HmacSHA256");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
JwtEncoder jwtEncoder(SecretKey jwtSecretKey) {
|
||||||
|
return new NimbusJwtEncoder(new ImmutableSecret<>(jwtSecretKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
JwtDecoder jwtDecoder(SecretKey jwtSecretKey) {
|
||||||
|
return NimbusJwtDecoder.withSecretKey(jwtSecretKey).build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
package com.reservation.demo.config;
|
||||||
|
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
|
||||||
|
@ConfigurationProperties(prefix = "app.jwt")
|
||||||
|
public record JwtProperties(String secret, long expirationMs) {
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,52 @@
|
||||||
|
package com.reservation.demo.config;
|
||||||
|
|
||||||
|
import com.reservation.demo.domain.user.Role;
|
||||||
|
import java.time.Instant;
|
||||||
|
import org.springframework.security.oauth2.jose.jws.MacAlgorithm;
|
||||||
|
import org.springframework.security.oauth2.jwt.JwsHeader;
|
||||||
|
import org.springframework.security.oauth2.jwt.Jwt;
|
||||||
|
import org.springframework.security.oauth2.jwt.JwtClaimsSet;
|
||||||
|
import org.springframework.security.oauth2.jwt.JwtDecoder;
|
||||||
|
import org.springframework.security.oauth2.jwt.JwtEncoder;
|
||||||
|
import org.springframework.security.oauth2.jwt.JwtEncoderParameters;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
public class JwtTokenProvider {
|
||||||
|
|
||||||
|
private final JwtEncoder jwtEncoder;
|
||||||
|
private final JwtDecoder jwtDecoder;
|
||||||
|
private final long expirationMs;
|
||||||
|
|
||||||
|
public JwtTokenProvider(JwtEncoder jwtEncoder, JwtDecoder jwtDecoder, JwtProperties jwtProperties) {
|
||||||
|
this.jwtEncoder = jwtEncoder;
|
||||||
|
this.jwtDecoder = jwtDecoder;
|
||||||
|
this.expirationMs = jwtProperties.expirationMs();
|
||||||
|
}
|
||||||
|
|
||||||
|
public String createToken(Long userId, String email, Role role) {
|
||||||
|
Instant now = Instant.now();
|
||||||
|
JwtClaimsSet claims = JwtClaimsSet.builder()
|
||||||
|
.subject(String.valueOf(userId))
|
||||||
|
.claim("email", email)
|
||||||
|
.claim("role", role.name())
|
||||||
|
.issuedAt(now)
|
||||||
|
.expiresAt(now.plusMillis(expirationMs))
|
||||||
|
.build();
|
||||||
|
|
||||||
|
JwsHeader header = JwsHeader.with(MacAlgorithm.HS256).build();
|
||||||
|
return jwtEncoder.encode(JwtEncoderParameters.from(header, claims)).getTokenValue();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getUserId(String token) {
|
||||||
|
return Long.parseLong(decode(token).getSubject());
|
||||||
|
}
|
||||||
|
|
||||||
|
public Role getRole(String token) {
|
||||||
|
return Role.valueOf(decode(token).getClaim("role"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private Jwt decode(String token) {
|
||||||
|
return jwtDecoder.decode(token);
|
||||||
|
}
|
||||||
|
}
|
||||||
30
src/main/java/com/reservation/demo/config/OpenApiConfig.java
Normal file
30
src/main/java/com/reservation/demo/config/OpenApiConfig.java
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
package com.reservation.demo.config;
|
||||||
|
|
||||||
|
import io.swagger.v3.oas.models.Components;
|
||||||
|
import io.swagger.v3.oas.models.OpenAPI;
|
||||||
|
import io.swagger.v3.oas.models.info.Info;
|
||||||
|
import io.swagger.v3.oas.models.security.SecurityRequirement;
|
||||||
|
import io.swagger.v3.oas.models.security.SecurityScheme;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
public class OpenApiConfig {
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public OpenAPI openAPI() {
|
||||||
|
String schemeName = "bearerAuth";
|
||||||
|
return new OpenAPI()
|
||||||
|
.info(new Info()
|
||||||
|
.title("Reservation Demo API")
|
||||||
|
.description("reservation-demo — 공연 좌석 예약 API")
|
||||||
|
.version("0.1.0"))
|
||||||
|
.addSecurityItem(new SecurityRequirement().addList(schemeName))
|
||||||
|
.components(new Components()
|
||||||
|
.addSecuritySchemes(schemeName, new SecurityScheme()
|
||||||
|
.name(schemeName)
|
||||||
|
.type(SecurityScheme.Type.HTTP)
|
||||||
|
.scheme("bearer")
|
||||||
|
.bearerFormat("JWT")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,51 @@
|
||||||
|
package com.reservation.demo.config;
|
||||||
|
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.http.HttpMethod;
|
||||||
|
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||||
|
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||||
|
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
|
||||||
|
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||||
|
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
import org.springframework.security.web.SecurityFilterChain;
|
||||||
|
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||||
|
|
||||||
|
@Configuration
|
||||||
|
@EnableWebSecurity
|
||||||
|
public class SecurityConfig {
|
||||||
|
|
||||||
|
private final JwtAuthenticationFilter jwtAuthenticationFilter;
|
||||||
|
|
||||||
|
public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) {
|
||||||
|
this.jwtAuthenticationFilter = jwtAuthenticationFilter;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
||||||
|
http
|
||||||
|
.csrf(AbstractHttpConfigurer::disable)
|
||||||
|
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||||
|
.authorizeHttpRequests(auth -> auth
|
||||||
|
.requestMatchers(
|
||||||
|
"/api/v1/auth/**",
|
||||||
|
"/swagger-ui/**",
|
||||||
|
"/swagger-ui.html",
|
||||||
|
"/api-docs/**"
|
||||||
|
).permitAll()
|
||||||
|
.requestMatchers(HttpMethod.GET, "/api/v1/concerts/**").permitAll()
|
||||||
|
.requestMatchers("/api/v1/organizer/**").hasRole("ORGANIZER")
|
||||||
|
.requestMatchers("/api/v1/reservations/**").hasRole("USER")
|
||||||
|
.anyRequest().authenticated()
|
||||||
|
)
|
||||||
|
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
|
||||||
|
|
||||||
|
return http.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public PasswordEncoder passwordEncoder() {
|
||||||
|
return new BCryptPasswordEncoder();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,58 @@
|
||||||
|
package com.reservation.demo.domain.concert;
|
||||||
|
|
||||||
|
import com.reservation.demo.domain.user.User;
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.FetchType;
|
||||||
|
import jakarta.persistence.GeneratedValue;
|
||||||
|
import jakarta.persistence.GenerationType;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.JoinColumn;
|
||||||
|
import jakarta.persistence.ManyToOne;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import lombok.AccessLevel;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@NoArgsConstructor(access = AccessLevel.PROTECTED)
|
||||||
|
@Entity
|
||||||
|
@Table(name = "concerts")
|
||||||
|
public class Concert {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||||
|
@JoinColumn(name = "organizer_id", nullable = false)
|
||||||
|
private User organizer;
|
||||||
|
|
||||||
|
@Column(nullable = false, length = 200)
|
||||||
|
private String title;
|
||||||
|
|
||||||
|
@Column(nullable = false, length = 200)
|
||||||
|
private String venue;
|
||||||
|
|
||||||
|
@Column(columnDefinition = "TEXT")
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
@Builder
|
||||||
|
public Concert(User organizer, String title, String venue, String description) {
|
||||||
|
this.organizer = organizer;
|
||||||
|
this.title = title;
|
||||||
|
this.venue = venue;
|
||||||
|
this.description = description;
|
||||||
|
this.createdAt = LocalDateTime.now();
|
||||||
|
this.updatedAt = LocalDateTime.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
package com.reservation.demo.domain.concert;
|
||||||
|
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.FetchType;
|
||||||
|
import jakarta.persistence.GeneratedValue;
|
||||||
|
import jakarta.persistence.GenerationType;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.JoinColumn;
|
||||||
|
import jakarta.persistence.ManyToOne;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import lombok.AccessLevel;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@NoArgsConstructor(access = AccessLevel.PROTECTED)
|
||||||
|
@Entity
|
||||||
|
@Table(name = "performances")
|
||||||
|
public class Performance {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||||
|
@JoinColumn(name = "concert_id", nullable = false)
|
||||||
|
private Concert concert;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private LocalDateTime performanceAt;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
@Builder
|
||||||
|
public Performance(Concert concert, LocalDateTime performanceAt) {
|
||||||
|
this.concert = concert;
|
||||||
|
this.performanceAt = performanceAt;
|
||||||
|
this.createdAt = LocalDateTime.now();
|
||||||
|
this.updatedAt = LocalDateTime.now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,82 @@
|
||||||
|
package com.reservation.demo.domain.reservation;
|
||||||
|
|
||||||
|
import com.reservation.demo.domain.concert.Performance;
|
||||||
|
import com.reservation.demo.domain.user.User;
|
||||||
|
import jakarta.persistence.CascadeType;
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.EnumType;
|
||||||
|
import jakarta.persistence.Enumerated;
|
||||||
|
import jakarta.persistence.FetchType;
|
||||||
|
import jakarta.persistence.GeneratedValue;
|
||||||
|
import jakarta.persistence.GenerationType;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.JoinColumn;
|
||||||
|
import jakarta.persistence.ManyToOne;
|
||||||
|
import jakarta.persistence.OneToMany;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import lombok.AccessLevel;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@NoArgsConstructor(access = AccessLevel.PROTECTED)
|
||||||
|
@Entity
|
||||||
|
@Table(name = "reservations")
|
||||||
|
public class Reservation {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||||
|
@JoinColumn(name = "user_id", nullable = false)
|
||||||
|
private User user;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||||
|
@JoinColumn(name = "performance_id", nullable = false)
|
||||||
|
private Performance performance;
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(nullable = false, length = 20)
|
||||||
|
private ReservationStatus status;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private Long totalAmount;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
@OneToMany(mappedBy = "reservation", cascade = CascadeType.ALL, orphanRemoval = true)
|
||||||
|
private List<ReservationItem> items = new ArrayList<>();
|
||||||
|
|
||||||
|
@Builder
|
||||||
|
public Reservation(User user, Performance performance, ReservationStatus status, Long totalAmount) {
|
||||||
|
this.user = user;
|
||||||
|
this.performance = performance;
|
||||||
|
this.status = status;
|
||||||
|
this.totalAmount = totalAmount;
|
||||||
|
this.createdAt = LocalDateTime.now();
|
||||||
|
this.updatedAt = LocalDateTime.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void addItem(ReservationItem item) {
|
||||||
|
items.add(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void cancel() {
|
||||||
|
this.status = ReservationStatus.CANCELLED;
|
||||||
|
this.updatedAt = LocalDateTime.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isConfirmed() {
|
||||||
|
return status == ReservationStatus.CONFIRMED;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,45 @@
|
||||||
|
package com.reservation.demo.domain.reservation;
|
||||||
|
|
||||||
|
import com.reservation.demo.domain.seat.SeatInventory;
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.FetchType;
|
||||||
|
import jakarta.persistence.GeneratedValue;
|
||||||
|
import jakarta.persistence.GenerationType;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.JoinColumn;
|
||||||
|
import jakarta.persistence.ManyToOne;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
import lombok.AccessLevel;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@NoArgsConstructor(access = AccessLevel.PROTECTED)
|
||||||
|
@Entity
|
||||||
|
@Table(name = "reservation_items")
|
||||||
|
public class ReservationItem {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||||
|
@JoinColumn(name = "reservation_id", nullable = false)
|
||||||
|
private Reservation reservation;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||||
|
@JoinColumn(name = "seat_inventory_id", nullable = false)
|
||||||
|
private SeatInventory seatInventory;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private Long price;
|
||||||
|
|
||||||
|
@Builder
|
||||||
|
public ReservationItem(Reservation reservation, SeatInventory seatInventory, Long price) {
|
||||||
|
this.reservation = reservation;
|
||||||
|
this.seatInventory = seatInventory;
|
||||||
|
this.price = price;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
package com.reservation.demo.domain.reservation;
|
||||||
|
|
||||||
|
public enum ReservationStatus {
|
||||||
|
CONFIRMED,
|
||||||
|
CANCELLED
|
||||||
|
}
|
||||||
53
src/main/java/com/reservation/demo/domain/seat/Seat.java
Normal file
53
src/main/java/com/reservation/demo/domain/seat/Seat.java
Normal file
|
|
@ -0,0 +1,53 @@
|
||||||
|
package com.reservation.demo.domain.seat;
|
||||||
|
|
||||||
|
import com.reservation.demo.domain.concert.Concert;
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.FetchType;
|
||||||
|
import jakarta.persistence.GeneratedValue;
|
||||||
|
import jakarta.persistence.GenerationType;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.JoinColumn;
|
||||||
|
import jakarta.persistence.ManyToOne;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import lombok.AccessLevel;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@NoArgsConstructor(access = AccessLevel.PROTECTED)
|
||||||
|
@Entity
|
||||||
|
@Table(name = "seats")
|
||||||
|
public class Seat {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||||
|
@JoinColumn(name = "concert_id", nullable = false)
|
||||||
|
private Concert concert;
|
||||||
|
|
||||||
|
@Column(nullable = false, length = 50)
|
||||||
|
private String section;
|
||||||
|
|
||||||
|
@Column(name = "row_label", nullable = false, length = 10)
|
||||||
|
private String rowLabel;
|
||||||
|
|
||||||
|
@Column(name = "seat_number", nullable = false)
|
||||||
|
private Integer seatNumber;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
@Builder
|
||||||
|
public Seat(Concert concert, String section, String rowLabel, Integer seatNumber) {
|
||||||
|
this.concert = concert;
|
||||||
|
this.section = section;
|
||||||
|
this.rowLabel = rowLabel;
|
||||||
|
this.seatNumber = seatNumber;
|
||||||
|
this.createdAt = LocalDateTime.now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,71 @@
|
||||||
|
package com.reservation.demo.domain.seat;
|
||||||
|
|
||||||
|
import com.reservation.demo.domain.concert.Performance;
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.EnumType;
|
||||||
|
import jakarta.persistence.Enumerated;
|
||||||
|
import jakarta.persistence.FetchType;
|
||||||
|
import jakarta.persistence.GeneratedValue;
|
||||||
|
import jakarta.persistence.GenerationType;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.JoinColumn;
|
||||||
|
import jakarta.persistence.ManyToOne;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import lombok.AccessLevel;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@NoArgsConstructor(access = AccessLevel.PROTECTED)
|
||||||
|
@Entity
|
||||||
|
@Table(name = "seat_inventories")
|
||||||
|
public class SeatInventory {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||||
|
@JoinColumn(name = "performance_id", nullable = false)
|
||||||
|
private Performance performance;
|
||||||
|
|
||||||
|
@ManyToOne(fetch = FetchType.LAZY, optional = false)
|
||||||
|
@JoinColumn(name = "seat_id", nullable = false)
|
||||||
|
private Seat seat;
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(nullable = false, length = 20)
|
||||||
|
private SeatInventoryStatus status;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
@Builder
|
||||||
|
public SeatInventory(Performance performance, Seat seat, SeatInventoryStatus status) {
|
||||||
|
this.performance = performance;
|
||||||
|
this.seat = seat;
|
||||||
|
this.status = status;
|
||||||
|
this.createdAt = LocalDateTime.now();
|
||||||
|
this.updatedAt = LocalDateTime.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isAvailable() {
|
||||||
|
return status == SeatInventoryStatus.AVAILABLE;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void markSold() {
|
||||||
|
this.status = SeatInventoryStatus.SOLD;
|
||||||
|
this.updatedAt = LocalDateTime.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void markAvailable() {
|
||||||
|
this.status = SeatInventoryStatus.AVAILABLE;
|
||||||
|
this.updatedAt = LocalDateTime.now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
package com.reservation.demo.domain.seat;
|
||||||
|
|
||||||
|
public enum SeatInventoryStatus {
|
||||||
|
AVAILABLE,
|
||||||
|
SOLD
|
||||||
|
}
|
||||||
6
src/main/java/com/reservation/demo/domain/user/Role.java
Normal file
6
src/main/java/com/reservation/demo/domain/user/Role.java
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
package com.reservation.demo.domain.user;
|
||||||
|
|
||||||
|
public enum Role {
|
||||||
|
USER,
|
||||||
|
ORGANIZER
|
||||||
|
}
|
||||||
55
src/main/java/com/reservation/demo/domain/user/User.java
Normal file
55
src/main/java/com/reservation/demo/domain/user/User.java
Normal file
|
|
@ -0,0 +1,55 @@
|
||||||
|
package com.reservation.demo.domain.user;
|
||||||
|
|
||||||
|
import jakarta.persistence.Column;
|
||||||
|
import jakarta.persistence.Entity;
|
||||||
|
import jakarta.persistence.EnumType;
|
||||||
|
import jakarta.persistence.Enumerated;
|
||||||
|
import jakarta.persistence.GeneratedValue;
|
||||||
|
import jakarta.persistence.GenerationType;
|
||||||
|
import jakarta.persistence.Id;
|
||||||
|
import jakarta.persistence.Table;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import lombok.AccessLevel;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Getter;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
@NoArgsConstructor(access = AccessLevel.PROTECTED)
|
||||||
|
@Entity
|
||||||
|
@Table(name = "users")
|
||||||
|
public class User {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(nullable = false, unique = true)
|
||||||
|
private String email;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private String password;
|
||||||
|
|
||||||
|
@Column(nullable = false, length = 100)
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(nullable = false, length = 20)
|
||||||
|
private Role role;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
@Column(nullable = false)
|
||||||
|
private LocalDateTime updatedAt;
|
||||||
|
|
||||||
|
@Builder
|
||||||
|
public User(String email, String password, String name, Role role) {
|
||||||
|
this.email = email;
|
||||||
|
this.password = password;
|
||||||
|
this.name = name;
|
||||||
|
this.role = role;
|
||||||
|
this.createdAt = LocalDateTime.now();
|
||||||
|
this.updatedAt = LocalDateTime.now();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,17 @@
|
||||||
|
package com.reservation.demo.repository;
|
||||||
|
|
||||||
|
import com.reservation.demo.domain.concert.Concert;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
|
||||||
|
public interface ConcertRepository extends JpaRepository<Concert, Long> {
|
||||||
|
|
||||||
|
@Query("SELECT c FROM Concert c JOIN FETCH c.organizer ORDER BY c.createdAt DESC")
|
||||||
|
List<Concert> findAllWithOrganizer();
|
||||||
|
|
||||||
|
@Query("SELECT c FROM Concert c JOIN FETCH c.organizer WHERE c.id = :id")
|
||||||
|
Optional<Concert> findByIdWithOrganizer(@Param("id") Long id);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
package com.reservation.demo.repository;
|
||||||
|
|
||||||
|
import com.reservation.demo.domain.concert.Performance;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
|
||||||
|
public interface PerformanceRepository extends JpaRepository<Performance, Long> {
|
||||||
|
|
||||||
|
List<Performance> findAllByConcertId(Long concertId);
|
||||||
|
|
||||||
|
@Query("SELECT p FROM Performance p JOIN FETCH p.concert WHERE p.id = :id")
|
||||||
|
Optional<Performance> findByIdWithConcert(@Param("id") Long id);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
package com.reservation.demo.repository;
|
||||||
|
|
||||||
|
import com.reservation.demo.domain.reservation.Reservation;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
|
||||||
|
public interface ReservationRepository extends JpaRepository<Reservation, Long> {
|
||||||
|
|
||||||
|
@Query("""
|
||||||
|
SELECT r FROM Reservation r
|
||||||
|
JOIN FETCH r.performance p
|
||||||
|
JOIN FETCH p.concert
|
||||||
|
WHERE r.user.id = :userId
|
||||||
|
ORDER BY r.createdAt DESC
|
||||||
|
""")
|
||||||
|
List<Reservation> findAllByUserIdWithDetails(@Param("userId") Long userId);
|
||||||
|
|
||||||
|
@Query("""
|
||||||
|
SELECT r FROM Reservation r
|
||||||
|
JOIN FETCH r.performance p
|
||||||
|
JOIN FETCH p.concert
|
||||||
|
JOIN FETCH r.items i
|
||||||
|
JOIN FETCH i.seatInventory si
|
||||||
|
JOIN FETCH si.seat
|
||||||
|
WHERE r.id = :id AND r.user.id = :userId
|
||||||
|
""")
|
||||||
|
Optional<Reservation> findByIdAndUserIdWithDetails(@Param("id") Long id, @Param("userId") Long userId);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
package com.reservation.demo.repository;
|
||||||
|
|
||||||
|
import com.reservation.demo.domain.seat.SeatInventory;
|
||||||
|
import jakarta.persistence.LockModeType;
|
||||||
|
import java.util.List;
|
||||||
|
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;
|
||||||
|
|
||||||
|
public interface SeatInventoryRepository extends JpaRepository<SeatInventory, Long> {
|
||||||
|
|
||||||
|
List<SeatInventory> findAllByPerformanceId(Long performanceId);
|
||||||
|
|
||||||
|
@Query("""
|
||||||
|
SELECT si FROM SeatInventory si
|
||||||
|
JOIN FETCH si.seat s
|
||||||
|
WHERE si.performance.id = :performanceId
|
||||||
|
ORDER BY s.section, s.rowLabel, s.seatNumber
|
||||||
|
""")
|
||||||
|
List<SeatInventory> findAllByPerformanceIdWithSeat(@Param("performanceId") Long performanceId);
|
||||||
|
|
||||||
|
@Lock(LockModeType.PESSIMISTIC_WRITE)
|
||||||
|
@Query("""
|
||||||
|
SELECT si FROM SeatInventory si
|
||||||
|
JOIN FETCH si.seat
|
||||||
|
WHERE si.id IN :ids
|
||||||
|
ORDER BY si.id
|
||||||
|
""")
|
||||||
|
List<SeatInventory> findAllByIdInForUpdate(@Param("ids") List<Long> ids);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,10 @@
|
||||||
|
package com.reservation.demo.repository;
|
||||||
|
|
||||||
|
import com.reservation.demo.domain.seat.Seat;
|
||||||
|
import java.util.List;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
public interface SeatRepository extends JpaRepository<Seat, Long> {
|
||||||
|
|
||||||
|
List<Seat> findAllByConcertId(Long concertId);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
package com.reservation.demo.repository;
|
||||||
|
|
||||||
|
import com.reservation.demo.domain.user.User;
|
||||||
|
import java.util.Optional;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
public interface UserRepository extends JpaRepository<User, Long> {
|
||||||
|
|
||||||
|
boolean existsByEmail(String email);
|
||||||
|
|
||||||
|
Optional<User> findByEmail(String email);
|
||||||
|
}
|
||||||
63
src/main/java/com/reservation/demo/service/AuthService.java
Normal file
63
src/main/java/com/reservation/demo/service/AuthService.java
Normal file
|
|
@ -0,0 +1,63 @@
|
||||||
|
package com.reservation.demo.service;
|
||||||
|
|
||||||
|
import com.reservation.demo.api.auth.AuthDtos.AuthResponse;
|
||||||
|
import com.reservation.demo.api.auth.AuthDtos.LoginRequest;
|
||||||
|
import com.reservation.demo.api.auth.AuthDtos.SignupRequest;
|
||||||
|
import com.reservation.demo.common.exception.BusinessException;
|
||||||
|
import com.reservation.demo.common.exception.ErrorCode;
|
||||||
|
import com.reservation.demo.config.JwtTokenProvider;
|
||||||
|
import com.reservation.demo.domain.user.User;
|
||||||
|
import com.reservation.demo.repository.UserRepository;
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class AuthService {
|
||||||
|
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
private final PasswordEncoder passwordEncoder;
|
||||||
|
private final JwtTokenProvider jwtTokenProvider;
|
||||||
|
|
||||||
|
public AuthService(
|
||||||
|
UserRepository userRepository,
|
||||||
|
PasswordEncoder passwordEncoder,
|
||||||
|
JwtTokenProvider jwtTokenProvider
|
||||||
|
) {
|
||||||
|
this.userRepository = userRepository;
|
||||||
|
this.passwordEncoder = passwordEncoder;
|
||||||
|
this.jwtTokenProvider = jwtTokenProvider;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public AuthResponse signup(SignupRequest request) {
|
||||||
|
if (userRepository.existsByEmail(request.email())) {
|
||||||
|
throw new BusinessException(ErrorCode.DUPLICATE_EMAIL);
|
||||||
|
}
|
||||||
|
|
||||||
|
User user = User.builder()
|
||||||
|
.email(request.email())
|
||||||
|
.password(passwordEncoder.encode(request.password()))
|
||||||
|
.name(request.name())
|
||||||
|
.role(request.role())
|
||||||
|
.build();
|
||||||
|
|
||||||
|
User saved = userRepository.save(user);
|
||||||
|
String token = jwtTokenProvider.createToken(saved.getId(), saved.getEmail(), saved.getRole());
|
||||||
|
|
||||||
|
return new AuthResponse(saved.getId(), saved.getEmail(), saved.getName(), saved.getRole(), token);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public AuthResponse login(LoginRequest request) {
|
||||||
|
User user = userRepository.findByEmail(request.email())
|
||||||
|
.orElseThrow(() -> new BusinessException(ErrorCode.UNAUTHORIZED, "이메일 또는 비밀번호가 올바르지 않습니다."));
|
||||||
|
|
||||||
|
if (!passwordEncoder.matches(request.password(), user.getPassword())) {
|
||||||
|
throw new BusinessException(ErrorCode.UNAUTHORIZED, "이메일 또는 비밀번호가 올바르지 않습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
String token = jwtTokenProvider.createToken(user.getId(), user.getEmail(), user.getRole());
|
||||||
|
return new AuthResponse(user.getId(), user.getEmail(), user.getName(), user.getRole(), token);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,99 @@
|
||||||
|
package com.reservation.demo.service;
|
||||||
|
|
||||||
|
import com.reservation.demo.api.concert.ConcertDtos.ConcertDetailResponse;
|
||||||
|
import com.reservation.demo.api.concert.ConcertDtos.ConcertSummaryResponse;
|
||||||
|
import com.reservation.demo.api.concert.ConcertDtos.PerformanceResponse;
|
||||||
|
import com.reservation.demo.api.concert.ConcertDtos.SeatMapItemResponse;
|
||||||
|
import com.reservation.demo.api.concert.ConcertDtos.SeatMapResponse;
|
||||||
|
import com.reservation.demo.common.exception.BusinessException;
|
||||||
|
import com.reservation.demo.common.exception.ErrorCode;
|
||||||
|
import com.reservation.demo.domain.seat.SeatInventory;
|
||||||
|
import com.reservation.demo.repository.ConcertRepository;
|
||||||
|
import com.reservation.demo.repository.PerformanceRepository;
|
||||||
|
import com.reservation.demo.repository.SeatInventoryRepository;
|
||||||
|
import java.util.List;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class ConcertService {
|
||||||
|
|
||||||
|
private final ConcertRepository concertRepository;
|
||||||
|
private final PerformanceRepository performanceRepository;
|
||||||
|
private final SeatInventoryRepository seatInventoryRepository;
|
||||||
|
|
||||||
|
public ConcertService(
|
||||||
|
ConcertRepository concertRepository,
|
||||||
|
PerformanceRepository performanceRepository,
|
||||||
|
SeatInventoryRepository seatInventoryRepository
|
||||||
|
) {
|
||||||
|
this.concertRepository = concertRepository;
|
||||||
|
this.performanceRepository = performanceRepository;
|
||||||
|
this.seatInventoryRepository = seatInventoryRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<ConcertSummaryResponse> getConcerts() {
|
||||||
|
return concertRepository.findAllWithOrganizer().stream()
|
||||||
|
.map(concert -> new ConcertSummaryResponse(
|
||||||
|
concert.getId(),
|
||||||
|
concert.getTitle(),
|
||||||
|
concert.getVenue(),
|
||||||
|
concert.getOrganizer().getName()
|
||||||
|
))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public ConcertDetailResponse getConcertDetail(Long concertId) {
|
||||||
|
var concert = concertRepository.findByIdWithOrganizer(concertId)
|
||||||
|
.orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND, "공연을 찾을 수 없습니다."));
|
||||||
|
|
||||||
|
List<PerformanceResponse> performances = performanceRepository.findAllByConcertId(concertId).stream()
|
||||||
|
.map(performance -> new PerformanceResponse(
|
||||||
|
performance.getId(),
|
||||||
|
performance.getPerformanceAt()
|
||||||
|
))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
return new ConcertDetailResponse(
|
||||||
|
concert.getId(),
|
||||||
|
concert.getTitle(),
|
||||||
|
concert.getVenue(),
|
||||||
|
concert.getDescription(),
|
||||||
|
concert.getOrganizer().getName(),
|
||||||
|
performances
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public SeatMapResponse getSeatMap(Long performanceId) {
|
||||||
|
var performance = performanceRepository.findByIdWithConcert(performanceId)
|
||||||
|
.orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND, "회차를 찾을 수 없습니다."));
|
||||||
|
|
||||||
|
List<SeatMapItemResponse> seats = seatInventoryRepository.findAllByPerformanceIdWithSeat(performanceId).stream()
|
||||||
|
.map(this::toSeatMapItem)
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
var concert = performance.getConcert();
|
||||||
|
return new SeatMapResponse(
|
||||||
|
performance.getId(),
|
||||||
|
concert.getId(),
|
||||||
|
concert.getTitle(),
|
||||||
|
performance.getPerformanceAt(),
|
||||||
|
seats
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private SeatMapItemResponse toSeatMapItem(SeatInventory inventory) {
|
||||||
|
var seat = inventory.getSeat();
|
||||||
|
return new SeatMapItemResponse(
|
||||||
|
inventory.getId(),
|
||||||
|
seat.getId(),
|
||||||
|
seat.getSection(),
|
||||||
|
seat.getRowLabel(),
|
||||||
|
seat.getSeatNumber(),
|
||||||
|
inventory.getStatus().name()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,92 @@
|
||||||
|
package com.reservation.demo.service;
|
||||||
|
|
||||||
|
import com.reservation.demo.api.organizer.OrganizerDtos.CreateConcertRequest;
|
||||||
|
import com.reservation.demo.api.organizer.OrganizerDtos.CreateConcertResponse;
|
||||||
|
import com.reservation.demo.common.exception.BusinessException;
|
||||||
|
import com.reservation.demo.common.exception.ErrorCode;
|
||||||
|
import com.reservation.demo.domain.concert.Concert;
|
||||||
|
import com.reservation.demo.domain.concert.Performance;
|
||||||
|
import com.reservation.demo.domain.seat.Seat;
|
||||||
|
import com.reservation.demo.domain.seat.SeatInventory;
|
||||||
|
import com.reservation.demo.domain.seat.SeatInventoryStatus;
|
||||||
|
import com.reservation.demo.domain.user.User;
|
||||||
|
import com.reservation.demo.repository.ConcertRepository;
|
||||||
|
import com.reservation.demo.repository.PerformanceRepository;
|
||||||
|
import com.reservation.demo.repository.SeatInventoryRepository;
|
||||||
|
import com.reservation.demo.repository.SeatRepository;
|
||||||
|
import com.reservation.demo.repository.UserRepository;
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class OrganizerService {
|
||||||
|
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
private final ConcertRepository concertRepository;
|
||||||
|
private final PerformanceRepository performanceRepository;
|
||||||
|
private final SeatRepository seatRepository;
|
||||||
|
private final SeatInventoryRepository seatInventoryRepository;
|
||||||
|
|
||||||
|
public OrganizerService(
|
||||||
|
UserRepository userRepository,
|
||||||
|
ConcertRepository concertRepository,
|
||||||
|
PerformanceRepository performanceRepository,
|
||||||
|
SeatRepository seatRepository,
|
||||||
|
SeatInventoryRepository seatInventoryRepository
|
||||||
|
) {
|
||||||
|
this.userRepository = userRepository;
|
||||||
|
this.concertRepository = concertRepository;
|
||||||
|
this.performanceRepository = performanceRepository;
|
||||||
|
this.seatRepository = seatRepository;
|
||||||
|
this.seatInventoryRepository = seatInventoryRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public CreateConcertResponse createConcert(Long organizerId, CreateConcertRequest request) {
|
||||||
|
User organizer = userRepository.findById(organizerId)
|
||||||
|
.orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND, "주최사를 찾을 수 없습니다."));
|
||||||
|
|
||||||
|
Concert concert = Concert.builder()
|
||||||
|
.organizer(organizer)
|
||||||
|
.title(request.title())
|
||||||
|
.venue(request.venue())
|
||||||
|
.description(request.description())
|
||||||
|
.build();
|
||||||
|
concertRepository.save(concert);
|
||||||
|
|
||||||
|
List<Seat> seats = new ArrayList<>();
|
||||||
|
for (var seatTemplate : request.seats()) {
|
||||||
|
Seat seat = Seat.builder()
|
||||||
|
.concert(concert)
|
||||||
|
.section(seatTemplate.section())
|
||||||
|
.rowLabel(seatTemplate.rowLabel())
|
||||||
|
.seatNumber(seatTemplate.seatNumber())
|
||||||
|
.build();
|
||||||
|
seats.add(seatRepository.save(seat));
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Performance> performances = new ArrayList<>();
|
||||||
|
for (var performanceTemplate : request.performances()) {
|
||||||
|
Performance performance = Performance.builder()
|
||||||
|
.concert(concert)
|
||||||
|
.performanceAt(performanceTemplate.performanceAt())
|
||||||
|
.build();
|
||||||
|
performances.add(performanceRepository.save(performance));
|
||||||
|
}
|
||||||
|
|
||||||
|
for (Performance performance : performances) {
|
||||||
|
for (Seat seat : seats) {
|
||||||
|
SeatInventory inventory = SeatInventory.builder()
|
||||||
|
.performance(performance)
|
||||||
|
.seat(seat)
|
||||||
|
.status(SeatInventoryStatus.AVAILABLE)
|
||||||
|
.build();
|
||||||
|
seatInventoryRepository.save(inventory);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new CreateConcertResponse(concert.getId(), performances.size(), seats.size());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,166 @@
|
||||||
|
package com.reservation.demo.service;
|
||||||
|
|
||||||
|
import com.reservation.demo.api.reservation.ReservationDtos.CreateReservationRequest;
|
||||||
|
import com.reservation.demo.api.reservation.ReservationDtos.ReservationResponse;
|
||||||
|
import com.reservation.demo.api.reservation.ReservationDtos.ReservationSeatResponse;
|
||||||
|
import com.reservation.demo.common.exception.BusinessException;
|
||||||
|
import com.reservation.demo.common.exception.ErrorCode;
|
||||||
|
import com.reservation.demo.domain.reservation.Reservation;
|
||||||
|
import com.reservation.demo.domain.reservation.ReservationItem;
|
||||||
|
import com.reservation.demo.domain.reservation.ReservationStatus;
|
||||||
|
import com.reservation.demo.domain.seat.SeatInventory;
|
||||||
|
import com.reservation.demo.domain.user.User;
|
||||||
|
import com.reservation.demo.repository.PerformanceRepository;
|
||||||
|
import com.reservation.demo.repository.ReservationRepository;
|
||||||
|
import com.reservation.demo.repository.SeatInventoryRepository;
|
||||||
|
import com.reservation.demo.repository.UserRepository;
|
||||||
|
import java.util.Comparator;
|
||||||
|
import java.util.List;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
public class ReservationService {
|
||||||
|
|
||||||
|
private static final long SEAT_PRICE = 80_000L;
|
||||||
|
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
private final PerformanceRepository performanceRepository;
|
||||||
|
private final SeatInventoryRepository seatInventoryRepository;
|
||||||
|
private final ReservationRepository reservationRepository;
|
||||||
|
|
||||||
|
public ReservationService(
|
||||||
|
UserRepository userRepository,
|
||||||
|
PerformanceRepository performanceRepository,
|
||||||
|
SeatInventoryRepository seatInventoryRepository,
|
||||||
|
ReservationRepository reservationRepository
|
||||||
|
) {
|
||||||
|
this.userRepository = userRepository;
|
||||||
|
this.performanceRepository = performanceRepository;
|
||||||
|
this.seatInventoryRepository = seatInventoryRepository;
|
||||||
|
this.reservationRepository = reservationRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public ReservationResponse createReservation(Long userId, CreateReservationRequest request) {
|
||||||
|
User user = userRepository.findById(userId)
|
||||||
|
.orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND, "사용자를 찾을 수 없습니다."));
|
||||||
|
|
||||||
|
var performance = performanceRepository.findByIdWithConcert(request.performanceId())
|
||||||
|
.orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND, "회차를 찾을 수 없습니다."));
|
||||||
|
|
||||||
|
List<Long> sortedIds = request.seatInventoryIds().stream()
|
||||||
|
.distinct()
|
||||||
|
.sorted()
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
if (sortedIds.isEmpty()) {
|
||||||
|
throw new BusinessException(ErrorCode.INVALID_REQUEST, "예약할 좌석을 선택해 주세요.");
|
||||||
|
}
|
||||||
|
|
||||||
|
List<SeatInventory> inventories = seatInventoryRepository.findAllByIdInForUpdate(sortedIds);
|
||||||
|
|
||||||
|
if (inventories.size() != sortedIds.size()) {
|
||||||
|
throw new BusinessException(ErrorCode.NOT_FOUND, "존재하지 않는 좌석이 포함되어 있습니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
inventories.sort(Comparator.comparing(SeatInventory::getId));
|
||||||
|
|
||||||
|
for (SeatInventory inventory : inventories) {
|
||||||
|
if (!inventory.getPerformance().getId().equals(performance.getId())) {
|
||||||
|
throw new BusinessException(ErrorCode.INVALID_REQUEST, "다른 회차의 좌석이 포함되어 있습니다.");
|
||||||
|
}
|
||||||
|
if (!inventory.isAvailable()) {
|
||||||
|
throw new BusinessException(ErrorCode.SEAT_NOT_AVAILABLE);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
long totalAmount = SEAT_PRICE * inventories.size();
|
||||||
|
|
||||||
|
Reservation reservation = Reservation.builder()
|
||||||
|
.user(user)
|
||||||
|
.performance(performance)
|
||||||
|
.status(ReservationStatus.CONFIRMED)
|
||||||
|
.totalAmount(totalAmount)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
for (SeatInventory inventory : inventories) {
|
||||||
|
inventory.markSold();
|
||||||
|
ReservationItem item = ReservationItem.builder()
|
||||||
|
.reservation(reservation)
|
||||||
|
.seatInventory(inventory)
|
||||||
|
.price(SEAT_PRICE)
|
||||||
|
.build();
|
||||||
|
reservation.addItem(item);
|
||||||
|
}
|
||||||
|
|
||||||
|
Reservation saved = reservationRepository.save(reservation);
|
||||||
|
return toResponse(saved);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional
|
||||||
|
public ReservationResponse cancelReservation(Long userId, Long reservationId) {
|
||||||
|
Reservation reservation = reservationRepository.findByIdAndUserIdWithDetails(reservationId, userId)
|
||||||
|
.orElseThrow(() -> new BusinessException(ErrorCode.NOT_FOUND, "예약을 찾을 수 없습니다."));
|
||||||
|
|
||||||
|
if (!reservation.isConfirmed()) {
|
||||||
|
throw new BusinessException(ErrorCode.INVALID_REQUEST, "이미 취소된 예약입니다.");
|
||||||
|
}
|
||||||
|
|
||||||
|
reservation.cancel();
|
||||||
|
for (ReservationItem item : List.copyOf(reservation.getItems())) {
|
||||||
|
item.getSeatInventory().markAvailable();
|
||||||
|
}
|
||||||
|
|
||||||
|
ReservationResponse response = toResponse(reservation);
|
||||||
|
reservation.getItems().clear();
|
||||||
|
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<ReservationResponse> getMyReservations(Long userId) {
|
||||||
|
return reservationRepository.findAllByUserIdWithDetails(userId).stream()
|
||||||
|
.map(this::toSummaryResponse)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
private ReservationResponse toSummaryResponse(Reservation reservation) {
|
||||||
|
return new ReservationResponse(
|
||||||
|
reservation.getId(),
|
||||||
|
reservation.getPerformance().getId(),
|
||||||
|
reservation.getPerformance().getConcert().getTitle(),
|
||||||
|
reservation.getPerformance().getPerformanceAt(),
|
||||||
|
reservation.getStatus(),
|
||||||
|
reservation.getTotalAmount(),
|
||||||
|
reservation.getCreatedAt(),
|
||||||
|
List.of()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private ReservationResponse toResponse(Reservation reservation) {
|
||||||
|
List<ReservationSeatResponse> seats = reservation.getItems().stream()
|
||||||
|
.map(item -> {
|
||||||
|
var seat = item.getSeatInventory().getSeat();
|
||||||
|
return new ReservationSeatResponse(
|
||||||
|
item.getSeatInventory().getId(),
|
||||||
|
seat.getSection(),
|
||||||
|
seat.getRowLabel(),
|
||||||
|
seat.getSeatNumber(),
|
||||||
|
item.getPrice()
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
return new ReservationResponse(
|
||||||
|
reservation.getId(),
|
||||||
|
reservation.getPerformance().getId(),
|
||||||
|
reservation.getPerformance().getConcert().getTitle(),
|
||||||
|
reservation.getPerformance().getPerformanceAt(),
|
||||||
|
reservation.getStatus(),
|
||||||
|
reservation.getTotalAmount(),
|
||||||
|
reservation.getCreatedAt(),
|
||||||
|
seats
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
spring.application.name=demo
|
|
||||||
40
src/main/resources/application.yml
Normal file
40
src/main/resources/application.yml
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
spring:
|
||||||
|
application:
|
||||||
|
name: reservation-demo
|
||||||
|
|
||||||
|
datasource:
|
||||||
|
url: jdbc:mysql://${DB_HOST:localhost}:3306/reservation_demo?useSSL=false&allowPublicKeyRetrieval=true&characterEncoding=UTF-8
|
||||||
|
username: demo
|
||||||
|
password: demo
|
||||||
|
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||||
|
|
||||||
|
jpa:
|
||||||
|
hibernate:
|
||||||
|
ddl-auto: validate
|
||||||
|
open-in-view: false
|
||||||
|
properties:
|
||||||
|
hibernate:
|
||||||
|
format_sql: true
|
||||||
|
default_batch_fetch_size: 100
|
||||||
|
|
||||||
|
flyway:
|
||||||
|
enabled: true
|
||||||
|
locations: classpath:db/migration
|
||||||
|
|
||||||
|
server:
|
||||||
|
port: 8080
|
||||||
|
|
||||||
|
app:
|
||||||
|
jwt:
|
||||||
|
secret: reservation-demo-jwt-secret-key-32chars!
|
||||||
|
expiration-ms: 86400000
|
||||||
|
|
||||||
|
springdoc:
|
||||||
|
api-docs:
|
||||||
|
path: /api-docs
|
||||||
|
swagger-ui:
|
||||||
|
path: /swagger-ui.html
|
||||||
|
|
||||||
|
logging:
|
||||||
|
level:
|
||||||
|
org.hibernate.SQL: debug
|
||||||
90
src/main/resources/db/migration/V1__init_schema.sql
Normal file
90
src/main/resources/db/migration/V1__init_schema.sql
Normal file
|
|
@ -0,0 +1,90 @@
|
||||||
|
CREATE TABLE users (
|
||||||
|
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||||
|
email VARCHAR(255) NOT NULL,
|
||||||
|
password VARCHAR(255) NOT NULL,
|
||||||
|
name VARCHAR(100) NOT NULL,
|
||||||
|
role VARCHAR(20) NOT NULL,
|
||||||
|
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||||
|
updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uk_users_email (email)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE concerts (
|
||||||
|
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||||
|
organizer_id BIGINT NOT NULL,
|
||||||
|
title VARCHAR(200) NOT NULL,
|
||||||
|
venue VARCHAR(200) NOT NULL,
|
||||||
|
description TEXT,
|
||||||
|
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||||
|
updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
KEY idx_concerts_organizer_id (organizer_id),
|
||||||
|
CONSTRAINT fk_concerts_organizer FOREIGN KEY (organizer_id) REFERENCES users (id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE performances (
|
||||||
|
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||||
|
concert_id BIGINT NOT NULL,
|
||||||
|
performance_at DATETIME(6) NOT NULL,
|
||||||
|
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||||
|
updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
KEY idx_performances_concert_id (concert_id),
|
||||||
|
KEY idx_performances_performance_at (performance_at),
|
||||||
|
CONSTRAINT fk_performances_concert FOREIGN KEY (concert_id) REFERENCES concerts (id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE seats (
|
||||||
|
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||||
|
concert_id BIGINT NOT NULL,
|
||||||
|
section VARCHAR(50) NOT NULL,
|
||||||
|
row_label VARCHAR(10) NOT NULL,
|
||||||
|
seat_number INT NOT NULL,
|
||||||
|
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uk_seats_concert_section_row_seat (concert_id, section, row_label, seat_number),
|
||||||
|
KEY idx_seats_concert_id (concert_id),
|
||||||
|
CONSTRAINT fk_seats_concert FOREIGN KEY (concert_id) REFERENCES concerts (id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE seat_inventories (
|
||||||
|
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||||
|
performance_id BIGINT NOT NULL,
|
||||||
|
seat_id BIGINT NOT NULL,
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'AVAILABLE',
|
||||||
|
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||||
|
updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uk_seat_inventories_performance_seat (performance_id, seat_id),
|
||||||
|
KEY idx_seat_inventories_status (status),
|
||||||
|
CONSTRAINT fk_seat_inventories_performance FOREIGN KEY (performance_id) REFERENCES performances (id),
|
||||||
|
CONSTRAINT fk_seat_inventories_seat FOREIGN KEY (seat_id) REFERENCES seats (id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE reservations (
|
||||||
|
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||||
|
user_id BIGINT NOT NULL,
|
||||||
|
performance_id BIGINT NOT NULL,
|
||||||
|
status VARCHAR(20) NOT NULL DEFAULT 'CONFIRMED',
|
||||||
|
total_amount BIGINT NOT NULL DEFAULT 0,
|
||||||
|
created_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6),
|
||||||
|
updated_at DATETIME(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6),
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
KEY idx_reservations_user_id (user_id),
|
||||||
|
KEY idx_reservations_performance_id (performance_id),
|
||||||
|
CONSTRAINT fk_reservations_user FOREIGN KEY (user_id) REFERENCES users (id),
|
||||||
|
CONSTRAINT fk_reservations_performance FOREIGN KEY (performance_id) REFERENCES performances (id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
CREATE TABLE reservation_items (
|
||||||
|
id BIGINT NOT NULL AUTO_INCREMENT,
|
||||||
|
reservation_id BIGINT NOT NULL,
|
||||||
|
seat_inventory_id BIGINT NOT NULL,
|
||||||
|
price BIGINT NOT NULL DEFAULT 0,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
UNIQUE KEY uk_reservation_items_inventory (seat_inventory_id),
|
||||||
|
KEY idx_reservation_items_reservation_id (reservation_id),
|
||||||
|
CONSTRAINT fk_reservation_items_reservation FOREIGN KEY (reservation_id) REFERENCES reservations (id),
|
||||||
|
CONSTRAINT fk_reservation_items_inventory FOREIGN KEY (seat_inventory_id) REFERENCES seat_inventories (id)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
@ -1,15 +1,33 @@
|
||||||
package com.reservation.demo;
|
package com.reservation.demo;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Tag;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.springframework.boot.test.context.SpringBootTest;
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
import org.springframework.context.annotation.Import;
|
import org.springframework.test.context.DynamicPropertyRegistry;
|
||||||
|
import org.springframework.test.context.DynamicPropertySource;
|
||||||
|
import org.testcontainers.containers.MySQLContainer;
|
||||||
|
import org.testcontainers.junit.jupiter.Container;
|
||||||
|
import org.testcontainers.junit.jupiter.Testcontainers;
|
||||||
|
|
||||||
@Import(TestcontainersConfiguration.class)
|
|
||||||
@SpringBootTest
|
@SpringBootTest
|
||||||
|
@Testcontainers
|
||||||
|
@Tag("integration")
|
||||||
class DemoApplicationTests {
|
class DemoApplicationTests {
|
||||||
|
|
||||||
|
@Container
|
||||||
|
static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.4")
|
||||||
|
.withDatabaseName("reservation_demo_test")
|
||||||
|
.withUsername("test")
|
||||||
|
.withPassword("test");
|
||||||
|
|
||||||
|
@DynamicPropertySource
|
||||||
|
static void configureProperties(DynamicPropertyRegistry registry) {
|
||||||
|
registry.add("spring.datasource.url", mysql::getJdbcUrl);
|
||||||
|
registry.add("spring.datasource.username", mysql::getUsername);
|
||||||
|
registry.add("spring.datasource.password", mysql::getPassword);
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void contextLoads() {
|
void contextLoads() {
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
package com.reservation.demo;
|
|
||||||
|
|
||||||
import org.springframework.boot.SpringApplication;
|
|
||||||
|
|
||||||
public class TestDemoApplication {
|
|
||||||
|
|
||||||
public static void main(String[] args) {
|
|
||||||
SpringApplication.from(DemoApplication::main).with(TestcontainersConfiguration.class).run(args);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
package com.reservation.demo;
|
|
||||||
|
|
||||||
import org.springframework.boot.test.context.TestConfiguration;
|
|
||||||
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
|
|
||||||
import org.springframework.context.annotation.Bean;
|
|
||||||
import org.testcontainers.mysql.MySQLContainer;
|
|
||||||
import org.testcontainers.utility.DockerImageName;
|
|
||||||
|
|
||||||
@TestConfiguration(proxyBeanMethods = false)
|
|
||||||
class TestcontainersConfiguration {
|
|
||||||
|
|
||||||
@Bean
|
|
||||||
@ServiceConnection
|
|
||||||
MySQLContainer mysqlContainer() {
|
|
||||||
return new MySQLContainer(DockerImageName.parse("mysql:latest"));
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
package com.reservation.demo.domain.seat;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
class SeatInventoryTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void marksSoldAndAvailable() {
|
||||||
|
SeatInventory inventory = SeatInventory.builder()
|
||||||
|
.performance(null)
|
||||||
|
.seat(null)
|
||||||
|
.status(SeatInventoryStatus.AVAILABLE)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
inventory.markSold();
|
||||||
|
assertThat(inventory.getStatus()).isEqualTo(SeatInventoryStatus.SOLD);
|
||||||
|
assertThat(inventory.isAvailable()).isFalse();
|
||||||
|
|
||||||
|
inventory.markAvailable();
|
||||||
|
assertThat(inventory.getStatus()).isEqualTo(SeatInventoryStatus.AVAILABLE);
|
||||||
|
assertThat(inventory.isAvailable()).isTrue();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,149 @@
|
||||||
|
package com.reservation.demo.service;
|
||||||
|
|
||||||
|
import com.reservation.demo.api.organizer.OrganizerDtos.CreateConcertRequest;
|
||||||
|
import com.reservation.demo.api.organizer.OrganizerDtos.PerformanceTemplateRequest;
|
||||||
|
import com.reservation.demo.api.organizer.OrganizerDtos.SeatTemplateRequest;
|
||||||
|
import com.reservation.demo.api.reservation.ReservationDtos.CreateReservationRequest;
|
||||||
|
import com.reservation.demo.common.exception.BusinessException;
|
||||||
|
import com.reservation.demo.domain.user.Role;
|
||||||
|
import com.reservation.demo.domain.user.User;
|
||||||
|
import com.reservation.demo.repository.PerformanceRepository;
|
||||||
|
import com.reservation.demo.repository.SeatInventoryRepository;
|
||||||
|
import com.reservation.demo.repository.UserRepository;
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.concurrent.CountDownLatch;
|
||||||
|
import java.util.concurrent.ExecutorService;
|
||||||
|
import java.util.concurrent.Executors;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Tag;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
import org.springframework.test.context.DynamicPropertyRegistry;
|
||||||
|
import org.springframework.test.context.DynamicPropertySource;
|
||||||
|
import org.testcontainers.containers.MySQLContainer;
|
||||||
|
import org.testcontainers.junit.jupiter.Container;
|
||||||
|
import org.testcontainers.junit.jupiter.Testcontainers;
|
||||||
|
|
||||||
|
import static org.assertj.core.api.Assertions.assertThat;
|
||||||
|
|
||||||
|
@SpringBootTest
|
||||||
|
@Testcontainers
|
||||||
|
@Tag("integration")
|
||||||
|
class ReservationConcurrencyIntegrationTest {
|
||||||
|
|
||||||
|
@Container
|
||||||
|
static MySQLContainer<?> mysql = new MySQLContainer<>("mysql:8.4")
|
||||||
|
.withDatabaseName("reservation_demo_test")
|
||||||
|
.withUsername("test")
|
||||||
|
.withPassword("test");
|
||||||
|
|
||||||
|
@DynamicPropertySource
|
||||||
|
static void configureProperties(DynamicPropertyRegistry registry) {
|
||||||
|
registry.add("spring.datasource.url", mysql::getJdbcUrl);
|
||||||
|
registry.add("spring.datasource.username", mysql::getUsername);
|
||||||
|
registry.add("spring.datasource.password", mysql::getPassword);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private OrganizerService organizerService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private ReservationService reservationService;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private UserRepository userRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private PerformanceRepository performanceRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private SeatInventoryRepository seatInventoryRepository;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private PasswordEncoder passwordEncoder;
|
||||||
|
|
||||||
|
private Long userAId;
|
||||||
|
private Long userBId;
|
||||||
|
private Long performanceId;
|
||||||
|
private Long targetSeatInventoryId;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
User organizer = userRepository.save(User.builder()
|
||||||
|
.email("organizer-" + System.nanoTime() + "@test.com")
|
||||||
|
.password(passwordEncoder.encode("password123"))
|
||||||
|
.name("Organizer")
|
||||||
|
.role(Role.ORGANIZER)
|
||||||
|
.build());
|
||||||
|
|
||||||
|
userAId = userRepository.save(User.builder()
|
||||||
|
.email("user-a-" + System.nanoTime() + "@test.com")
|
||||||
|
.password(passwordEncoder.encode("password123"))
|
||||||
|
.name("User A")
|
||||||
|
.role(Role.USER)
|
||||||
|
.build()).getId();
|
||||||
|
|
||||||
|
userBId = userRepository.save(User.builder()
|
||||||
|
.email("user-b-" + System.nanoTime() + "@test.com")
|
||||||
|
.password(passwordEncoder.encode("password123"))
|
||||||
|
.name("User B")
|
||||||
|
.role(Role.USER)
|
||||||
|
.build()).getId();
|
||||||
|
|
||||||
|
var concertResponse = organizerService.createConcert(organizer.getId(), new CreateConcertRequest(
|
||||||
|
"Test Concert",
|
||||||
|
"Test Hall",
|
||||||
|
"Concurrency test",
|
||||||
|
List.of(new PerformanceTemplateRequest(LocalDateTime.now().plusDays(7))),
|
||||||
|
List.of(new SeatTemplateRequest("A", "1", 1))
|
||||||
|
));
|
||||||
|
|
||||||
|
performanceId = performanceRepository.findAllByConcertId(concertResponse.concertId()).getFirst().getId();
|
||||||
|
targetSeatInventoryId = seatInventoryRepository.findAllByPerformanceId(performanceId).getFirst().getId();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void onlyOneReservationSucceedsForSameSeat() throws InterruptedException {
|
||||||
|
int threadCount = 10;
|
||||||
|
ExecutorService executor = Executors.newFixedThreadPool(threadCount);
|
||||||
|
CountDownLatch ready = new CountDownLatch(threadCount);
|
||||||
|
CountDownLatch start = new CountDownLatch(1);
|
||||||
|
CountDownLatch done = new CountDownLatch(threadCount);
|
||||||
|
|
||||||
|
AtomicInteger successCount = new AtomicInteger();
|
||||||
|
AtomicInteger conflictCount = new AtomicInteger();
|
||||||
|
|
||||||
|
for (int i = 0; i < threadCount; i++) {
|
||||||
|
Long userId = i % 2 == 0 ? userAId : userBId;
|
||||||
|
executor.submit(() -> {
|
||||||
|
ready.countDown();
|
||||||
|
try {
|
||||||
|
start.await();
|
||||||
|
reservationService.createReservation(
|
||||||
|
userId,
|
||||||
|
new CreateReservationRequest(performanceId, List.of(targetSeatInventoryId))
|
||||||
|
);
|
||||||
|
successCount.incrementAndGet();
|
||||||
|
} catch (BusinessException exception) {
|
||||||
|
conflictCount.incrementAndGet();
|
||||||
|
} catch (InterruptedException exception) {
|
||||||
|
Thread.currentThread().interrupt();
|
||||||
|
} finally {
|
||||||
|
done.countDown();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ready.await();
|
||||||
|
start.countDown();
|
||||||
|
done.await();
|
||||||
|
executor.shutdown();
|
||||||
|
|
||||||
|
assertThat(successCount.get()).isEqualTo(1);
|
||||||
|
assertThat(conflictCount.get()).isEqualTo(threadCount - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue