feat: implement reservation MVP backend

This commit is contained in:
bumpsoo 2026-06-09 23:48:38 +09:00
parent 85a6f1fbbc
commit 6fda098137
48 changed files with 1974 additions and 39 deletions

View file

@ -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);
}
}

View file

@ -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);
}
}

View 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
) {
}
}

View file

@ -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);
}
}

View file

@ -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
) {
}
}

View file

@ -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);
}
}

View file

@ -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
) {
}
}

View file

@ -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);
}
}

View file

@ -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
) {
}
}

View file

@ -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;
}
}

View file

@ -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;
}

View file

@ -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
);
}
}

View file

@ -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));
}
}

View file

@ -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();
}
}

View file

@ -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);
}
}

View 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();
}
}

View file

@ -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) {
}

View file

@ -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);
}
}

View 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")));
}
}

View file

@ -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();
}
}

View file

@ -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();
}
}

View file

@ -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();
}
}

View file

@ -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;
}
}

View file

@ -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;
}
}

View file

@ -0,0 +1,6 @@
package com.reservation.demo.domain.reservation;
public enum ReservationStatus {
CONFIRMED,
CANCELLED
}

View 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();
}
}

View file

@ -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();
}
}

View file

@ -0,0 +1,6 @@
package com.reservation.demo.domain.seat;
public enum SeatInventoryStatus {
AVAILABLE,
SOLD
}

View file

@ -0,0 +1,6 @@
package com.reservation.demo.domain.user;
public enum Role {
USER,
ORGANIZER
}

View 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();
}
}

View file

@ -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);
}

View file

@ -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);
}

View file

@ -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);
}

View file

@ -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);
}

View file

@ -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);
}

View file

@ -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);
}

View 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);
}
}

View file

@ -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()
);
}
}

View file

@ -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());
}
}

View file

@ -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
);
}
}

View file

@ -1 +0,0 @@
spring.application.name=demo

View 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

View 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;

View file

@ -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() {
}
}

View file

@ -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);
}
}

View file

@ -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"));
}
}

View file

@ -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();
}
}

View file

@ -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);
}
}