diff --git a/src/main/java/com/reservation/demo/DemoApplication.java b/src/main/java/com/reservation/demo/DemoApplication.java index bb12851..8324c6c 100644 --- a/src/main/java/com/reservation/demo/DemoApplication.java +++ b/src/main/java/com/reservation/demo/DemoApplication.java @@ -6,8 +6,7 @@ import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication public class DemoApplication { - public static void main(String[] args) { - SpringApplication.run(DemoApplication.class, args); - } - + public static void main(String[] args) { + SpringApplication.run(DemoApplication.class, args); + } } diff --git a/src/main/java/com/reservation/demo/api/auth/AuthController.java b/src/main/java/com/reservation/demo/api/auth/AuthController.java new file mode 100644 index 0000000..57eb657 --- /dev/null +++ b/src/main/java/com/reservation/demo/api/auth/AuthController.java @@ -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); + } +} diff --git a/src/main/java/com/reservation/demo/api/auth/AuthDtos.java b/src/main/java/com/reservation/demo/api/auth/AuthDtos.java new file mode 100644 index 0000000..889c2e4 --- /dev/null +++ b/src/main/java/com/reservation/demo/api/auth/AuthDtos.java @@ -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 + ) { + } +} diff --git a/src/main/java/com/reservation/demo/api/concert/ConcertController.java b/src/main/java/com/reservation/demo/api/concert/ConcertController.java new file mode 100644 index 0000000..73e0892 --- /dev/null +++ b/src/main/java/com/reservation/demo/api/concert/ConcertController.java @@ -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 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); + } +} diff --git a/src/main/java/com/reservation/demo/api/concert/ConcertDtos.java b/src/main/java/com/reservation/demo/api/concert/ConcertDtos.java new file mode 100644 index 0000000..e5df080 --- /dev/null +++ b/src/main/java/com/reservation/demo/api/concert/ConcertDtos.java @@ -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 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 seats + ) { + } +} diff --git a/src/main/java/com/reservation/demo/api/organizer/OrganizerController.java b/src/main/java/com/reservation/demo/api/organizer/OrganizerController.java new file mode 100644 index 0000000..7568d23 --- /dev/null +++ b/src/main/java/com/reservation/demo/api/organizer/OrganizerController.java @@ -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); + } +} diff --git a/src/main/java/com/reservation/demo/api/organizer/OrganizerDtos.java b/src/main/java/com/reservation/demo/api/organizer/OrganizerDtos.java new file mode 100644 index 0000000..a763379 --- /dev/null +++ b/src/main/java/com/reservation/demo/api/organizer/OrganizerDtos.java @@ -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 performances, + @NotEmpty @Valid List seats + ) { + } + + public record CreateConcertResponse( + Long concertId, + int performanceCount, + int seatCount + ) { + } +} diff --git a/src/main/java/com/reservation/demo/api/reservation/ReservationController.java b/src/main/java/com/reservation/demo/api/reservation/ReservationController.java new file mode 100644 index 0000000..7f83378 --- /dev/null +++ b/src/main/java/com/reservation/demo/api/reservation/ReservationController.java @@ -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 getMyReservations() { + Long userId = SecurityUtils.getCurrentUserId(); + return reservationService.getMyReservations(userId); + } +} diff --git a/src/main/java/com/reservation/demo/api/reservation/ReservationDtos.java b/src/main/java/com/reservation/demo/api/reservation/ReservationDtos.java new file mode 100644 index 0000000..2242f0f --- /dev/null +++ b/src/main/java/com/reservation/demo/api/reservation/ReservationDtos.java @@ -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 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 seats + ) { + } +} diff --git a/src/main/java/com/reservation/demo/common/exception/BusinessException.java b/src/main/java/com/reservation/demo/common/exception/BusinessException.java new file mode 100644 index 0000000..79cf0ab --- /dev/null +++ b/src/main/java/com/reservation/demo/common/exception/BusinessException.java @@ -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; + } +} diff --git a/src/main/java/com/reservation/demo/common/exception/ErrorCode.java b/src/main/java/com/reservation/demo/common/exception/ErrorCode.java new file mode 100644 index 0000000..b055a54 --- /dev/null +++ b/src/main/java/com/reservation/demo/common/exception/ErrorCode.java @@ -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; +} diff --git a/src/main/java/com/reservation/demo/common/exception/ErrorResponse.java b/src/main/java/com/reservation/demo/common/exception/ErrorResponse.java new file mode 100644 index 0000000..2033178 --- /dev/null +++ b/src/main/java/com/reservation/demo/common/exception/ErrorResponse.java @@ -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 + ); + } +} diff --git a/src/main/java/com/reservation/demo/common/exception/GlobalExceptionHandler.java b/src/main/java/com/reservation/demo/common/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..2ce6c1d --- /dev/null +++ b/src/main/java/com/reservation/demo/common/exception/GlobalExceptionHandler.java @@ -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 handleBusinessException(BusinessException exception) { + ErrorCode errorCode = exception.getErrorCode(); + return ResponseEntity + .status(errorCode.getStatus()) + .body(ErrorResponse.of(errorCode, exception.getMessage())); + } + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity 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 handleException(Exception exception) { + log.error("Unhandled exception", exception); + return ResponseEntity + .internalServerError() + .body(ErrorResponse.of(ErrorCode.INTERNAL_ERROR)); + } +} diff --git a/src/main/java/com/reservation/demo/common/security/SecurityUtils.java b/src/main/java/com/reservation/demo/common/security/SecurityUtils.java new file mode 100644 index 0000000..2186f45 --- /dev/null +++ b/src/main/java/com/reservation/demo/common/security/SecurityUtils.java @@ -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(); + } +} diff --git a/src/main/java/com/reservation/demo/config/JwtAuthenticationFilter.java b/src/main/java/com/reservation/demo/config/JwtAuthenticationFilter.java new file mode 100644 index 0000000..9f9fade --- /dev/null +++ b/src/main/java/com/reservation/demo/config/JwtAuthenticationFilter.java @@ -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); + } +} diff --git a/src/main/java/com/reservation/demo/config/JwtConfig.java b/src/main/java/com/reservation/demo/config/JwtConfig.java new file mode 100644 index 0000000..4ecfca8 --- /dev/null +++ b/src/main/java/com/reservation/demo/config/JwtConfig.java @@ -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(); + } +} diff --git a/src/main/java/com/reservation/demo/config/JwtProperties.java b/src/main/java/com/reservation/demo/config/JwtProperties.java new file mode 100644 index 0000000..730e0d4 --- /dev/null +++ b/src/main/java/com/reservation/demo/config/JwtProperties.java @@ -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) { +} diff --git a/src/main/java/com/reservation/demo/config/JwtTokenProvider.java b/src/main/java/com/reservation/demo/config/JwtTokenProvider.java new file mode 100644 index 0000000..7de9c24 --- /dev/null +++ b/src/main/java/com/reservation/demo/config/JwtTokenProvider.java @@ -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); + } +} diff --git a/src/main/java/com/reservation/demo/config/OpenApiConfig.java b/src/main/java/com/reservation/demo/config/OpenApiConfig.java new file mode 100644 index 0000000..b24c57a --- /dev/null +++ b/src/main/java/com/reservation/demo/config/OpenApiConfig.java @@ -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"))); + } +} diff --git a/src/main/java/com/reservation/demo/config/SecurityConfig.java b/src/main/java/com/reservation/demo/config/SecurityConfig.java new file mode 100644 index 0000000..f01ac58 --- /dev/null +++ b/src/main/java/com/reservation/demo/config/SecurityConfig.java @@ -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(); + } +} diff --git a/src/main/java/com/reservation/demo/domain/concert/Concert.java b/src/main/java/com/reservation/demo/domain/concert/Concert.java new file mode 100644 index 0000000..e73feab --- /dev/null +++ b/src/main/java/com/reservation/demo/domain/concert/Concert.java @@ -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(); + } + +} diff --git a/src/main/java/com/reservation/demo/domain/concert/Performance.java b/src/main/java/com/reservation/demo/domain/concert/Performance.java new file mode 100644 index 0000000..4d52f42 --- /dev/null +++ b/src/main/java/com/reservation/demo/domain/concert/Performance.java @@ -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(); + } +} diff --git a/src/main/java/com/reservation/demo/domain/reservation/Reservation.java b/src/main/java/com/reservation/demo/domain/reservation/Reservation.java new file mode 100644 index 0000000..2216c9f --- /dev/null +++ b/src/main/java/com/reservation/demo/domain/reservation/Reservation.java @@ -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 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; + } +} diff --git a/src/main/java/com/reservation/demo/domain/reservation/ReservationItem.java b/src/main/java/com/reservation/demo/domain/reservation/ReservationItem.java new file mode 100644 index 0000000..0025619 --- /dev/null +++ b/src/main/java/com/reservation/demo/domain/reservation/ReservationItem.java @@ -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; + } +} diff --git a/src/main/java/com/reservation/demo/domain/reservation/ReservationStatus.java b/src/main/java/com/reservation/demo/domain/reservation/ReservationStatus.java new file mode 100644 index 0000000..0ed4889 --- /dev/null +++ b/src/main/java/com/reservation/demo/domain/reservation/ReservationStatus.java @@ -0,0 +1,6 @@ +package com.reservation.demo.domain.reservation; + +public enum ReservationStatus { + CONFIRMED, + CANCELLED +} diff --git a/src/main/java/com/reservation/demo/domain/seat/Seat.java b/src/main/java/com/reservation/demo/domain/seat/Seat.java new file mode 100644 index 0000000..150adf9 --- /dev/null +++ b/src/main/java/com/reservation/demo/domain/seat/Seat.java @@ -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(); + } +} diff --git a/src/main/java/com/reservation/demo/domain/seat/SeatInventory.java b/src/main/java/com/reservation/demo/domain/seat/SeatInventory.java new file mode 100644 index 0000000..af0556a --- /dev/null +++ b/src/main/java/com/reservation/demo/domain/seat/SeatInventory.java @@ -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(); + } +} diff --git a/src/main/java/com/reservation/demo/domain/seat/SeatInventoryStatus.java b/src/main/java/com/reservation/demo/domain/seat/SeatInventoryStatus.java new file mode 100644 index 0000000..82cd24c --- /dev/null +++ b/src/main/java/com/reservation/demo/domain/seat/SeatInventoryStatus.java @@ -0,0 +1,6 @@ +package com.reservation.demo.domain.seat; + +public enum SeatInventoryStatus { + AVAILABLE, + SOLD +} diff --git a/src/main/java/com/reservation/demo/domain/user/Role.java b/src/main/java/com/reservation/demo/domain/user/Role.java new file mode 100644 index 0000000..d9dec84 --- /dev/null +++ b/src/main/java/com/reservation/demo/domain/user/Role.java @@ -0,0 +1,6 @@ +package com.reservation.demo.domain.user; + +public enum Role { + USER, + ORGANIZER +} diff --git a/src/main/java/com/reservation/demo/domain/user/User.java b/src/main/java/com/reservation/demo/domain/user/User.java new file mode 100644 index 0000000..a6fed1b --- /dev/null +++ b/src/main/java/com/reservation/demo/domain/user/User.java @@ -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(); + } +} diff --git a/src/main/java/com/reservation/demo/repository/ConcertRepository.java b/src/main/java/com/reservation/demo/repository/ConcertRepository.java new file mode 100644 index 0000000..d4e0ac8 --- /dev/null +++ b/src/main/java/com/reservation/demo/repository/ConcertRepository.java @@ -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 { + + @Query("SELECT c FROM Concert c JOIN FETCH c.organizer ORDER BY c.createdAt DESC") + List findAllWithOrganizer(); + + @Query("SELECT c FROM Concert c JOIN FETCH c.organizer WHERE c.id = :id") + Optional findByIdWithOrganizer(@Param("id") Long id); +} diff --git a/src/main/java/com/reservation/demo/repository/PerformanceRepository.java b/src/main/java/com/reservation/demo/repository/PerformanceRepository.java new file mode 100644 index 0000000..ad3396c --- /dev/null +++ b/src/main/java/com/reservation/demo/repository/PerformanceRepository.java @@ -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 { + + List findAllByConcertId(Long concertId); + + @Query("SELECT p FROM Performance p JOIN FETCH p.concert WHERE p.id = :id") + Optional findByIdWithConcert(@Param("id") Long id); +} diff --git a/src/main/java/com/reservation/demo/repository/ReservationRepository.java b/src/main/java/com/reservation/demo/repository/ReservationRepository.java new file mode 100644 index 0000000..f6a0bcb --- /dev/null +++ b/src/main/java/com/reservation/demo/repository/ReservationRepository.java @@ -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 { + + @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 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 findByIdAndUserIdWithDetails(@Param("id") Long id, @Param("userId") Long userId); +} diff --git a/src/main/java/com/reservation/demo/repository/SeatInventoryRepository.java b/src/main/java/com/reservation/demo/repository/SeatInventoryRepository.java new file mode 100644 index 0000000..a04a523 --- /dev/null +++ b/src/main/java/com/reservation/demo/repository/SeatInventoryRepository.java @@ -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 { + + List 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 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 findAllByIdInForUpdate(@Param("ids") List ids); +} diff --git a/src/main/java/com/reservation/demo/repository/SeatRepository.java b/src/main/java/com/reservation/demo/repository/SeatRepository.java new file mode 100644 index 0000000..db72191 --- /dev/null +++ b/src/main/java/com/reservation/demo/repository/SeatRepository.java @@ -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 { + + List findAllByConcertId(Long concertId); +} diff --git a/src/main/java/com/reservation/demo/repository/UserRepository.java b/src/main/java/com/reservation/demo/repository/UserRepository.java new file mode 100644 index 0000000..9da4e06 --- /dev/null +++ b/src/main/java/com/reservation/demo/repository/UserRepository.java @@ -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 { + + boolean existsByEmail(String email); + + Optional findByEmail(String email); +} diff --git a/src/main/java/com/reservation/demo/service/AuthService.java b/src/main/java/com/reservation/demo/service/AuthService.java new file mode 100644 index 0000000..94f98cd --- /dev/null +++ b/src/main/java/com/reservation/demo/service/AuthService.java @@ -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); + } +} diff --git a/src/main/java/com/reservation/demo/service/ConcertService.java b/src/main/java/com/reservation/demo/service/ConcertService.java new file mode 100644 index 0000000..8f0d87b --- /dev/null +++ b/src/main/java/com/reservation/demo/service/ConcertService.java @@ -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 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 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 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() + ); + } +} diff --git a/src/main/java/com/reservation/demo/service/OrganizerService.java b/src/main/java/com/reservation/demo/service/OrganizerService.java new file mode 100644 index 0000000..21d319b --- /dev/null +++ b/src/main/java/com/reservation/demo/service/OrganizerService.java @@ -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 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 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()); + } +} diff --git a/src/main/java/com/reservation/demo/service/ReservationService.java b/src/main/java/com/reservation/demo/service/ReservationService.java new file mode 100644 index 0000000..1eca9d8 --- /dev/null +++ b/src/main/java/com/reservation/demo/service/ReservationService.java @@ -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 sortedIds = request.seatInventoryIds().stream() + .distinct() + .sorted() + .toList(); + + if (sortedIds.isEmpty()) { + throw new BusinessException(ErrorCode.INVALID_REQUEST, "예약할 좌석을 선택해 주세요."); + } + + List 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 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 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 + ); + } +} diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties deleted file mode 100644 index 2109a44..0000000 --- a/src/main/resources/application.properties +++ /dev/null @@ -1 +0,0 @@ -spring.application.name=demo diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml new file mode 100644 index 0000000..6705d65 --- /dev/null +++ b/src/main/resources/application.yml @@ -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 diff --git a/src/main/resources/db/migration/V1__init_schema.sql b/src/main/resources/db/migration/V1__init_schema.sql new file mode 100644 index 0000000..f8ac196 --- /dev/null +++ b/src/main/resources/db/migration/V1__init_schema.sql @@ -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; diff --git a/src/test/java/com/reservation/demo/DemoApplicationTests.java b/src/test/java/com/reservation/demo/DemoApplicationTests.java index ce511fc..ac915f2 100644 --- a/src/test/java/com/reservation/demo/DemoApplicationTests.java +++ b/src/test/java/com/reservation/demo/DemoApplicationTests.java @@ -1,15 +1,33 @@ package com.reservation.demo; +import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Test; 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 +@Testcontainers +@Tag("integration") class DemoApplicationTests { - @Test - void contextLoads() { - } + @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 + void contextLoads() { + } } diff --git a/src/test/java/com/reservation/demo/TestDemoApplication.java b/src/test/java/com/reservation/demo/TestDemoApplication.java deleted file mode 100644 index eaf940d..0000000 --- a/src/test/java/com/reservation/demo/TestDemoApplication.java +++ /dev/null @@ -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); - } - -} diff --git a/src/test/java/com/reservation/demo/TestcontainersConfiguration.java b/src/test/java/com/reservation/demo/TestcontainersConfiguration.java deleted file mode 100644 index 86020ed..0000000 --- a/src/test/java/com/reservation/demo/TestcontainersConfiguration.java +++ /dev/null @@ -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")); - } - -} diff --git a/src/test/java/com/reservation/demo/domain/seat/SeatInventoryTest.java b/src/test/java/com/reservation/demo/domain/seat/SeatInventoryTest.java new file mode 100644 index 0000000..c2e67d9 --- /dev/null +++ b/src/test/java/com/reservation/demo/domain/seat/SeatInventoryTest.java @@ -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(); + } +} diff --git a/src/test/java/com/reservation/demo/service/ReservationConcurrencyIntegrationTest.java b/src/test/java/com/reservation/demo/service/ReservationConcurrencyIntegrationTest.java new file mode 100644 index 0000000..1feba9c --- /dev/null +++ b/src/test/java/com/reservation/demo/service/ReservationConcurrencyIntegrationTest.java @@ -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); + } +}