feat: implement reservation MVP backend
This commit is contained in:
parent
85a6f1fbbc
commit
6fda098137
48 changed files with 1974 additions and 39 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
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() {
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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