diff --git a/README.md b/README.md index 4229b42..ce21d00 100644 --- a/README.md +++ b/README.md @@ -9,3 +9,6 @@ CEOS 22기 백엔드 스터디 - CGV 클론 코딩 프로젝트 - wk3 - [✨ wk3: 인증 방법 정리](https://github.com/yooniicode/spring-cgv-22nd/wiki/wk3:-%EC%9D%B8%EC%A6%9D-%EB%B0%A9%EB%B2%95-%EC%A0%95%EB%A6%AC) - [✨ wk3: 로그인 구현하기](https://github.com/yooniicode/spring-cgv-22nd/wiki/wk3;-%EB%A1%9C%EA%B7%B8%EC%9D%B8-%EA%B5%AC%ED%98%84%ED%95%98%EA%B8%B0) + +- wk4 + - [✨ wk4: 동시성 문제 해결(락)](https://github.com/yooniicode/spring-cgv-22nd/wiki/wk4-:-%EB%8F%99%EC%8B%9C%EC%84%B1-%ED%95%B4%EA%B2%B0-%EB%B0%A9%EB%B2%95) diff --git a/cgvclone/build.gradle b/cgvclone/build.gradle index 191a8a6..e047307 100644 --- a/cgvclone/build.gradle +++ b/cgvclone/build.gradle @@ -34,7 +34,6 @@ dependencies { testAnnotationProcessor 'org.projectlombok:lombok' // runtimeOnly 'com.h2database:h2' - // 버전 명시 안하면 안되는 이유는 멀까.. .. ㅜㅜ implementation 'io.jsonwebtoken:jjwt-api:0.11.5' runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' diff --git a/cgvclone/src/main/java/com/ceos22/spring_boot/common/auth/controller/AuthController.java b/cgvclone/src/main/java/com/ceos22/spring_boot/common/auth/controller/AuthController.java index 341a761..d3c140a 100644 --- a/cgvclone/src/main/java/com/ceos22/spring_boot/common/auth/controller/AuthController.java +++ b/cgvclone/src/main/java/com/ceos22/spring_boot/common/auth/controller/AuthController.java @@ -1,13 +1,15 @@ package com.ceos22.spring_boot.common.auth.controller; +import com.ceos22.spring_boot.common.auth.dto.LoginRequestDto; +import com.ceos22.spring_boot.common.auth.dto.LoginResponseDto; import com.ceos22.spring_boot.common.auth.security.jwt.JwtTokenProvider; +import com.ceos22.spring_boot.common.response.ApiResponse; +import com.ceos22.spring_boot.common.response.status.SuccessStatus; import com.ceos22.spring_boot.domain.user.User; import com.ceos22.spring_boot.domain.user.UserService; import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.media.Content; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.security.authentication.*; import org.springframework.security.core.Authentication; @@ -16,58 +18,36 @@ @Tag(name = "Auth API", description = "인증 관련 API") @RestController @RequestMapping("/auth") +@RequiredArgsConstructor public class AuthController { private final AuthenticationManager am; private final JwtTokenProvider tokenProvider; private final UserService userService; - public AuthController(AuthenticationManager am, JwtTokenProvider tokenProvider, UserService userService) { - this.am = am; - this.tokenProvider = tokenProvider; - this.userService = userService; - } - @Operation( summary = "로그인", - description = "사용자 이름과 비밀번호로 로그인 후 JWT 액세스 토큰을 발급받습니다.", - responses = { - @ApiResponse( - responseCode = "200", - description = "로그인 성공, 토큰 발급", - content = @Content( - schema = @Schema(implementation = TokenResponse.class) - ) - ), - @ApiResponse(responseCode = "401", description = "인증 실패") - } + description = "사용자 이름과 비밀번호로 로그인 후 JWT 액세스 토큰을 발급받습니다." ) @PostMapping("/login") - public ResponseEntity login(@RequestBody LoginRequest req) { + public ResponseEntity> login(@RequestBody LoginRequestDto req) { Authentication auth = am.authenticate( new UsernamePasswordAuthenticationToken(req.username(), req.password()) ); + String token = tokenProvider.issueToken(auth); - return ResponseEntity.ok(TokenResponse.bearer(token)); - } - public record LoginRequest(String username, String password) {} - public record TokenResponse(String accessToken, String tokenType) { - public static TokenResponse bearer(String t) { return new TokenResponse(t, "Bearer"); } + User user = userService.findByUsername(req.username()); + long expiresIn = tokenProvider.getExpiration(token); + LoginResponseDto response = LoginResponseDto.of(token, user, expiresIn); + return ApiResponse.onSuccess(SuccessStatus.LOGIN_SUCCESS, response); } + + @Operation( summary = "회원가입", - description = "새로운 사용자를 등록합니다. 비밀번호는 솔트와 함께 해시되어 저장됩니다.", - responses = { - @ApiResponse( - responseCode = "200", - description = "회원가입 성공", - content = @Content(schema = @Schema(implementation = AuthController.RegisterResponse.class)) - ), - @ApiResponse(responseCode = "400", description = "잘못된 요청"), - @ApiResponse(responseCode = "409", description = "중복된 사용자") - } + description = "새로운 사용자를 등록합니다. 비밀번호는 솔트와 함께 해시되어 저장됩니다." ) @PostMapping("/register") public ResponseEntity register(@RequestBody AuthController.RegisterRequest req) { diff --git a/cgvclone/src/main/java/com/ceos22/spring_boot/common/auth/dto/LoginRequestDto.java b/cgvclone/src/main/java/com/ceos22/spring_boot/common/auth/dto/LoginRequestDto.java new file mode 100644 index 0000000..e7f6f10 --- /dev/null +++ b/cgvclone/src/main/java/com/ceos22/spring_boot/common/auth/dto/LoginRequestDto.java @@ -0,0 +1,14 @@ +package com.ceos22.spring_boot.common.auth.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; + +public record LoginRequestDto( + @NotBlank + @Schema(description = "사용자 이름", example = "evan523") + String username, + + @NotBlank + @Schema(description = "비밀번호", example = "password123") + String password +) {} diff --git a/cgvclone/src/main/java/com/ceos22/spring_boot/common/auth/dto/LoginResponseDto.java b/cgvclone/src/main/java/com/ceos22/spring_boot/common/auth/dto/LoginResponseDto.java new file mode 100644 index 0000000..e661a54 --- /dev/null +++ b/cgvclone/src/main/java/com/ceos22/spring_boot/common/auth/dto/LoginResponseDto.java @@ -0,0 +1,27 @@ +package com.ceos22.spring_boot.common.auth.dto; + +import com.ceos22.spring_boot.domain.user.User; + +import java.util.UUID; + +public record LoginResponseDto( + String accessToken, + String tokenType, + UUID publicId, + String username, + String name, + String email, + long expiresIn +) { + public static LoginResponseDto of(String token, User user, long expiresIn) { + return new LoginResponseDto( + token, + "Bearer", + user.getPublicId(), + user.getUsername(), + user.getName(), + user.getEmail(), + expiresIn + ); + } +} diff --git a/cgvclone/src/main/java/com/ceos22/spring_boot/common/auth/properties/JwtProperties.java b/cgvclone/src/main/java/com/ceos22/spring_boot/common/auth/properties/JwtProperties.java index ec3ec6b..58ced6b 100644 --- a/cgvclone/src/main/java/com/ceos22/spring_boot/common/auth/properties/JwtProperties.java +++ b/cgvclone/src/main/java/com/ceos22/spring_boot/common/auth/properties/JwtProperties.java @@ -2,5 +2,7 @@ import org.springframework.boot.context.properties.ConfigurationProperties; +import java.time.Duration; + @ConfigurationProperties(prefix = "jwt") -public record JwtProperties(String secret, long accessTokenValidityInSeconds) {} +public record JwtProperties(String secret, Duration accessTokenValidity) {} diff --git a/cgvclone/src/main/java/com/ceos22/spring_boot/common/auth/security/authentication/SaltAuthenticationProvider.java b/cgvclone/src/main/java/com/ceos22/spring_boot/common/auth/security/authentication/SaltAuthenticationProvider.java index a1345f9..65cce21 100644 --- a/cgvclone/src/main/java/com/ceos22/spring_boot/common/auth/security/authentication/SaltAuthenticationProvider.java +++ b/cgvclone/src/main/java/com/ceos22/spring_boot/common/auth/security/authentication/SaltAuthenticationProvider.java @@ -28,19 +28,19 @@ public Authentication authenticate(Authentication authentication) throws Authent String username = String.valueOf(authentication.getPrincipal()); String rawPassword = String.valueOf(authentication.getCredentials()); - User u = users.findByUsername(username) + User user = users.findByUsername(username) .orElseThrow(() -> new AuthFailureException(ErrorStatus.USER_NOT_FOUND, "존재하지 않는 사용자입니다.") ); - if (!saltService.matches(rawPassword, u.getSalt(), u.getPasswordHash())) { + if (!saltService.matches(rawPassword, user.getSalt(), user.getPasswordHash())) { throw new AuthFailureException(ErrorStatus.INVALID_CREDENTIALS, "아이디 또는 비밀번호가 올바르지 않습니다."); } var authorities = List.of(() -> "ROLE_USER"); var principal = new CustomUserPrincipal( - u.getUserId(), u.getPublicId(), u.getUsername(), - u.getPasswordHash(), u.getName(), u.getEmail(), authorities + user.getUserId(), user.getPublicId(), user.getUsername(), + user.getPasswordHash(), user.getName(), user.getEmail(), authorities ); return new UsernamePasswordAuthenticationToken(principal, null, authorities); } diff --git a/cgvclone/src/main/java/com/ceos22/spring_boot/common/auth/security/jwt/JwtAuthEntryPoint.java b/cgvclone/src/main/java/com/ceos22/spring_boot/common/auth/security/jwt/JwtAuthEntryPoint.java index 861032c..d49cfec 100644 --- a/cgvclone/src/main/java/com/ceos22/spring_boot/common/auth/security/jwt/JwtAuthEntryPoint.java +++ b/cgvclone/src/main/java/com/ceos22/spring_boot/common/auth/security/jwt/JwtAuthEntryPoint.java @@ -6,24 +6,28 @@ import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; import org.springframework.security.core.AuthenticationException; import org.springframework.security.web.AuthenticationEntryPoint; import org.springframework.stereotype.Component; - +import lombok.extern.slf4j.Slf4j; import java.io.IOException; +@Slf4j @Component +@RequiredArgsConstructor public class JwtAuthEntryPoint implements AuthenticationEntryPoint { - private final ObjectMapper om = new ObjectMapper(); + private final ObjectMapper objectMapper; @Override - public void commence(HttpServletRequest req, - HttpServletResponse res, - AuthenticationException ex) throws IOException { + public void commence(HttpServletRequest request, + HttpServletResponse response, + AuthenticationException exception) throws IOException { ErrorStatus status; - String message = ex.getMessage(); - if (ex instanceof AuthFailureException afe) { + log.warn("Unauthorized access: {}", exception.getMessage()); + + if (exception instanceof AuthFailureException afe) { status = afe.getErrorStatus(); } else { status = ErrorStatus._UNAUTHORIZED; @@ -31,8 +35,8 @@ public void commence(HttpServletRequest req, var responseEntity = ApiResponse.onFailure(status); - res.setStatus(status.getHttpStatus().value()); - res.setContentType("application/json;charset=UTF-8"); - om.writeValue(res.getWriter(), responseEntity.getBody()); + response.setStatus(status.getHttpStatus().value()); + response.setContentType("application/json;charset=UTF-8"); + objectMapper.writeValue(response.getWriter(), responseEntity.getBody()); } } diff --git a/cgvclone/src/main/java/com/ceos22/spring_boot/common/auth/security/jwt/JwtAuthenticationFilter.java b/cgvclone/src/main/java/com/ceos22/spring_boot/common/auth/security/jwt/JwtAuthenticationFilter.java index 0c8e629..6822a8c 100644 --- a/cgvclone/src/main/java/com/ceos22/spring_boot/common/auth/security/jwt/JwtAuthenticationFilter.java +++ b/cgvclone/src/main/java/com/ceos22/spring_boot/common/auth/security/jwt/JwtAuthenticationFilter.java @@ -29,8 +29,10 @@ protected void doFilterInternal(HttpServletRequest request, String token = resolveToken(request); - log.info("Authorization Header = {}", request.getHeader("Authorization")); - log.info("Parsed token = {}", token); + if (log.isDebugEnabled()) { + log.debug("Authorization Header = {}", request.getHeader("Authorization")); + log.debug("Parsed token = {}", token); + } if (token != null && tokenProvider.validate(token)) { var authentication = tokenProvider.getAuthentication(token); @@ -46,7 +48,10 @@ protected void doFilterInternal(HttpServletRequest request, private String resolveToken(HttpServletRequest req) { String header = req.getHeader("Authorization"); if (header != null && header.startsWith("Bearer ")) { - return header.substring(7); + String token = header.substring(7); + if (!token.isBlank()) { + return token; + } } return null; } diff --git a/cgvclone/src/main/java/com/ceos22/spring_boot/common/auth/security/jwt/JwtTokenProvider.java b/cgvclone/src/main/java/com/ceos22/spring_boot/common/auth/security/jwt/JwtTokenProvider.java index 7f8bb78..c4b9764 100644 --- a/cgvclone/src/main/java/com/ceos22/spring_boot/common/auth/security/jwt/JwtTokenProvider.java +++ b/cgvclone/src/main/java/com/ceos22/spring_boot/common/auth/security/jwt/JwtTokenProvider.java @@ -11,12 +11,14 @@ import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; import org.springframework.security.core.Authentication; import org.springframework.security.core.authority.SimpleGrantedAuthority; +import lombok.extern.slf4j.Slf4j; import java.nio.charset.StandardCharsets; import java.security.Key; import java.util.*; import java.util.stream.Collectors; +@Slf4j public class JwtTokenProvider { private static final String AUTH_CLAIM = "auth"; @@ -26,7 +28,7 @@ public class JwtTokenProvider { public JwtTokenProvider(JwtProperties props, UserRepository users) { this.key = Keys.hmacShaKeyFor(props.secret().getBytes(StandardCharsets.UTF_8)); - this.validityMillis = props.accessTokenValidityInSeconds() * 1000L; + this.validityMillis = props.accessTokenValidity().toMillis(); this.users = users; } @@ -37,8 +39,17 @@ public String issueToken(Authentication authentication) { .map(a -> a.getAuthority()) .collect(Collectors.joining(",")); + Object principal = authentication.getPrincipal(); + + String subject; + if (principal instanceof CustomUserPrincipal customPrincipal) { + subject = customPrincipal.getPublicId().toString(); + } else { + subject = authentication.getName(); + } + return Jwts.builder() - .setSubject(authentication.getName()) + .setSubject(subject) .claim(AUTH_CLAIM, authorities) .setIssuedAt(now) .setExpiration(expiry) @@ -48,7 +59,15 @@ public String issueToken(Authentication authentication) { public Authentication getAuthentication(String token) { Claims claims = parseClaims(token); - String username = claims.getSubject(); + + String subject = claims.getSubject(); + UUID publicId; + try { + publicId = UUID.fromString(subject); + } catch (IllegalArgumentException e) { + throw new GeneralException(ErrorStatus.INVALID_TOKEN, "잘못된 토큰 형식입니다: " + subject); + } + String authStr = claims.get(AUTH_CLAIM, String.class); var authorities = Optional.ofNullable(authStr) .stream() @@ -56,8 +75,9 @@ public Authentication getAuthentication(String token) { .filter(s -> !s.isBlank()) .map(SimpleGrantedAuthority::new) .collect(Collectors.toList()); - User u = users.findByUsername(username) - .orElseThrow(() -> new GeneralException(ErrorStatus._BAD_REQUEST, "사용자를 찾을 수 없습니다: " + username)); + User u = users.findByPublicId(publicId) + .orElseThrow(() -> new GeneralException(ErrorStatus._BAD_REQUEST, "사용자를 찾을 수 없습니다: " + publicId)); + var principal = new CustomUserPrincipal( u.getUserId(), @@ -77,14 +97,23 @@ public boolean validate(String token) { parseClaims(token); return true; } catch (ExpiredJwtException e) { - return false; + log.info("JWT expired at {}", e.getClaims().getExpiration()); + throw new GeneralException(ErrorStatus.TOKEN_EXPIRED, "토큰이 만료되었습니다."); } catch (JwtException | IllegalArgumentException e) { - return false; + throw new GeneralException(ErrorStatus.INVALID_TOKEN, "유효하지 않은 토큰입니다."); } } + private Claims parseClaims(String token) { return Jwts.parserBuilder().setSigningKey(key).build() .parseClaimsJws(token).getBody(); } + + public long getExpiration(String token) { + Claims claims = parseClaims(token); + Date expiration = claims.getExpiration(); + return (expiration.getTime() - System.currentTimeMillis()) / 1000L; + } + } diff --git a/cgvclone/src/main/java/com/ceos22/spring_boot/common/enums/PaymentMethod.java b/cgvclone/src/main/java/com/ceos22/spring_boot/common/enums/PaymentMethod.java index 7165b6a..6594ad3 100644 --- a/cgvclone/src/main/java/com/ceos22/spring_boot/common/enums/PaymentMethod.java +++ b/cgvclone/src/main/java/com/ceos22/spring_boot/common/enums/PaymentMethod.java @@ -1,5 +1,5 @@ package com.ceos22.spring_boot.common.enums; public enum PaymentMethod { - 카드, 현금, 네이버페이, 카카오페이 + CARD, CASH, NAVER, KAKAO } diff --git a/cgvclone/src/main/java/com/ceos22/spring_boot/common/exception/GlobalExceptionHandler.java b/cgvclone/src/main/java/com/ceos22/spring_boot/common/exception/GlobalExceptionHandler.java index fc8d422..ac32461 100644 --- a/cgvclone/src/main/java/com/ceos22/spring_boot/common/exception/GlobalExceptionHandler.java +++ b/cgvclone/src/main/java/com/ceos22/spring_boot/common/exception/GlobalExceptionHandler.java @@ -37,11 +37,6 @@ public ResponseEntity> handleNoResourceFoundException(NoResour return ApiResponse.onFailure(ErrorStatus._NOT_FOUND); } - @ExceptionHandler(Exception.class) - public ResponseEntity> handleException(Exception e) { - return ApiResponse.onFailure(ErrorStatus._INTERNAL_SERVER_ERROR); - } - @ExceptionHandler(GeneralException.class) public ResponseEntity> handleGeneralException(GeneralException e) { return ApiResponse.onFailure(e.getErrorStatus(), e.getMessage()); @@ -58,5 +53,9 @@ public ResponseEntity> handleAuthFailure(AuthFailureException return ApiResponse.onFailure(e.getErrorStatus(), e.getMessage()); } + @ExceptionHandler(Exception.class) + public ResponseEntity> handleException(Exception e) { + return ApiResponse.onFailure(ErrorStatus._INTERNAL_SERVER_ERROR); + } } \ No newline at end of file diff --git a/cgvclone/src/main/java/com/ceos22/spring_boot/common/response/ApiResponse.java b/cgvclone/src/main/java/com/ceos22/spring_boot/common/response/ApiResponse.java index fb33c96..d73fe9d 100644 --- a/cgvclone/src/main/java/com/ceos22/spring_boot/common/response/ApiResponse.java +++ b/cgvclone/src/main/java/com/ceos22/spring_boot/common/response/ApiResponse.java @@ -5,6 +5,7 @@ import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonPropertyOrder; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Getter; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; @@ -15,16 +16,24 @@ @Getter @RequiredArgsConstructor @JsonPropertyOrder({"isSuccess", "code", "message", "pageInfo", "result"}) +@Schema(description = "공통 API 응답 포맷") public class ApiResponse { + @Schema(description = "성공 여부", example = "true") @JsonProperty("isSuccess") private final Boolean isSuccess; + + @Schema(description = "응답 코드", example = "SUCCESS_200") private final String code; + + @Schema(description = "응답 메시지", example = "요청이 성공적으로 처리되었습니다.") private final String message; + @Schema(description = "페이지 정보 (페이징 응답일 때만 포함)", nullable = true) @JsonInclude(JsonInclude.Include.NON_NULL) private final PageInfo pageInfo; + @Schema(description = "결과 데이터", nullable = true) @JsonInclude(JsonInclude.Include.NON_NULL) private final T result; diff --git a/cgvclone/src/main/java/com/ceos22/spring_boot/common/response/status/ErrorStatus.java b/cgvclone/src/main/java/com/ceos22/spring_boot/common/response/status/ErrorStatus.java index accd829..2213a1e 100644 --- a/cgvclone/src/main/java/com/ceos22/spring_boot/common/response/status/ErrorStatus.java +++ b/cgvclone/src/main/java/com/ceos22/spring_boot/common/response/status/ErrorStatus.java @@ -24,12 +24,19 @@ public enum ErrorStatus { INVALID_CREDENTIALS(HttpStatus.BAD_REQUEST, "AUTH401", "아이디 또는 비밀번호가 올바르지 않습니다."), DUPLICATE_USERNAME(HttpStatus.CONFLICT, "AUTH402", "이미 존재하는 사용자명입니다."), DUPLICATE_EMAIL(HttpStatus.CONFLICT, "AUTH403", "이미 존재하는 이메일입니다."), + TOKEN_EXPIRED(HttpStatus.UNAUTHORIZED, "AUTH404", "만료된 토큰입니다."), + INVALID_TOKEN(HttpStatus.UNAUTHORIZED, "AUTH405", "유효하지 않은 토큰입니다."), + + FAVORITE_NOT_FOUND(HttpStatus.NOT_FOUND, "FAVORITE400", "즐겨찾기 내역이 없습니다."), + ALREADY_FAVORITED(HttpStatus.CONFLICT, "FAVORITE409", "이미 즐겨찾기된 영화입니다."), OUT_OF_STOCK(HttpStatus.FORBIDDEN, "STORE400", "재고가 부족합니다."), PRODUCT_NOT_FOUND(HttpStatus.NOT_FOUND, "STORE401", "상품이 존재하지 않습니다."), PRODUCT_NOT_AVAILABLE(HttpStatus.BAD_REQUEST, "STORE402", "판매 중지된 상품입니다."), + MOVIE_NOT_FOUND(HttpStatus.NOT_FOUND, "MOVIE400", "존재하지 않는 영화입니다."), + SEAT_ALREADY_RESERVED(HttpStatus.CONFLICT, "SEAT400", "이미 예약된 좌석입니다.") ; diff --git a/cgvclone/src/main/java/com/ceos22/spring_boot/common/response/status/SuccessStatus.java b/cgvclone/src/main/java/com/ceos22/spring_boot/common/response/status/SuccessStatus.java index 3d76bbf..285c456 100644 --- a/cgvclone/src/main/java/com/ceos22/spring_boot/common/response/status/SuccessStatus.java +++ b/cgvclone/src/main/java/com/ceos22/spring_boot/common/response/status/SuccessStatus.java @@ -8,7 +8,9 @@ @AllArgsConstructor public enum SuccessStatus { - _OK(HttpStatus.OK, "COMMON200", "성공입니다."); + _OK(HttpStatus.OK, "COMMON200", "성공입니다."), + LOGIN_SUCCESS(HttpStatus.OK, "LOGIN200", "로그인에 성공했습니다.") + ; private final HttpStatus httpStatus; private final String code; diff --git a/cgvclone/src/main/java/com/ceos22/spring_boot/config/SecurityConfig.java b/cgvclone/src/main/java/com/ceos22/spring_boot/config/SecurityConfig.java index 3eac8ac..c7f4bf5 100644 --- a/cgvclone/src/main/java/com/ceos22/spring_boot/config/SecurityConfig.java +++ b/cgvclone/src/main/java/com/ceos22/spring_boot/config/SecurityConfig.java @@ -15,6 +15,11 @@ import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.util.List; @Configuration public class SecurityConfig { @@ -33,6 +38,7 @@ public JwtTokenProvider jwtTokenProvider(JwtProperties props, UserRepository use @Bean public SecurityFilterChain filterChain(HttpSecurity http, JwtAuthenticationFilter jwtFilter, JwtAuthEntryPoint entryPoint) throws Exception { http.csrf(csrf -> csrf.disable()) + .cors(cors -> cors.configurationSource(corsConfigurationSource())) .sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .authorizeHttpRequests(auth -> auth .requestMatchers("/auth/**", "/swagger-ui.html", "/v3/api-docs/**", "/swagger-ui/**").permitAll() @@ -42,4 +48,24 @@ public SecurityFilterChain filterChain(HttpSecurity http, JwtAuthenticationFilte .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class); return http.build(); } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration config = new CorsConfiguration(); + + // 허용할 도메인 (프론트엔드 주소) + config.setAllowedOrigins(List.of( + "http://localhost:3000", // local dev + "https://front.vercel.app" // 아직 모름 + )); + + config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS")); + config.setAllowedHeaders(List.of("Authorization", "Content-Type", "Accept")); + config.setAllowCredentials(true); + config.setMaxAge(3600L); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", config); + return source; + } } diff --git a/cgvclone/src/main/java/com/ceos22/spring_boot/domain/movie/controller/FavoriteMovieController.java b/cgvclone/src/main/java/com/ceos22/spring_boot/domain/movie/controller/FavoriteMovieController.java index 04398bc..cecef8c 100644 --- a/cgvclone/src/main/java/com/ceos22/spring_boot/domain/movie/controller/FavoriteMovieController.java +++ b/cgvclone/src/main/java/com/ceos22/spring_boot/domain/movie/controller/FavoriteMovieController.java @@ -26,7 +26,7 @@ public ResponseEntity add(@PathVariable Long movieId, public ResponseEntity remove(@PathVariable Long movieId, @AuthenticationPrincipal CustomUserPrincipal me) { service.remove(me.getUserId(), movieId); - return ResponseEntity.noContent().build(); + return ResponseEntity.ok().build(); } @GetMapping("/count") // 해당 영화 찜 수 diff --git a/cgvclone/src/main/java/com/ceos22/spring_boot/domain/movie/controller/MovieController.java b/cgvclone/src/main/java/com/ceos22/spring_boot/domain/movie/controller/MovieController.java index 5a611e9..2e8af1e 100644 --- a/cgvclone/src/main/java/com/ceos22/spring_boot/domain/movie/controller/MovieController.java +++ b/cgvclone/src/main/java/com/ceos22/spring_boot/domain/movie/controller/MovieController.java @@ -2,7 +2,7 @@ import com.ceos22.spring_boot.common.response.ApiResponse; import com.ceos22.spring_boot.common.response.status.SuccessStatus; -import com.ceos22.spring_boot.domain.movie.MovieDto; +import com.ceos22.spring_boot.domain.movie.dto.MovieDto; import com.ceos22.spring_boot.domain.movie.service.MovieService; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; diff --git a/cgvclone/src/main/java/com/ceos22/spring_boot/domain/movie/MovieDto.java b/cgvclone/src/main/java/com/ceos22/spring_boot/domain/movie/dto/MovieDto.java similarity index 96% rename from cgvclone/src/main/java/com/ceos22/spring_boot/domain/movie/MovieDto.java rename to cgvclone/src/main/java/com/ceos22/spring_boot/domain/movie/dto/MovieDto.java index a05a241..2e2ea80 100644 --- a/cgvclone/src/main/java/com/ceos22/spring_boot/domain/movie/MovieDto.java +++ b/cgvclone/src/main/java/com/ceos22/spring_boot/domain/movie/dto/MovieDto.java @@ -1,4 +1,4 @@ -package com.ceos22.spring_boot.domain.movie; +package com.ceos22.spring_boot.domain.movie.dto; import com.ceos22.spring_boot.common.enums.Rating; import com.ceos22.spring_boot.domain.movie.entity.Movie; diff --git a/cgvclone/src/main/java/com/ceos22/spring_boot/domain/movie/FavoriteMovieRepository.java b/cgvclone/src/main/java/com/ceos22/spring_boot/domain/movie/repository/FavoriteMovieRepository.java similarity index 89% rename from cgvclone/src/main/java/com/ceos22/spring_boot/domain/movie/FavoriteMovieRepository.java rename to cgvclone/src/main/java/com/ceos22/spring_boot/domain/movie/repository/FavoriteMovieRepository.java index deb5fd9..8b5a20f 100644 --- a/cgvclone/src/main/java/com/ceos22/spring_boot/domain/movie/FavoriteMovieRepository.java +++ b/cgvclone/src/main/java/com/ceos22/spring_boot/domain/movie/repository/FavoriteMovieRepository.java @@ -1,4 +1,4 @@ -package com.ceos22.spring_boot.domain.movie; +package com.ceos22.spring_boot.domain.movie.repository; import com.ceos22.spring_boot.domain.movie.entity.FavoriteMovie; import org.springframework.data.jpa.repository.JpaRepository; diff --git a/cgvclone/src/main/java/com/ceos22/spring_boot/domain/movie/MovieRepository.java b/cgvclone/src/main/java/com/ceos22/spring_boot/domain/movie/repository/MovieRepository.java similarity index 77% rename from cgvclone/src/main/java/com/ceos22/spring_boot/domain/movie/MovieRepository.java rename to cgvclone/src/main/java/com/ceos22/spring_boot/domain/movie/repository/MovieRepository.java index 8b1b3a4..171e2ad 100644 --- a/cgvclone/src/main/java/com/ceos22/spring_boot/domain/movie/MovieRepository.java +++ b/cgvclone/src/main/java/com/ceos22/spring_boot/domain/movie/repository/MovieRepository.java @@ -1,4 +1,4 @@ -package com.ceos22.spring_boot.domain.movie; +package com.ceos22.spring_boot.domain.movie.repository; import com.ceos22.spring_boot.domain.movie.entity.Movie; import org.springframework.data.jpa.repository.JpaRepository; diff --git a/cgvclone/src/main/java/com/ceos22/spring_boot/domain/movie/service/FavoriteMovieService.java b/cgvclone/src/main/java/com/ceos22/spring_boot/domain/movie/service/FavoriteMovieService.java index f02cf1b..521326e 100644 --- a/cgvclone/src/main/java/com/ceos22/spring_boot/domain/movie/service/FavoriteMovieService.java +++ b/cgvclone/src/main/java/com/ceos22/spring_boot/domain/movie/service/FavoriteMovieService.java @@ -1,31 +1,45 @@ package com.ceos22.spring_boot.domain.movie.service; -import com.ceos22.spring_boot.domain.movie.FavoriteMovieRepository; -import com.ceos22.spring_boot.domain.movie.MovieRepository; +import com.ceos22.spring_boot.common.exception.GeneralException; +import com.ceos22.spring_boot.common.response.status.ErrorStatus; +import com.ceos22.spring_boot.domain.movie.repository.FavoriteMovieRepository; +import com.ceos22.spring_boot.domain.movie.repository.MovieRepository; import com.ceos22.spring_boot.domain.movie.entity.FavoriteMovie; import com.ceos22.spring_boot.domain.user.UserRepository; -import jakarta.transaction.Transactional; +import org.springframework.transaction.annotation.Transactional; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @Service @RequiredArgsConstructor -@Transactional +@Transactional(readOnly=true) public class FavoriteMovieService { private final FavoriteMovieRepository favorites; private final MovieRepository movies; private final UserRepository users; + @Transactional public void add(Long userId, Long movieId) { - if (favorites.existsByUser_UserIdAndMovie_MovieId(userId, movieId)) return; - var user = users.findById(userId).orElseThrow(); - var movie = movies.findById(movieId).orElseThrow(); - favorites.save(FavoriteMovie.builder().user(user).movie(movie).build()); - } + var user = users.findById(userId) + .orElseThrow(() -> new GeneralException(ErrorStatus.USER_NOT_FOUND)); + var movie = movies.findById(movieId) + .orElseThrow(() -> new GeneralException(ErrorStatus.MOVIE_NOT_FOUND)); + + if (favorites.existsByUser_UserIdAndMovie_MovieId(userId, movieId)) { + throw new GeneralException(ErrorStatus.ALREADY_FAVORITED, "이미 즐겨찾기된 영화입니다."); + } + + favorites.save(FavoriteMovie.builder() + .user(user) + .movie(movie) + .build()); + } + @Transactional public void remove(Long userId, Long movieId) { - favorites.findByUser_UserIdAndMovie_MovieId(userId, movieId) - .ifPresent(favorites::delete); + var fav = favorites.findByUser_UserIdAndMovie_MovieId(userId, movieId) + .orElseThrow(() -> new GeneralException(ErrorStatus.FAVORITE_NOT_FOUND, "즐겨찾기 내역이 없습니다.")); + favorites.delete(fav); } public long count(Long movieId) { diff --git a/cgvclone/src/main/java/com/ceos22/spring_boot/domain/movie/service/MovieService.java b/cgvclone/src/main/java/com/ceos22/spring_boot/domain/movie/service/MovieService.java index bb5af1e..fd4e956 100644 --- a/cgvclone/src/main/java/com/ceos22/spring_boot/domain/movie/service/MovieService.java +++ b/cgvclone/src/main/java/com/ceos22/spring_boot/domain/movie/service/MovieService.java @@ -1,7 +1,7 @@ package com.ceos22.spring_boot.domain.movie.service; -import com.ceos22.spring_boot.domain.movie.MovieDto; -import com.ceos22.spring_boot.domain.movie.MovieRepository; +import com.ceos22.spring_boot.domain.movie.dto.MovieDto; +import com.ceos22.spring_boot.domain.movie.repository.MovieRepository; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; diff --git a/cgvclone/src/main/java/com/ceos22/spring_boot/domain/order/OrderController.java b/cgvclone/src/main/java/com/ceos22/spring_boot/domain/order/OrderController.java index 39603bf..8060ba8 100644 --- a/cgvclone/src/main/java/com/ceos22/spring_boot/domain/order/OrderController.java +++ b/cgvclone/src/main/java/com/ceos22/spring_boot/domain/order/OrderController.java @@ -35,9 +35,6 @@ public ResponseEntity> purchase( @Valid @RequestBody PurchaseRequestDto req, @AuthenticationPrincipal CustomUserPrincipal me ) { - if (me == null) { - return ApiResponse.onFailure(ErrorStatus._UNAUTHORIZED); - } PurchaseResponseDto res = orderService.purchase(me.getUserId(), req); return ApiResponse.onSuccess(SuccessStatus._OK, res); } diff --git a/cgvclone/src/main/java/com/ceos22/spring_boot/domain/order/OrderService.java b/cgvclone/src/main/java/com/ceos22/spring_boot/domain/order/OrderService.java index bdce275..ed571d7 100644 --- a/cgvclone/src/main/java/com/ceos22/spring_boot/domain/order/OrderService.java +++ b/cgvclone/src/main/java/com/ceos22/spring_boot/domain/order/OrderService.java @@ -16,7 +16,7 @@ import com.ceos22.spring_boot.domain.order.repository.UserOrderRepository; import com.ceos22.spring_boot.domain.user.User; import com.ceos22.spring_boot.domain.user.UserRepository; -import jakarta.transaction.Transactional; +import org.springframework.transaction.annotation.Transactional; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -51,7 +51,7 @@ public PurchaseResponseDto purchase(Long userId, PurchaseRequestDto req) { int total = 0; for (var item : req.items()) { - Product p = products.findById(item.productId()) + Product p = products.findByIdWithLock(item.productId()) .orElseThrow(() -> new GeneralException(ErrorStatus.PRODUCT_NOT_FOUND, "상품이 존재하지 않습니다. id=" + item.productId())); if (!p.isAvailable()) { @@ -68,14 +68,14 @@ public PurchaseResponseDto purchase(Long userId, PurchaseRequestDto req) { int subtotal = unitPrice * item.quantity(); total += subtotal; - OrderDetail od = new OrderDetail( - null, // odId - order, // userOrder - p, // product - item.quantity(), - unitPrice, - subtotal - ); + OrderDetail od = OrderDetail.builder() + .userOrder(order) + .product(p) + .quantity(item.quantity()) + .unitPrice(unitPrice) + .subtotal(subtotal) + .build(); + orderDetails.save(od); } diff --git a/cgvclone/src/main/java/com/ceos22/spring_boot/domain/order/entity/OrderDetail.java b/cgvclone/src/main/java/com/ceos22/spring_boot/domain/order/entity/OrderDetail.java index 6901652..195e9ac 100644 --- a/cgvclone/src/main/java/com/ceos22/spring_boot/domain/order/entity/OrderDetail.java +++ b/cgvclone/src/main/java/com/ceos22/spring_boot/domain/order/entity/OrderDetail.java @@ -2,14 +2,13 @@ import com.ceos22.spring_boot.common.BaseEntity; import jakarta.persistence.*; -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.NoArgsConstructor; +import lombok.*; @Entity @NoArgsConstructor @AllArgsConstructor @Getter +@Builder public class OrderDetail extends BaseEntity { @Id @@ -26,6 +25,7 @@ public class OrderDetail extends BaseEntity { private Integer quantity; private Integer price; + private Integer unitPrice; // subtotal = quantity * price private Integer subtotal; diff --git a/cgvclone/src/main/java/com/ceos22/spring_boot/domain/order/repository/ProductRepository.java b/cgvclone/src/main/java/com/ceos22/spring_boot/domain/order/repository/ProductRepository.java index 38440d0..3cc8b13 100644 --- a/cgvclone/src/main/java/com/ceos22/spring_boot/domain/order/repository/ProductRepository.java +++ b/cgvclone/src/main/java/com/ceos22/spring_boot/domain/order/repository/ProductRepository.java @@ -1,6 +1,17 @@ package com.ceos22.spring_boot.domain.order.repository; import com.ceos22.spring_boot.domain.order.entity.Product; +import jakarta.persistence.LockModeType; 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 ProductRepository extends JpaRepository {} \ No newline at end of file +import java.util.Optional; + +public interface ProductRepository extends JpaRepository { + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT p FROM Product p WHERE p.productId = :id") + Optional findByIdWithLock(@Param("id") Long id); +} \ No newline at end of file diff --git a/cgvclone/src/main/java/com/ceos22/spring_boot/domain/reservation/ReservationService.java b/cgvclone/src/main/java/com/ceos22/spring_boot/domain/reservation/ReservationService.java index a71ca07..5029b30 100644 --- a/cgvclone/src/main/java/com/ceos22/spring_boot/domain/reservation/ReservationService.java +++ b/cgvclone/src/main/java/com/ceos22/spring_boot/domain/reservation/ReservationService.java @@ -63,13 +63,13 @@ public ReservationDto.ReservationResponse createReservation(Long userId, Reserva } // 이미 예약된 좌석인지 검증 - for (ScreeningSeat seat : seats) { - if (reservationSeatRepository.existsByScreeningSeat(seat)) { - throw new GeneralException(ErrorStatus._BAD_REQUEST, - "이미 예약된 좌석이 포함되어 있습니다: " + seat.getSeat().getSeatName()); - } + List reservedSeatIds = reservationSeatRepository.findReservedSeatIds(seats); + if (!reservedSeatIds.isEmpty()) { + throw new GeneralException(ErrorStatus.SEAT_ALREADY_RESERVED, + "이미 예약된 좌석이 포함되어 있습니다: " + reservedSeatIds); } + int totalAmount = seats.stream().mapToInt(ScreeningSeat::getPrice).sum(); Reservation reservation = Reservation.builder() diff --git a/cgvclone/src/main/java/com/ceos22/spring_boot/domain/reservation/repository/ReservationRepository.java b/cgvclone/src/main/java/com/ceos22/spring_boot/domain/reservation/repository/ReservationRepository.java index 238dee1..6436251 100644 --- a/cgvclone/src/main/java/com/ceos22/spring_boot/domain/reservation/repository/ReservationRepository.java +++ b/cgvclone/src/main/java/com/ceos22/spring_boot/domain/reservation/repository/ReservationRepository.java @@ -2,7 +2,10 @@ import com.ceos22.spring_boot.common.enums.PaymentStatus; import com.ceos22.spring_boot.domain.reservation.entity.Reservation; +import com.ceos22.spring_boot.domain.theater.entity.ScreeningSeat; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.time.LocalDateTime; import java.util.List; diff --git a/cgvclone/src/main/java/com/ceos22/spring_boot/domain/reservation/repository/ReservationSeatRepository.java b/cgvclone/src/main/java/com/ceos22/spring_boot/domain/reservation/repository/ReservationSeatRepository.java index 5ff4ad3..4bf237f 100644 --- a/cgvclone/src/main/java/com/ceos22/spring_boot/domain/reservation/repository/ReservationSeatRepository.java +++ b/cgvclone/src/main/java/com/ceos22/spring_boot/domain/reservation/repository/ReservationSeatRepository.java @@ -4,10 +4,16 @@ import com.ceos22.spring_boot.domain.reservation.entity.ReservationSeat; import com.ceos22.spring_boot.domain.theater.entity.ScreeningSeat; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import java.util.List; public interface ReservationSeatRepository extends JpaRepository { - boolean existsByScreeningSeat(ScreeningSeat screeningSeat); + List findByReservation(Reservation reservation); + + @Query("SELECT rs.screeningSeat.ssId FROM ReservationSeat rs WHERE rs.screeningSeat IN :seats") + List findReservedSeatIds(@Param("seats") List seats); + } diff --git a/cgvclone/src/main/java/com/ceos22/spring_boot/domain/theater/controller/ScreeningController.java b/cgvclone/src/main/java/com/ceos22/spring_boot/domain/theater/controller/ScreeningController.java index 81f71e0..5bded4d 100644 --- a/cgvclone/src/main/java/com/ceos22/spring_boot/domain/theater/controller/ScreeningController.java +++ b/cgvclone/src/main/java/com/ceos22/spring_boot/domain/theater/controller/ScreeningController.java @@ -3,7 +3,7 @@ import com.ceos22.spring_boot.common.response.ApiResponse; import com.ceos22.spring_boot.common.response.status.ErrorStatus; import com.ceos22.spring_boot.common.response.status.SuccessStatus; -import com.ceos22.spring_boot.domain.movie.MovieRepository; +import com.ceos22.spring_boot.domain.movie.repository.MovieRepository; import com.ceos22.spring_boot.domain.movie.entity.Movie; import com.ceos22.spring_boot.domain.theater.dto.ScreeningRequestDto; import com.ceos22.spring_boot.domain.theater.dto.ScreeningResponseDto; diff --git a/cgvclone/src/main/java/com/ceos22/spring_boot/domain/theater/service/ScreeningService.java b/cgvclone/src/main/java/com/ceos22/spring_boot/domain/theater/service/ScreeningService.java index f2f8eb7..e4c605f 100644 --- a/cgvclone/src/main/java/com/ceos22/spring_boot/domain/theater/service/ScreeningService.java +++ b/cgvclone/src/main/java/com/ceos22/spring_boot/domain/theater/service/ScreeningService.java @@ -2,7 +2,7 @@ import com.ceos22.spring_boot.common.exception.GeneralException; import com.ceos22.spring_boot.common.response.status.ErrorStatus; -import com.ceos22.spring_boot.domain.movie.MovieRepository; +import com.ceos22.spring_boot.domain.movie.repository.MovieRepository; import com.ceos22.spring_boot.domain.movie.entity.Movie; import com.ceos22.spring_boot.domain.theater.dto.ScreeningRequestDto; import com.ceos22.spring_boot.domain.theater.entity.Screen; diff --git a/cgvclone/src/main/java/com/ceos22/spring_boot/domain/user/UserRepository.java b/cgvclone/src/main/java/com/ceos22/spring_boot/domain/user/UserRepository.java index 5ad1be9..cb8c6ba 100644 --- a/cgvclone/src/main/java/com/ceos22/spring_boot/domain/user/UserRepository.java +++ b/cgvclone/src/main/java/com/ceos22/spring_boot/domain/user/UserRepository.java @@ -3,6 +3,7 @@ import org.springframework.data.jpa.repository.JpaRepository; import java.util.Optional; +import java.util.UUID; public interface UserRepository extends JpaRepository { User findByUserId(long id); @@ -10,5 +11,6 @@ public interface UserRepository extends JpaRepository { boolean existsByUsername(String username); boolean existsByEmail(String email); + Optional findByPublicId(UUID publicId); Optional findByUsername(String username); } diff --git a/cgvclone/src/main/java/com/ceos22/spring_boot/domain/user/UserService.java b/cgvclone/src/main/java/com/ceos22/spring_boot/domain/user/UserService.java index 9bb8ba2..332cd8d 100644 --- a/cgvclone/src/main/java/com/ceos22/spring_boot/domain/user/UserService.java +++ b/cgvclone/src/main/java/com/ceos22/spring_boot/domain/user/UserService.java @@ -41,4 +41,13 @@ public User register(String username, String rawPassword, String name, String em return users.save(user); } + @Transactional(readOnly = true) + public User findByUsername(String username) { + return users.findByUsername(username) + .orElseThrow(() -> + new GeneralException(ErrorStatus.USER_NOT_FOUND, + "존재하지 않는 사용자입니다: " + username)); + } + + } diff --git a/cgvclone/src/main/resources/application.yml b/cgvclone/src/main/resources/application.yml index 9c8989a..fa24f99 100644 --- a/cgvclone/src/main/resources/application.yml +++ b/cgvclone/src/main/resources/application.yml @@ -28,7 +28,7 @@ springdoc: jwt: secret: ${JWT_SECRET} - access-token-validity-in-seconds: 1800 + access-token-validity: 1h # duration으로 변경함 security: password: diff --git a/cgvclone/src/test/java/com/ceos22/spring_boot/service/MovieServiceTest.java b/cgvclone/src/test/java/com/ceos22/spring_boot/service/MovieServiceTest.java index cdbbeed..f2e25c6 100644 --- a/cgvclone/src/test/java/com/ceos22/spring_boot/service/MovieServiceTest.java +++ b/cgvclone/src/test/java/com/ceos22/spring_boot/service/MovieServiceTest.java @@ -1,9 +1,9 @@ package com.ceos22.spring_boot.service; -import com.ceos22.spring_boot.domain.movie.MovieDto; +import com.ceos22.spring_boot.domain.movie.dto.MovieDto; import com.ceos22.spring_boot.domain.movie.service.MovieService; import com.ceos22.spring_boot.domain.movie.entity.Movie; -import com.ceos22.spring_boot.domain.movie.MovieRepository; +import com.ceos22.spring_boot.domain.movie.repository.MovieRepository; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith;