From 6e01b5903dfd26acb8ffb00d6dbe9ba84807b9c7 Mon Sep 17 00:00:00 2001 From: jeonghyemin Date: Thu, 3 Jul 2025 17:31:41 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(user):=20=EC=8A=88=ED=8D=BC=EA=B3=84?= =?UTF-8?q?=EC=A0=95=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - SUPER_ADMIN 추가 - git 추적에 빠졌던 파일 git 추적하도록 추가 --- ...4\353\223\234-\354\235\264\354\212\210.md" | 14 ++ .../example/apiuser/ApiUserApplication.java | 39 ++++ .../controller/BookmarkController.java | 67 +++++++ .../bookmark/dto/BookmarkCreateResponse.java | 24 +++ .../bookmark/dto/BookmarkGetResponse.java | 24 +++ .../bookmark/service/BookmarkService.java | 78 ++++++++ .../exception/GlobalExceptionHandler.java | 170 ++++++++++++++++++ .../menu/controller/MenuController.java | 32 ++++ .../menu/dto/MenuImageUploadResponse.java | 20 +++ .../example/apiuser/menu/dto/MenuReadDto.java | 32 ++++ .../apiuser/menu/dto/MenuReadResponse.java | 21 +++ .../apiuser/menu/service/MenuService.java | 42 +++++ .../order/controller/OrderController.java | 70 ++++++++ .../apiuser/order/dto/CartItemDto.java | 11 ++ .../order/dto/OrderCreateRequestDto.java | 15 ++ .../order/dto/OrderCreateResponseDto.java | 28 +++ .../dto/OrderItemListGetResponseDto.java | 29 +++ .../order/dto/OrderItemResponseDTO.java | 16 ++ .../apiuser/order/service/OrderService.java | 128 +++++++++++++ .../controller/ReservationController.java | 47 +++++ .../dto/ReservationCreateRequestDto.java | 10 ++ .../dto/ReservationCreateResponseDto.java | 17 ++ .../ReservationNotFoundException.java | 9 + .../service/ReservationService.java | 58 ++++++ .../store/controller/StoreController.java | 56 ++++++ .../store/dto/StoreImageUploadResponse.java | 22 +++ .../apiuser/store/dto/StoreReadDto.java | 39 ++++ .../apiuser/store/dto/StoreReadResponse.java | 23 +++ .../apiuser/store/service/StoreService.java | 16 ++ .../store/service/StoreServiceImpl.java | 73 ++++++++ .../token/controller/TokenController.java | 59 ++++++ .../token/dto/AuthenticationResponse.java | 18 ++ .../token/dto/RefreshTokenRequest.java | 12 ++ .../apiuser/token/service/TokenService.java | 66 +++++++ .../user/serivce/UserService.java | 8 +- .../applicationconfig/config/AsyncConfig.java | 21 +++ .../applicationconfig/config/CorsConfig.java | 28 +++ .../token/controller/TokenController.java | 7 +- .../java/com/nowait/common/enums/Role.java | 3 +- .../com/example/domaintoken/entity/Token.java | 54 ++++++ .../exception/BusinessException.java | 16 ++ .../repository/TokenRepository.java | 11 ++ .../infrastorage/config/AwsS3Config.java | 33 ++++ .../example/infrastorage/s3/S3Service.java | 57 ++++++ .../com/nowait/auth/config/CorsConfig.java | 28 +++ .../nowait/auth/config/SecurityConfig.java | 93 ++++++++++ .../com/nowait/auth/dto/CustomOAuth2User.java | 60 +++++++ .../com/nowait/auth/dto/KaKaoResponse.java | 40 +++++ .../com/nowait/auth/dto/OAuth2Response.java | 19 ++ .../auth/jwt/JwtAuthorizationFilter.java | 93 ++++++++++ .../java/com/nowait/auth/jwt/JwtUtil.java | 76 ++++++++ .../auth/oauth2/CustomOAuth2UserService.java | 70 ++++++++ .../oauth2/OAuth2LoginSuccessHandler.java | 80 +++++++++ .../auth/service/CustomUserDetailService.java | 30 ++++ .../nowait/exception/BusinessException.java | 14 ++ .../com/nowait/exception/ErrorMessage.java | 22 +++ .../com/nowait/exception/ErrorResponse.java | 26 +++ .../exception/GlobalExceptionHandler.java | 108 +++++++++++ .../RefreshTokenNotFoundException.java | 9 + .../exception/ResourceNotFoundException.java | 14 ++ .../exception/TokenBadRequestException.java | 8 + .../exception/UnauthorizedException.java | 19 ++ 62 files changed, 2429 insertions(+), 3 deletions(-) create mode 100644 ".github/ISSUE_TEMPLATE/\353\260\261\354\227\224\353\223\234-\354\235\264\354\212\210.md" create mode 100644 api-user/src/main/java/com/example/apiuser/ApiUserApplication.java create mode 100644 api-user/src/main/java/com/example/apiuser/bookmark/controller/BookmarkController.java create mode 100644 api-user/src/main/java/com/example/apiuser/bookmark/dto/BookmarkCreateResponse.java create mode 100644 api-user/src/main/java/com/example/apiuser/bookmark/dto/BookmarkGetResponse.java create mode 100644 api-user/src/main/java/com/example/apiuser/bookmark/service/BookmarkService.java create mode 100644 api-user/src/main/java/com/example/apiuser/exception/GlobalExceptionHandler.java create mode 100644 api-user/src/main/java/com/example/apiuser/menu/controller/MenuController.java create mode 100644 api-user/src/main/java/com/example/apiuser/menu/dto/MenuImageUploadResponse.java create mode 100644 api-user/src/main/java/com/example/apiuser/menu/dto/MenuReadDto.java create mode 100644 api-user/src/main/java/com/example/apiuser/menu/dto/MenuReadResponse.java create mode 100644 api-user/src/main/java/com/example/apiuser/menu/service/MenuService.java create mode 100644 api-user/src/main/java/com/example/apiuser/order/controller/OrderController.java create mode 100644 api-user/src/main/java/com/example/apiuser/order/dto/CartItemDto.java create mode 100644 api-user/src/main/java/com/example/apiuser/order/dto/OrderCreateRequestDto.java create mode 100644 api-user/src/main/java/com/example/apiuser/order/dto/OrderCreateResponseDto.java create mode 100644 api-user/src/main/java/com/example/apiuser/order/dto/OrderItemListGetResponseDto.java create mode 100644 api-user/src/main/java/com/example/apiuser/order/dto/OrderItemResponseDTO.java create mode 100644 api-user/src/main/java/com/example/apiuser/order/service/OrderService.java create mode 100644 api-user/src/main/java/com/example/apiuser/reservation/controller/ReservationController.java create mode 100644 api-user/src/main/java/com/example/apiuser/reservation/dto/ReservationCreateRequestDto.java create mode 100644 api-user/src/main/java/com/example/apiuser/reservation/dto/ReservationCreateResponseDto.java create mode 100644 api-user/src/main/java/com/example/apiuser/reservation/exception/ReservationNotFoundException.java create mode 100644 api-user/src/main/java/com/example/apiuser/reservation/service/ReservationService.java create mode 100644 api-user/src/main/java/com/example/apiuser/store/controller/StoreController.java create mode 100644 api-user/src/main/java/com/example/apiuser/store/dto/StoreImageUploadResponse.java create mode 100644 api-user/src/main/java/com/example/apiuser/store/dto/StoreReadDto.java create mode 100644 api-user/src/main/java/com/example/apiuser/store/dto/StoreReadResponse.java create mode 100644 api-user/src/main/java/com/example/apiuser/store/service/StoreService.java create mode 100644 api-user/src/main/java/com/example/apiuser/store/service/StoreServiceImpl.java create mode 100644 api-user/src/main/java/com/example/apiuser/token/controller/TokenController.java create mode 100644 api-user/src/main/java/com/example/apiuser/token/dto/AuthenticationResponse.java create mode 100644 api-user/src/main/java/com/example/apiuser/token/dto/RefreshTokenRequest.java create mode 100644 api-user/src/main/java/com/example/apiuser/token/service/TokenService.java create mode 100644 application-config/src/main/java/com/example/applicationconfig/config/AsyncConfig.java create mode 100644 application-config/src/main/java/com/example/applicationconfig/config/CorsConfig.java create mode 100644 domain-token/src/main/java/com/example/domaintoken/entity/Token.java create mode 100644 domain-token/src/main/java/com/example/domaintoken/exception/BusinessException.java create mode 100644 domain-token/src/main/java/com/example/domaintoken/repository/TokenRepository.java create mode 100644 infra-aws/src/main/java/com/example/infrastorage/config/AwsS3Config.java create mode 100644 infra-aws/src/main/java/com/example/infrastorage/s3/S3Service.java create mode 100644 security-front/src/main/java/com/nowait/auth/config/CorsConfig.java create mode 100644 security-front/src/main/java/com/nowait/auth/config/SecurityConfig.java create mode 100644 security-front/src/main/java/com/nowait/auth/dto/CustomOAuth2User.java create mode 100644 security-front/src/main/java/com/nowait/auth/dto/KaKaoResponse.java create mode 100644 security-front/src/main/java/com/nowait/auth/dto/OAuth2Response.java create mode 100644 security-front/src/main/java/com/nowait/auth/jwt/JwtAuthorizationFilter.java create mode 100644 security-front/src/main/java/com/nowait/auth/jwt/JwtUtil.java create mode 100644 security-front/src/main/java/com/nowait/auth/oauth2/CustomOAuth2UserService.java create mode 100644 security-front/src/main/java/com/nowait/auth/oauth2/OAuth2LoginSuccessHandler.java create mode 100644 security-front/src/main/java/com/nowait/auth/service/CustomUserDetailService.java create mode 100644 security-front/src/main/java/com/nowait/exception/BusinessException.java create mode 100644 security-front/src/main/java/com/nowait/exception/ErrorMessage.java create mode 100644 security-front/src/main/java/com/nowait/exception/ErrorResponse.java create mode 100644 security-front/src/main/java/com/nowait/exception/GlobalExceptionHandler.java create mode 100644 security-front/src/main/java/com/nowait/exception/RefreshTokenNotFoundException.java create mode 100644 security-front/src/main/java/com/nowait/exception/ResourceNotFoundException.java create mode 100644 security-front/src/main/java/com/nowait/exception/TokenBadRequestException.java create mode 100644 security-front/src/main/java/com/nowait/exception/UnauthorizedException.java diff --git "a/.github/ISSUE_TEMPLATE/\353\260\261\354\227\224\353\223\234-\354\235\264\354\212\210.md" "b/.github/ISSUE_TEMPLATE/\353\260\261\354\227\224\353\223\234-\354\235\264\354\212\210.md" new file mode 100644 index 00000000..f728915f --- /dev/null +++ "b/.github/ISSUE_TEMPLATE/\353\260\261\354\227\224\353\223\234-\354\235\264\354\212\210.md" @@ -0,0 +1,14 @@ +--- +name: 백엔드 이슈 +about: 백엔드와 관련된 이슈 +title: "[백엔드] " +labels: Backend +assignees: '' + +--- + +# 이슈 내용 + + +# 작업 목록 +- [ ] diff --git a/api-user/src/main/java/com/example/apiuser/ApiUserApplication.java b/api-user/src/main/java/com/example/apiuser/ApiUserApplication.java new file mode 100644 index 00000000..fcaf441e --- /dev/null +++ b/api-user/src/main/java/com/example/apiuser/ApiUserApplication.java @@ -0,0 +1,39 @@ +package com.example.apiuser; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.boot.autoconfigure.domain.EntityScan; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; +import org.springframework.data.jpa.repository.config.EnableJpaRepositories; + +@EnableJpaAuditing +@SpringBootApplication(scanBasePackages = { + "com.example.apiuser", + "com.nowait.auth" +}) +@EntityScan(basePackages = { + "com.example.menu.entity", // domain-menu + "com.example.domainstore.entity", // domain-store + "com.example.domaintoken.entity", + "com.nowaiting.user.entity", + "com.nowait.domainbookmark.entity", + "com.nowait.domainreservation.entity", + "com.nowait.domainorder.entity", + "com.nowait.domainorder.entity" +}) +@EnableJpaRepositories(basePackages = { + "com.example.menu.repository", + "com.nowaiting.user.repository", + "com.example.domainstore.repository", + "com.example.domaintoken.repository", + "com.nowait.domainbookmark.repository", + "com.nowait.domainorder.repository", + "com.nowait.domainreservation.repository" +}) +public class ApiUserApplication { + + public static void main(String[] args) { + SpringApplication.run(ApiUserApplication.class, args); + } + +} diff --git a/api-user/src/main/java/com/example/apiuser/bookmark/controller/BookmarkController.java b/api-user/src/main/java/com/example/apiuser/bookmark/controller/BookmarkController.java new file mode 100644 index 00000000..32c4132a --- /dev/null +++ b/api-user/src/main/java/com/example/apiuser/bookmark/controller/BookmarkController.java @@ -0,0 +1,67 @@ +package com.example.apiuser.bookmark.controller; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; +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.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.example.apiuser.bookmark.dto.BookmarkCreateResponse; +import com.example.apiuser.bookmark.service.BookmarkService; +import com.nowait.auth.dto.CustomOAuth2User; +import com.nowaiting.common.api.ApiUtils; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +@Tag(name = "Bookmark API", description = "북마크 API") +@RestController +@RequestMapping("/bookmarks") +@RequiredArgsConstructor +public class BookmarkController { + private final BookmarkService bookmarkService; + + @PostMapping("/{storeId}") + @Operation(summary = "북마크 생성", description = "특정 주점에 대한 북마크 생성") + @ApiResponse(responseCode = "201", description = "북마크 생성") + public ResponseEntity createBookmark(@PathVariable Long storeId,@AuthenticationPrincipal CustomOAuth2User customOAuth2User) { + BookmarkCreateResponse response = bookmarkService.createBookmark(storeId,customOAuth2User); + + return ResponseEntity + .status(HttpStatus.CREATED) + .body( + ApiUtils.success( + response + ) + ); + } + @GetMapping + @Operation(summary = "북마크 조회", description = "내가 북마크한 주점 조회") + @ApiResponse(responseCode = "200", description = "북마크 조회") + public ResponseEntity getAllBookmarks(@AuthenticationPrincipal CustomOAuth2User customOAuth2User) { + return ResponseEntity + .ok() + .body( + ApiUtils.success( + bookmarkService.getBookmarks(customOAuth2User) + ) + ); + } + @DeleteMapping("/{bookmarkId}") + @Operation(summary = "북마크 삭제", description = "특정 주점에 대한 북마크 삭제") + @ApiResponse(responseCode = "200", description = "북마크 삭제") + public ResponseEntity deleteBookmark(@PathVariable Long bookmarkId, @AuthenticationPrincipal CustomOAuth2User customOAuth2User) { + return ResponseEntity + .ok() + .body( + ApiUtils.success( + bookmarkService.deleteBookmark(bookmarkId,customOAuth2User) + ) + ); + } +} diff --git a/api-user/src/main/java/com/example/apiuser/bookmark/dto/BookmarkCreateResponse.java b/api-user/src/main/java/com/example/apiuser/bookmark/dto/BookmarkCreateResponse.java new file mode 100644 index 00000000..5e4a8f65 --- /dev/null +++ b/api-user/src/main/java/com/example/apiuser/bookmark/dto/BookmarkCreateResponse.java @@ -0,0 +1,24 @@ +package com.example.apiuser.bookmark.dto; + +import com.nowait.domainbookmark.entity.Bookmark; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Getter +@AllArgsConstructor +@Builder +public class BookmarkCreateResponse { + private Long bookmarkId; + private Long userId; + private Long storeId; + + public static BookmarkCreateResponse fromEntity(Bookmark bookmark) { + return BookmarkCreateResponse.builder() + .bookmarkId(bookmark.getId()) + .userId(bookmark.getUser().getId()) + .storeId(bookmark.getStore().getStoreId()) + .build(); + } +} diff --git a/api-user/src/main/java/com/example/apiuser/bookmark/dto/BookmarkGetResponse.java b/api-user/src/main/java/com/example/apiuser/bookmark/dto/BookmarkGetResponse.java new file mode 100644 index 00000000..6f05db8e --- /dev/null +++ b/api-user/src/main/java/com/example/apiuser/bookmark/dto/BookmarkGetResponse.java @@ -0,0 +1,24 @@ +package com.example.apiuser.bookmark.dto; + +import com.nowait.domainbookmark.entity.Bookmark; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Getter +@AllArgsConstructor +@Builder +public class BookmarkGetResponse { + private Long bookmarkId; + private Long userId; + private Long storeId; + + public static BookmarkGetResponse fromEntity(Bookmark bookmark) { + return BookmarkGetResponse.builder() + .bookmarkId(bookmark.getId()) + .userId(bookmark.getUser().getId()) + .storeId(bookmark.getStore().getStoreId()) + .build(); + } +} diff --git a/api-user/src/main/java/com/example/apiuser/bookmark/service/BookmarkService.java b/api-user/src/main/java/com/example/apiuser/bookmark/service/BookmarkService.java new file mode 100644 index 00000000..91ce0ff8 --- /dev/null +++ b/api-user/src/main/java/com/example/apiuser/bookmark/service/BookmarkService.java @@ -0,0 +1,78 @@ +package com.example.apiuser.bookmark.service; + +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.example.apiuser.bookmark.dto.BookmarkCreateResponse; +import com.example.apiuser.bookmark.dto.BookmarkGetResponse; +import com.example.domainstore.entity.Store; +import com.example.domainstore.repository.StoreRepository; +import com.nowait.auth.dto.CustomOAuth2User; +import com.nowait.domainbookmark.entity.Bookmark; +import com.nowait.domainbookmark.repository.BookmarkRepository; +import com.nowaiting.user.entity.User; +import com.nowaiting.user.repository.UserRepository; + +import jakarta.persistence.EntityNotFoundException; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class BookmarkService { + private final BookmarkRepository bookmarkRepository; + private final StoreRepository storeRepository; + private final UserRepository userRepository; + @Transactional + public BookmarkCreateResponse createBookmark(Long storeId, CustomOAuth2User customOAuth2User) { + parameterValidation(storeId, customOAuth2User); + Store store = storeRepository.findById(storeId) + .orElseThrow(() -> new EntityNotFoundException(storeId + " store not found.")); + User user = userRepository.findById(customOAuth2User.getUserId()) + .orElseThrow(() -> new EntityNotFoundException("User not found")); + + if (bookmarkRepository.existsByUserAndStore(user, store)) { + throw new IllegalArgumentException("already bookmarked"); + } + + Bookmark bookmark = Bookmark.builder() + .store(store) + .user(user) + .build(); + + return BookmarkCreateResponse.fromEntity(bookmarkRepository.save(bookmark)); + } + + @Transactional(readOnly = true) + public List getBookmarks(CustomOAuth2User customOAuth2User) { + User user = userRepository.findById(customOAuth2User.getUserId()) + .orElseThrow(() -> new EntityNotFoundException("User not found")); + return bookmarkRepository.findAllByUser(user) + .stream() + .map(BookmarkGetResponse::fromEntity) + .toList(); + } + + @Transactional + public String deleteBookmark(Long bookmarkId, CustomOAuth2User customOAuth2User) { + parameterValidation(bookmarkId, customOAuth2User); + Bookmark bookmark = bookmarkRepository.findById(bookmarkId) + .orElseThrow(() -> new EntityNotFoundException(bookmarkId + " bookmark not found.")); + if (bookmark.getUser().getId() != customOAuth2User.getUserId()) { + throw new IllegalArgumentException("you can only delete your own bookmark"); + } + bookmarkRepository.delete(bookmark); + return "Bookmark ID " + bookmarkId + " deleted."; + } + + private static void parameterValidation(Long storeId, CustomOAuth2User customOAuth2User) { + // 파라미터 유효성 검사 + if (storeId == null || storeId < 0) { + throw new IllegalArgumentException("storeId must be a positive number"); + } + if (customOAuth2User == null || customOAuth2User.getUserId() == null) { + throw new IllegalArgumentException("UserInfo is required"); + } + } +} diff --git a/api-user/src/main/java/com/example/apiuser/exception/GlobalExceptionHandler.java b/api-user/src/main/java/com/example/apiuser/exception/GlobalExceptionHandler.java new file mode 100644 index 00000000..fedb029f --- /dev/null +++ b/api-user/src/main/java/com/example/apiuser/exception/GlobalExceptionHandler.java @@ -0,0 +1,170 @@ +package com.example.apiuser.exception; + +import static com.nowaiting.common.exception.ErrorMessage.*; +import static org.springframework.http.HttpStatus.*; +import static org.springframework.http.HttpStatus.UNAUTHORIZED; + +import java.util.Map; +import java.util.stream.Collectors; + +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.validation.FieldError; +import org.springframework.validation.ObjectError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingRequestValueException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.multipart.MultipartException; + +import com.example.apiuser.reservation.exception.ReservationNotFoundException; +import com.nowait.domainbookmark.exception.BookmarkOwnerMismatchException; +import com.nowait.domainbookmark.exception.DuplicateBookmarkException; +import com.nowait.domainorder.exception.DuplicateOrderException; +import com.nowait.domainorder.exception.OrderItemsEmptyException; +import com.nowait.domainorder.exception.OrderParameterEmptyException; +import com.nowait.exception.BusinessException; +import com.nowait.exception.ResourceNotFoundException; +import com.nowait.exception.UnauthorizedException; +import com.nowaiting.common.exception.ErrorMessage; +import com.nowaiting.common.exception.ErrorResponse; +import com.nowaiting.user.exception.UserNotFoundException; + +import io.swagger.v3.oas.annotations.Hidden; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Hidden +@RestControllerAdvice +public class GlobalExceptionHandler { + + // OAUTH 인증 실패 에러처리 메서드 + @ResponseStatus(value = BAD_REQUEST) + @ExceptionHandler(OAuth2AuthenticationException.class) + public ErrorResponse handlerOAuth2AuthenticationException(OAuth2AuthenticationException e) { + log.error("handleOAuth2AuthenticationException", e); + + return new ErrorResponse("OAuth 인증 실패 : " + e.getMessage(), INVALID_INPUT_VALUE.getCode()); + } + + @ResponseStatus(value = BAD_REQUEST) + @ExceptionHandler(BusinessException.class) + public ErrorResponse handleBusinessException(BusinessException e) { + log.error("handleBusinessException", e); + return new ErrorResponse(e.getMessage(), e.getCode()); + } + + @ResponseStatus(value = BAD_REQUEST) + @ExceptionHandler(MethodArgumentNotValidException.class) + public ErrorResponse handleMethodArgumentNotValidException(MethodArgumentNotValidException e) { + log.error("handleMethodArgumentNotValidException", e); + Map errors = getErrors(e); + return new ErrorResponse(INVALID_INPUT_VALUE.getMessage(), INVALID_INPUT_VALUE.getCode(), errors); + } + + @ResponseStatus(value = BAD_REQUEST) + @ExceptionHandler(HttpMessageNotReadableException.class) + public ErrorResponse handleHttpMessageNotReadableException(HttpMessageNotReadableException e) { + log.error("handleHttpMessageNotReadableException", e); + return new ErrorResponse(INVALID_INPUT_VALUE.getMessage(), INVALID_INPUT_VALUE.getCode()); + } + + @ResponseStatus(value = BAD_REQUEST) + @ExceptionHandler(IllegalArgumentException.class) + public ErrorResponse handleIllegalArgumentException(IllegalArgumentException e) { + log.error("handleIllegalArgumentException", e); + return new ErrorResponse(e.getMessage(), INVALID_INPUT_VALUE.getCode()); + } + + @ResponseStatus(value = BAD_REQUEST) + @ExceptionHandler(MissingRequestValueException.class) + public ErrorResponse handleMissingRequestValueException(MissingRequestValueException e) { + log.error("handleMissingRequestValueExceptionException", e); + return new ErrorResponse(INVALID_INPUT_VALUE.getMessage(), INVALID_INPUT_VALUE.getCode()); + } + + @ResponseStatus(value = UNAUTHORIZED) + @ExceptionHandler(UnauthorizedException.class) + public ErrorResponse handleUnauthorizedException(UnauthorizedException e) { + log.error("handleUnauthorizedExceptionException", e); + return new ErrorResponse(e.getMessage(), e.getCode()); + } + + @ResponseStatus(value = NOT_FOUND) + @ExceptionHandler(ResourceNotFoundException.class) + public ErrorResponse handleResourceNotFoundException(ResourceNotFoundException e) { + log.error("handleResourceNotFoundExceptionException", e); + return new ErrorResponse(e.getMessage(), e.getCode()); + } + + @ResponseStatus(value = BAD_REQUEST) + @ExceptionHandler(MultipartException.class) + public ErrorResponse handleMultipartException(MultipartException e) { + log.error("handleMultipartException", e); + return new ErrorResponse(e.getMessage(), INVALID_INPUT_VALUE.getCode()); + } + + @ResponseStatus(value = BAD_REQUEST) + @ExceptionHandler(DuplicateBookmarkException.class) + public ErrorResponse handleDuplicateBookmarkException(DuplicateBookmarkException e) { + log.error("handleDuplicateBookmarkException", e); + return new ErrorResponse(e.getMessage(), ErrorMessage.DUPLICATE_BOOKMARK.getCode()); + } + + @ResponseStatus(value = FORBIDDEN) + @ExceptionHandler(BookmarkOwnerMismatchException.class) + public ErrorResponse bookmarkOwnerMismatchException(BookmarkOwnerMismatchException e) { + log.error("bookmarkOwnerMismatchException", e); + return new ErrorResponse(e.getMessage(), NOT_OWN_BOOKMARK.getCode()); + } + + @ResponseStatus(value = NOT_FOUND) + @ExceptionHandler(UserNotFoundException.class) + public ErrorResponse userNotFoundException(UserNotFoundException e) { + log.error("userNotFoundException", e); + return new ErrorResponse(e.getMessage(), NOTFOUND_USER.getCode()); + } + + @ResponseStatus(value = BAD_REQUEST) + @ExceptionHandler(OrderParameterEmptyException.class) + public ErrorResponse orderParameterEmptyException(OrderParameterEmptyException e) { + log.error("orderParameterEmptyException", e); + return new ErrorResponse(e.getMessage(), ORDER_PARAMETER_EMPTY.getCode()); + } + + @ResponseStatus(value = BAD_REQUEST) + @ExceptionHandler(OrderItemsEmptyException.class) + public ErrorResponse orderItemsEmptyException(OrderItemsEmptyException e) { + log.error("orderItemsEmptyException", e); + return new ErrorResponse(e.getMessage(), ORDER_ITEMS_EMPTY.getCode()); + } + + @ResponseStatus(value = BAD_REQUEST) + @ExceptionHandler(DuplicateOrderException.class) + public ErrorResponse duplicateOrderException(DuplicateOrderException e) { + log.error("duplicateOrderException", e); + return new ErrorResponse(e.getMessage(), ErrorMessage.DUPLICATE_ORDER.getCode()); + } + + @ResponseStatus(value = NOT_FOUND) + @ExceptionHandler(ReservationNotFoundException.class) + public ErrorResponse reservationNotFoundException(ReservationNotFoundException e) { + log.error("reservationNotFoundException", e); + return new ErrorResponse(e.getMessage(), NOTFOUND_RESERVATION.getCode()); + } + + + private static Map getErrors(MethodArgumentNotValidException e) { + return e.getBindingResult() + .getAllErrors() + .stream() + .filter(ObjectError.class::isInstance) + .collect(Collectors.toMap( + error -> error instanceof FieldError ? ((FieldError)error).getField() : error.getObjectName(), + ObjectError::getDefaultMessage, + (msg1, msg2) -> msg1 + ";" + msg2 + )); + } + +} diff --git a/api-user/src/main/java/com/example/apiuser/menu/controller/MenuController.java b/api-user/src/main/java/com/example/apiuser/menu/controller/MenuController.java new file mode 100644 index 00000000..d3fb6d02 --- /dev/null +++ b/api-user/src/main/java/com/example/apiuser/menu/controller/MenuController.java @@ -0,0 +1,32 @@ +package com.example.apiuser.menu.controller; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +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; + +import com.example.apiuser.menu.service.MenuService; +import com.nowaiting.common.api.ApiUtils; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/v1/menus") +@RequiredArgsConstructor +public class MenuController { + + private final MenuService menuService; + + @GetMapping("/all-menus/stores/{storeId}") + public ResponseEntity getMenusByStoreId(@PathVariable Long storeId) { + return ResponseEntity + .status(HttpStatus.OK) + .body( + ApiUtils.success( + menuService.getMenusByStoreId(storeId) + ) + ); + } +} diff --git a/api-user/src/main/java/com/example/apiuser/menu/dto/MenuImageUploadResponse.java b/api-user/src/main/java/com/example/apiuser/menu/dto/MenuImageUploadResponse.java new file mode 100644 index 00000000..c32be356 --- /dev/null +++ b/api-user/src/main/java/com/example/apiuser/menu/dto/MenuImageUploadResponse.java @@ -0,0 +1,20 @@ +package com.example.apiuser.menu.dto; + +import com.example.menu.entity.MenuImage; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class MenuImageUploadResponse { + private final Long id; + private final String imageUrl; + + public static MenuImageUploadResponse fromEntity(MenuImage menuImage) { + return MenuImageUploadResponse.builder() + .id(menuImage.getId()) + .imageUrl(menuImage.getImageUrl()) + .build(); + } +} diff --git a/api-user/src/main/java/com/example/apiuser/menu/dto/MenuReadDto.java b/api-user/src/main/java/com/example/apiuser/menu/dto/MenuReadDto.java new file mode 100644 index 00000000..98dda1f2 --- /dev/null +++ b/api-user/src/main/java/com/example/apiuser/menu/dto/MenuReadDto.java @@ -0,0 +1,32 @@ +package com.example.apiuser.menu.dto; + +import java.util.List; + +import com.example.menu.entity.Menu; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Getter +@AllArgsConstructor +@Builder +public class MenuReadDto { + private Long menuId; + private Long storeId; + private String name; + private String description; + private Integer price; + private List images; + + public static MenuReadDto fromEntity(Menu menu, List images) { + return MenuReadDto.builder() + .menuId(menu.getId()) + .storeId(menu.getStoreId()) + .name(menu.getName()) + .description(menu.getDescription()) + .price(menu.getPrice()) + .images(images) + .build(); + } +} diff --git a/api-user/src/main/java/com/example/apiuser/menu/dto/MenuReadResponse.java b/api-user/src/main/java/com/example/apiuser/menu/dto/MenuReadResponse.java new file mode 100644 index 00000000..755b243f --- /dev/null +++ b/api-user/src/main/java/com/example/apiuser/menu/dto/MenuReadResponse.java @@ -0,0 +1,21 @@ +package com.example.apiuser.menu.dto; + +import java.util.List; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Getter +@AllArgsConstructor +@Builder +public class MenuReadResponse { + + private List menuReadDto; + + public static MenuReadResponse of(List menuReadDto) { + return MenuReadResponse.builder() + .menuReadDto(menuReadDto) + .build(); + } +} diff --git a/api-user/src/main/java/com/example/apiuser/menu/service/MenuService.java b/api-user/src/main/java/com/example/apiuser/menu/service/MenuService.java new file mode 100644 index 00000000..64b9e0b9 --- /dev/null +++ b/api-user/src/main/java/com/example/apiuser/menu/service/MenuService.java @@ -0,0 +1,42 @@ +package com.example.apiuser.menu.service; + +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.example.apiuser.menu.dto.MenuImageUploadResponse; +import com.example.apiuser.menu.dto.MenuReadDto; +import com.example.apiuser.menu.dto.MenuReadResponse; +import com.example.menu.entity.Menu; +import com.example.menu.entity.MenuImage; +import com.example.menu.repository.MenuImageRepository; +import com.example.menu.repository.MenuRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class MenuService { + + private final MenuRepository menuRepository; + private final MenuImageRepository menuImageRepository; + + + @Transactional(readOnly = true) + public MenuReadResponse getMenusByStoreId(Long storeId) { + List menus = menuRepository.findAllByStoreId(storeId); + + List menuReadResponse = menus.stream() + .map(menu -> { + List images = menuImageRepository.findByMenu(menu); + List imageDto = images.stream() + .map(MenuImageUploadResponse::fromEntity) + .toList(); + return MenuReadDto.fromEntity(menu, imageDto); + }) + .toList(); + + return MenuReadResponse.of(menuReadResponse); + } +} diff --git a/api-user/src/main/java/com/example/apiuser/order/controller/OrderController.java b/api-user/src/main/java/com/example/apiuser/order/controller/OrderController.java new file mode 100644 index 00000000..65238120 --- /dev/null +++ b/api-user/src/main/java/com/example/apiuser/order/controller/OrderController.java @@ -0,0 +1,70 @@ +package com.example.apiuser.order.controller; + +import java.util.List; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +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; + +import com.example.apiuser.order.dto.OrderCreateRequestDto; +import com.example.apiuser.order.dto.OrderCreateResponseDto; +import com.example.apiuser.order.dto.OrderItemListGetResponseDto; +import com.example.apiuser.order.service.OrderService; +import com.nowaiting.common.api.ApiUtils; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.servlet.http.HttpSession; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; + +@Tag(name = "Order API", description = "주문 API") +@RestController +@RequestMapping("/orders") +@RequiredArgsConstructor +public class OrderController { + private final OrderService orderService; + + @PostMapping("/create/{storeId}/{tableId}") + @Operation(summary = "주문 생성", description = "특정 주점 - 특정 테이블에 대한 주문 생성") + @ApiResponse(responseCode = "201", description = "주문 생성") + public ResponseEntity createOrder( + @PathVariable Long storeId, + @PathVariable Long tableId, + @RequestBody @Valid OrderCreateRequestDto orderCreateRequestDto, + HttpSession session + ) { + String sessionId = session.getId(); + OrderCreateResponseDto response = orderService.createOrder(storeId,tableId,orderCreateRequestDto,sessionId); + return ResponseEntity + .status(HttpStatus.CREATED) + .body( + ApiUtils.success(response) + ); + } + + @GetMapping("/items/{storeId}/{tableId}") + @Operation(summary = "테이블별 주문 아이템 조회", description = "비로그인(세션) 기준으로 테이블의 내 주문 목록만 조회") + @ApiResponse(responseCode = "200", description = "주문 조회") + public ResponseEntity getOrderItems( + @PathVariable Long storeId, + @PathVariable Long tableId, + HttpSession session + ) { + // 세션ID 추출 (Spring이 세션 자동 관리) + String sessionId = session.getId(); + + List orderItems = orderService.getOrderItems(storeId, tableId, sessionId); + return ResponseEntity. + status(HttpStatus.OK) + .body( + ApiUtils.success(orderItems) + ); + } +} diff --git a/api-user/src/main/java/com/example/apiuser/order/dto/CartItemDto.java b/api-user/src/main/java/com/example/apiuser/order/dto/CartItemDto.java new file mode 100644 index 00000000..1b8fc52b --- /dev/null +++ b/api-user/src/main/java/com/example/apiuser/order/dto/CartItemDto.java @@ -0,0 +1,11 @@ +package com.example.apiuser.order.dto; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class CartItemDto { + private final Long menuId; // 메뉴 ID + private final int quantity; // 수량 +} diff --git a/api-user/src/main/java/com/example/apiuser/order/dto/OrderCreateRequestDto.java b/api-user/src/main/java/com/example/apiuser/order/dto/OrderCreateRequestDto.java new file mode 100644 index 00000000..056d44a6 --- /dev/null +++ b/api-user/src/main/java/com/example/apiuser/order/dto/OrderCreateRequestDto.java @@ -0,0 +1,15 @@ +package com.example.apiuser.order.dto; + +import java.util.List; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Getter +@AllArgsConstructor +@Builder +public class OrderCreateRequestDto { + private final List items; // 장바구니 항목 리스트 + +} diff --git a/api-user/src/main/java/com/example/apiuser/order/dto/OrderCreateResponseDto.java b/api-user/src/main/java/com/example/apiuser/order/dto/OrderCreateResponseDto.java new file mode 100644 index 00000000..1293ac0f --- /dev/null +++ b/api-user/src/main/java/com/example/apiuser/order/dto/OrderCreateResponseDto.java @@ -0,0 +1,28 @@ +package com.example.apiuser.order.dto; + +import java.util.List; + +import com.nowait.domainorder.entity.UserOrder; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Getter +@AllArgsConstructor +@Builder +public class OrderCreateResponseDto { + private Long orderId; // 주문 ID // 주문 상태 (예: "주문완료", "배송중" 등) + private Long storeId; // 상점 ID + private String storeName; // 상점 이름 + private List orderItems; // 주문 항목 목록 + + public static OrderCreateResponseDto fromEntity(UserOrder order) { + return OrderCreateResponseDto.builder() + .orderId(order.getId()) + .storeId(order.getStore().getStoreId()) + .storeName(order.getStore().getName()) + .orderItems(List.of()) + .build(); + } +} diff --git a/api-user/src/main/java/com/example/apiuser/order/dto/OrderItemListGetResponseDto.java b/api-user/src/main/java/com/example/apiuser/order/dto/OrderItemListGetResponseDto.java new file mode 100644 index 00000000..02e24e3f --- /dev/null +++ b/api-user/src/main/java/com/example/apiuser/order/dto/OrderItemListGetResponseDto.java @@ -0,0 +1,29 @@ +package com.example.apiuser.order.dto; + +import com.nowait.domainorder.entity.OrderItem; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class OrderItemListGetResponseDto { + private Long orderId; + private String menuName; + private Integer quantity; + private Integer price; + + public static OrderItemListGetResponseDto fromEntity(OrderItem orderItem) { + return OrderItemListGetResponseDto.builder() + .orderId(orderItem.getUserOrder().getId()) + .menuName(orderItem.getMenu().getName()) + .quantity(orderItem.getQuantity()) + .price(orderItem.getMenu().getPrice()) + .build(); + + } +} diff --git a/api-user/src/main/java/com/example/apiuser/order/dto/OrderItemResponseDTO.java b/api-user/src/main/java/com/example/apiuser/order/dto/OrderItemResponseDTO.java new file mode 100644 index 00000000..12f04514 --- /dev/null +++ b/api-user/src/main/java/com/example/apiuser/order/dto/OrderItemResponseDTO.java @@ -0,0 +1,16 @@ +package com.example.apiuser.order.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class OrderItemResponseDTO { + private Long menuId; // 메뉴 ID + private String menuName; // 메뉴 이름 + private int quantity; // 수량 +} diff --git a/api-user/src/main/java/com/example/apiuser/order/service/OrderService.java b/api-user/src/main/java/com/example/apiuser/order/service/OrderService.java new file mode 100644 index 00000000..1644ba16 --- /dev/null +++ b/api-user/src/main/java/com/example/apiuser/order/service/OrderService.java @@ -0,0 +1,128 @@ +package com.example.apiuser.order.service; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.function.Function; +import java.util.stream.Collectors; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.DigestUtils; + +import com.example.apiuser.order.dto.CartItemDto; +import com.example.apiuser.order.dto.OrderCreateRequestDto; +import com.example.apiuser.order.dto.OrderCreateResponseDto; +import com.example.apiuser.order.dto.OrderItemListGetResponseDto; +import com.example.domainstore.entity.Store; +import com.example.domainstore.repository.StoreRepository; +import com.example.menu.entity.Menu; +import com.example.menu.repository.MenuRepository; +import com.nowait.domainorder.entity.OrderItem; +import com.nowait.domainorder.entity.UserOrder; +import com.nowait.domainorder.exception.DuplicateOrderException; +import com.nowait.domainorder.exception.OrderItemsEmptyException; +import com.nowait.domainorder.exception.OrderParameterEmptyException; +import com.nowait.domainorder.repository.OrderItemRepository; +import com.nowait.domainorder.repository.OrderRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class OrderService { + private final OrderRepository orderRepository; + private final StoreRepository storeRepository; + private final MenuRepository menuRepository; + private final OrderItemRepository orderItemRepository; + @Transactional + public OrderCreateResponseDto createOrder(Long storeId, Long tableId, + OrderCreateRequestDto orderCreateRequestDto, String sessionId) { + parameterValidation(storeId, tableId, orderCreateRequestDto); + + // 💡 [중복 주문 방지] signature 생성 및 체크 + String signature = generateOrderSignature(storeId, tableId, orderCreateRequestDto.getItems()); + checkDuplicateOrderSignature(signature); + + // 1. Store 조회 + Store store = storeRepository.findById(storeId) + .orElseThrow(() -> new IllegalArgumentException("store not found")); + + // 2. UserOrder 생성 및 signature 저장 + UserOrder order = UserOrder.builder() + .tableId(tableId) + .store(store) + .signature(signature) // signature 저장 + .sessionId(sessionId) // sessionId 저장 + .build(); + UserOrder savedOrder = orderRepository.save(order); + + // 3. 메뉴 ID 리스트 수집 -> Map으로 캐싱 + List menuIds = orderCreateRequestDto.getItems().stream() + .map(CartItemDto::getMenuId) + .toList(); + + List menus = menuRepository.findAllById(menuIds); + Map menuMap = menus.stream() + .collect(Collectors.toMap(Menu::getId, Function.identity())); + + // 4. 각 장바구니 항목에 대해 OrderItem 생성 및 저장 + List orderItems = orderCreateRequestDto.getItems().stream() + .map(item -> { + Menu menu = Optional.ofNullable(menuMap.get(item.getMenuId())) + .orElseThrow(() -> new IllegalArgumentException("menu not found: " + item.getMenuId())); + return OrderItem.builder() + .userOrder(savedOrder) + .menu(menu) + .quantity(item.getQuantity()) + .build(); + }) + .collect(Collectors.toList()); + + + orderItemRepository.saveAll(orderItems); + + // 5. 응답 반환 + return OrderCreateResponseDto.fromEntity(savedOrder); + } + + @Transactional(readOnly = true) + public List getOrderItems(Long storeId, Long tableId, String sessionId) { + // 1. UserOrder 목록 조회 (storeId, tableId, sessionId 기준) + List userOrders = orderRepository.findByStore_StoreIdAndTableIdAndSessionId(storeId, tableId, sessionId); + + // 2. OrderItem으로 변환 + return userOrders.stream() + .flatMap(order -> order.getOrderItems().stream()) + .map(OrderItemListGetResponseDto::fromEntity) + .toList(); + } + + + private static void parameterValidation(Long storeId, Long tableId, OrderCreateRequestDto orderCreateRequestDto) { + if (storeId == null || tableId == null || orderCreateRequestDto == null) { + throw new OrderParameterEmptyException(); + } + if (orderCreateRequestDto.getItems() == null || orderCreateRequestDto.getItems().isEmpty()) { + throw new OrderItemsEmptyException(); + } + } + private String generateOrderSignature(Long storeId, Long tableId, List items) { + String cartString = items.stream() + .sorted((a, b) -> a.getMenuId().compareTo(b.getMenuId())) // 메뉴 ID 기준 정렬 + .map(item -> item.getMenuId() + ":" + item.getQuantity()) + .collect(Collectors.joining(",")); + String raw = storeId + "-" + tableId + "-" + cartString; + return DigestUtils.md5DigestAsHex(raw.getBytes()); + } + + private void checkDuplicateOrderSignature(String signature) { + // 최근 2초 이내 동일 signature 주문이 있는지 검사 + LocalDateTime threshold = LocalDateTime.now().minusSeconds(2); + boolean exists = orderRepository.existsBySignatureAndCreatedAtAfter(signature, threshold); + if (exists) { + throw new DuplicateOrderException(); + } + } +} diff --git a/api-user/src/main/java/com/example/apiuser/reservation/controller/ReservationController.java b/api-user/src/main/java/com/example/apiuser/reservation/controller/ReservationController.java new file mode 100644 index 00000000..75416513 --- /dev/null +++ b/api-user/src/main/java/com/example/apiuser/reservation/controller/ReservationController.java @@ -0,0 +1,47 @@ +package com.example.apiuser.reservation.controller; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +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; + +import com.example.apiuser.reservation.dto.ReservationCreateRequestDto; +import com.example.apiuser.reservation.dto.ReservationCreateResponseDto; +import com.example.apiuser.reservation.service.ReservationService; +import com.nowait.auth.dto.CustomOAuth2User; +import com.nowaiting.common.api.ApiUtils; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; + +@Tag(name = "Reservation API", description = "예약 API") +@RestController +@RequestMapping("/reservations") +@RequiredArgsConstructor +public class ReservationController { + + private final ReservationService reservationService; + + @PostMapping("/create/{storeId}") + @Operation(summary = "예약 생성", description = "특정 주점에 대한 예약하기 생성") + @ApiResponse(responseCode = "201", description = "예약 생성") + public ResponseEntity create( + @PathVariable Long storeId, + @AuthenticationPrincipal CustomOAuth2User customOAuth2User, + @RequestBody ReservationCreateRequestDto requestDto) { + ReservationCreateResponseDto response = reservationService.create(storeId, customOAuth2User, requestDto); + return ResponseEntity + .status(HttpStatus.CREATED) + .body( + ApiUtils.success( + response + ) + ); + } +} diff --git a/api-user/src/main/java/com/example/apiuser/reservation/dto/ReservationCreateRequestDto.java b/api-user/src/main/java/com/example/apiuser/reservation/dto/ReservationCreateRequestDto.java new file mode 100644 index 00000000..03fbfb4f --- /dev/null +++ b/api-user/src/main/java/com/example/apiuser/reservation/dto/ReservationCreateRequestDto.java @@ -0,0 +1,10 @@ +package com.example.apiuser.reservation.dto; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class ReservationCreateRequestDto { + private Integer partySize; +} diff --git a/api-user/src/main/java/com/example/apiuser/reservation/dto/ReservationCreateResponseDto.java b/api-user/src/main/java/com/example/apiuser/reservation/dto/ReservationCreateResponseDto.java new file mode 100644 index 00000000..fa581589 --- /dev/null +++ b/api-user/src/main/java/com/example/apiuser/reservation/dto/ReservationCreateResponseDto.java @@ -0,0 +1,17 @@ +package com.example.apiuser.reservation.dto; + +import java.time.LocalDateTime; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class ReservationCreateResponseDto { + private Long id; + private Long storeId; + private Long userId; + private LocalDateTime requestedAt; + private String status; + private Integer partySize; +} diff --git a/api-user/src/main/java/com/example/apiuser/reservation/exception/ReservationNotFoundException.java b/api-user/src/main/java/com/example/apiuser/reservation/exception/ReservationNotFoundException.java new file mode 100644 index 00000000..37dc86eb --- /dev/null +++ b/api-user/src/main/java/com/example/apiuser/reservation/exception/ReservationNotFoundException.java @@ -0,0 +1,9 @@ +package com.example.apiuser.reservation.exception; + +import com.nowaiting.common.exception.ErrorMessage; + +public class ReservationNotFoundException extends RuntimeException { + public ReservationNotFoundException() { + super(ErrorMessage.NOTFOUND_RESERVATION.getMessage()); + } +} diff --git a/api-user/src/main/java/com/example/apiuser/reservation/service/ReservationService.java b/api-user/src/main/java/com/example/apiuser/reservation/service/ReservationService.java new file mode 100644 index 00000000..a5394ec8 --- /dev/null +++ b/api-user/src/main/java/com/example/apiuser/reservation/service/ReservationService.java @@ -0,0 +1,58 @@ +package com.example.apiuser.reservation.service; + +import java.time.LocalDateTime; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.example.apiuser.reservation.dto.ReservationCreateRequestDto; +import com.example.apiuser.reservation.dto.ReservationCreateResponseDto; +import com.example.domainstore.entity.Store; +import com.example.domainstore.repository.StoreRepository; +import com.nowait.auth.dto.CustomOAuth2User; +import com.nowait.domainreservation.entity.Reservation; +import com.nowait.domainreservation.repository.ReservationRepository; +import com.nowaiting.common.enums.ReservationStatus; +import com.nowaiting.user.entity.User; +import com.nowaiting.user.exception.UserNotFoundException; +import com.nowaiting.user.repository.UserRepository; + +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class ReservationService { + + private final ReservationRepository reservationRepository; + private final StoreRepository storeRepository; + private final UserRepository userRepository; + + @Transactional + public ReservationCreateResponseDto create(Long storeId, CustomOAuth2User customOAuth2User, + ReservationCreateRequestDto requestDto) { + Store store = storeRepository.findById(storeId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 store")); + User user = userRepository.findById(customOAuth2User.getUserId()) + .orElseThrow(UserNotFoundException::new); + + Reservation reservation = Reservation.builder() + .store(store) + .user(user) + .requestedAt(LocalDateTime.now()) + .status(ReservationStatus.WAITING) + .partySize(requestDto.getPartySize()) + .build(); + + Reservation saved = reservationRepository.save(reservation); + + return ReservationCreateResponseDto.builder() + .id(saved.getId()) + .storeId(saved.getStore().getStoreId()) + .userId(saved.getUser().getId()) + .requestedAt(saved.getRequestedAt()) + .status(saved.getStatus().name()) + .partySize(saved.getPartySize()) + .build(); + } +} + diff --git a/api-user/src/main/java/com/example/apiuser/store/controller/StoreController.java b/api-user/src/main/java/com/example/apiuser/store/controller/StoreController.java new file mode 100644 index 00000000..510b19d7 --- /dev/null +++ b/api-user/src/main/java/com/example/apiuser/store/controller/StoreController.java @@ -0,0 +1,56 @@ +package com.example.apiuser.store.controller; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +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.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import com.example.apiuser.store.service.StoreService; +import com.nowaiting.common.api.ApiUtils; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("v1/stores") +@RequiredArgsConstructor +public class StoreController { + + private final StoreService storeService; + + + @GetMapping("/all-stores") + public ResponseEntity getAllStores() { + return ResponseEntity + .status(HttpStatus.OK) + .body( + ApiUtils.success( + storeService.getAllStores() + ) + ); + } + + @GetMapping("/{storeId}") + public ResponseEntity getStoreById(@PathVariable Long storeId) { + return ResponseEntity + .status(HttpStatus.OK) + .body( + ApiUtils.success( + storeService.getStoreByStoreId(storeId) + ) + ); + } + + @GetMapping("/search") + public ResponseEntity searchStores(@RequestParam("name") String name) { + return ResponseEntity + .ok() + .body( + ApiUtils.success( + storeService.searchStoresByName(name) + ) + ); + } +} diff --git a/api-user/src/main/java/com/example/apiuser/store/dto/StoreImageUploadResponse.java b/api-user/src/main/java/com/example/apiuser/store/dto/StoreImageUploadResponse.java new file mode 100644 index 00000000..40cb984d --- /dev/null +++ b/api-user/src/main/java/com/example/apiuser/store/dto/StoreImageUploadResponse.java @@ -0,0 +1,22 @@ +package com.example.apiuser.store.dto; + +import com.example.domainstore.entity.StoreImage; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class StoreImageUploadResponse { + private final Long id; + private final String imageUrl; + private final String type; + + public static StoreImageUploadResponse fromEntity(StoreImage storeImage) { + return StoreImageUploadResponse.builder() + .id(storeImage.getId()) + .imageUrl(storeImage.getImageUrl()) + .type(storeImage.getType()) + .build(); + } +} diff --git a/api-user/src/main/java/com/example/apiuser/store/dto/StoreReadDto.java b/api-user/src/main/java/com/example/apiuser/store/dto/StoreReadDto.java new file mode 100644 index 00000000..d6cfab6e --- /dev/null +++ b/api-user/src/main/java/com/example/apiuser/store/dto/StoreReadDto.java @@ -0,0 +1,39 @@ +package com.example.apiuser.store.dto; + +import java.time.LocalDateTime; +import java.util.List; + +import com.example.domainstore.entity.Store; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Getter +@AllArgsConstructor +@Builder +public class StoreReadDto { + private Long storeId; + private Long departmentId; + private String name; + private String location; + private String description; + private List images; + private Boolean isActive; + private Boolean deleted; + private LocalDateTime createdAt; + + public static StoreReadDto fromEntity(Store store, List images) { + return StoreReadDto.builder() + .createdAt(store.getCreatedAt()) + .storeId(store.getStoreId()) + .departmentId(store.getDepartmentId()) + .name(store.getName()) + .location(store.getLocation()) + .description(store.getDescription()) + .isActive(store.getIsActive()) + .deleted(store.getDeleted()) + .images(images) + .build(); + } +} diff --git a/api-user/src/main/java/com/example/apiuser/store/dto/StoreReadResponse.java b/api-user/src/main/java/com/example/apiuser/store/dto/StoreReadResponse.java new file mode 100644 index 00000000..a580e562 --- /dev/null +++ b/api-user/src/main/java/com/example/apiuser/store/dto/StoreReadResponse.java @@ -0,0 +1,23 @@ +package com.example.apiuser.store.dto; + +import java.util.List; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; + +@Getter +@AllArgsConstructor +@Builder +public class StoreReadResponse { + + private List storeReadDtos; + private boolean hasNext; + + public static StoreReadResponse of(List storeReadDtos, boolean hasNext) { + return StoreReadResponse.builder() + .storeReadDtos(storeReadDtos) + .hasNext(hasNext) + .build(); + } +} diff --git a/api-user/src/main/java/com/example/apiuser/store/service/StoreService.java b/api-user/src/main/java/com/example/apiuser/store/service/StoreService.java new file mode 100644 index 00000000..fd90de2a --- /dev/null +++ b/api-user/src/main/java/com/example/apiuser/store/service/StoreService.java @@ -0,0 +1,16 @@ +package com.example.apiuser.store.service; + +import java.util.List; + +import com.example.apiuser.store.dto.StoreReadDto; +import com.example.apiuser.store.dto.StoreReadResponse; + +public interface StoreService { + + StoreReadResponse getAllStores(); + + StoreReadDto getStoreByStoreId(Long storeId); + + List searchStoresByName(String name); + +} diff --git a/api-user/src/main/java/com/example/apiuser/store/service/StoreServiceImpl.java b/api-user/src/main/java/com/example/apiuser/store/service/StoreServiceImpl.java new file mode 100644 index 00000000..d641f03e --- /dev/null +++ b/api-user/src/main/java/com/example/apiuser/store/service/StoreServiceImpl.java @@ -0,0 +1,73 @@ +package com.example.apiuser.store.service; + +import java.util.List; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import com.example.apiuser.store.dto.StoreImageUploadResponse; +import com.example.apiuser.store.dto.StoreReadDto; +import com.example.apiuser.store.dto.StoreReadResponse; +import com.example.domainstore.entity.Store; +import com.example.domainstore.entity.StoreImage; +import com.example.domainstore.repository.StoreImageRepository; +import com.example.domainstore.repository.StoreRepository; + +import jakarta.persistence.EntityNotFoundException; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class StoreServiceImpl implements StoreService { + + private final StoreRepository storeRepository; + private final StoreImageRepository storeImageRepository; + + + @Override + @Transactional(readOnly = true) + public StoreReadResponse getAllStores() { + List stores = storeRepository.findAllByDeletedFalse(); + + List storeRead = stores.stream() + .map(store -> { + List images = storeImageRepository.findByStore(store); + List imageDto = images.stream() + .map(StoreImageUploadResponse::fromEntity) + .toList(); + return StoreReadDto.fromEntity(store, imageDto); + }) + .toList(); + + boolean hasNext = false; + + return StoreReadResponse.of(storeRead, hasNext); + } + + @Override + @Transactional(readOnly = true) + public StoreReadDto getStoreByStoreId(Long storeId) { + Store store = storeRepository.findByStoreIdAndDeletedFalse(storeId) + .orElseThrow(() -> new EntityNotFoundException(storeId + " store not found.")); + + List images = storeImageRepository.findByStore(store); + List imageDto = images.stream() + .map(StoreImageUploadResponse::fromEntity) + .toList(); + + return StoreReadDto.fromEntity(store, imageDto); + } + + @Override + public List searchStoresByName(String name) { + List stores = storeRepository.findByNameContainingIgnoreCaseAndDeletedFalse(name); + return stores.stream() + .map(store -> { + List images = storeImageRepository.findByStore(store); + List imageDto = images.stream() + .map(StoreImageUploadResponse::fromEntity) + .toList(); + return StoreReadDto.fromEntity(store, imageDto); + }) + .toList(); + } +} diff --git a/api-user/src/main/java/com/example/apiuser/token/controller/TokenController.java b/api-user/src/main/java/com/example/apiuser/token/controller/TokenController.java new file mode 100644 index 00000000..67bafe53 --- /dev/null +++ b/api-user/src/main/java/com/example/apiuser/token/controller/TokenController.java @@ -0,0 +1,59 @@ +package com.example.apiuser.token.controller; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +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; + +import com.example.apiuser.token.dto.AuthenticationResponse; +import com.example.apiuser.token.dto.RefreshTokenRequest; +import com.example.apiuser.token.service.TokenService; +import com.nowait.auth.jwt.JwtUtil; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/refresh-token") +@Slf4j +public class TokenController { + private final JwtUtil jwtUtil; + private final TokenService tokenService; + @Value("${jwt.access-token-expiration-ms}") + private long accessTokenExpiration; + @Value("${jwt.refresh-token-expiration-ms}") + private long refreshTokenExpiration; + @PostMapping + public ResponseEntity refreshToken(@RequestBody RefreshTokenRequest request){ + String refreshToken = request.getRefreshToken(); + + // 리프레시 토큰 검증 + Long userId = jwtUtil.getUserId(refreshToken); + String role = jwtUtil.getRole(refreshToken); + + long currentAccessTokenExpiration = accessTokenExpiration; + if (role.equals("SUPER_ADMIN")) { + currentAccessTokenExpiration = 7L * 24 * 60 * 60 * 1000L; // 7일 + } + + // 리프레시 토큰 유효성 검증 + if (tokenService.validateToken(refreshToken, userId)){ + // 유효한 토큰이라면, 새로운 accessToken, refreshToken 생성 + String newAccessToken = jwtUtil.createAccessToken("accessToken", userId, role, currentAccessTokenExpiration); + String newRefreshToken = jwtUtil.createRefreshToken("refreshToken", userId, refreshTokenExpiration); + + // DB에 새로운 refreshToken으로 교체 + tokenService.updateRefreshToken(userId, refreshToken, newRefreshToken); + + AuthenticationResponse authenticationResponse = new AuthenticationResponse(newAccessToken, refreshToken); + return ResponseEntity.ok().body(authenticationResponse); + + } + + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body("Invalid or expired refresh token"); + } +} diff --git a/api-user/src/main/java/com/example/apiuser/token/dto/AuthenticationResponse.java b/api-user/src/main/java/com/example/apiuser/token/dto/AuthenticationResponse.java new file mode 100644 index 00000000..71db8914 --- /dev/null +++ b/api-user/src/main/java/com/example/apiuser/token/dto/AuthenticationResponse.java @@ -0,0 +1,18 @@ +package com.example.apiuser.token.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import lombok.ToString; + +@RequiredArgsConstructor +@Getter +@ToString(exclude = {"accessToken", "refreshToken"}) // 로깅 시 토큰 노출 방지 +public class AuthenticationResponse { + @JsonProperty("access_token") + private final String accessToken; + + @JsonProperty("refresh_token") + private final String refreshToken; +} diff --git a/api-user/src/main/java/com/example/apiuser/token/dto/RefreshTokenRequest.java b/api-user/src/main/java/com/example/apiuser/token/dto/RefreshTokenRequest.java new file mode 100644 index 00000000..4ab8ae75 --- /dev/null +++ b/api-user/src/main/java/com/example/apiuser/token/dto/RefreshTokenRequest.java @@ -0,0 +1,12 @@ +package com.example.apiuser.token.dto; + +import jakarta.validation.constraints.NotBlank; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class RefreshTokenRequest { + @NotBlank(message = "Refresh token은 필수입니다.") + private String refreshToken; +} diff --git a/api-user/src/main/java/com/example/apiuser/token/service/TokenService.java b/api-user/src/main/java/com/example/apiuser/token/service/TokenService.java new file mode 100644 index 00000000..56845238 --- /dev/null +++ b/api-user/src/main/java/com/example/apiuser/token/service/TokenService.java @@ -0,0 +1,66 @@ +package com.example.apiuser.token.service; + +import java.time.LocalDateTime; +import java.util.Optional; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import com.example.domaintoken.entity.Token; +import com.example.domaintoken.repository.TokenRepository; +import com.nowait.auth.jwt.JwtUtil; +import com.nowait.exception.RefreshTokenNotFoundException; +import com.nowait.exception.TokenBadRequestException; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Service +@RequiredArgsConstructor +@Slf4j +public class TokenService { + private final TokenRepository tokenRepository; + private final JwtUtil jwtUtil; + + @Transactional + public Boolean validateToken(String token, Long userId){ + // DB에서 해당 userId와 일치하는 리프레시토큰을 찾는다. + Optional savedToken = tokenRepository.findByUserId(userId); + + // DB에서 userId에 대응되는 리프레시토큰 없으면, 유효하지 않음 + if (savedToken.isEmpty()){ + log.info("여기에 걸렸니 ? -- 1 "); + return false; + } + + // 리프레시 토큰이 DB에 저장된 토큰과 일치하는지 확인 + if (!savedToken.get().getRefreshToken().equals(token)){ + log.info("여기에 걸렸니 ? -- 2 "); + return false; + } + + // 리프레시 토큰의 만료여부 확인 + if(jwtUtil.isExpired(token)){ + log.info("여기에 걸렸니 ? -- 3 "); + return false; // 만료된 토큰은 유효하지 않음 + } + + log.info("여기에 걸렸니 ? -- 4 "); + return true; // 모든 조건 만족 시, 유효한 토큰 + } + + @Transactional + public void updateRefreshToken(Long userId, String oldRefreshToken, String newRefreshToken){ + Token token = tokenRepository.findByUserId(userId) + .orElseThrow(RefreshTokenNotFoundException::new); // 404 + + if (!token.getRefreshToken().equals(oldRefreshToken)){ + throw new TokenBadRequestException(); // 400 + } + + // 기존 토큰 삭제 및 새 토큰 저장 + tokenRepository.delete(token); + Token newToken = Token.toEntity(token.getUser(), newRefreshToken, LocalDateTime.now().plusDays(30)); + tokenRepository.save(newToken); + } +} diff --git a/application-admin/src/main/java/com/nowait/applicationadmin/user/serivce/UserService.java b/application-admin/src/main/java/com/nowait/applicationadmin/user/serivce/UserService.java index 9a11c497..7a797a9b 100644 --- a/application-admin/src/main/java/com/nowait/applicationadmin/user/serivce/UserService.java +++ b/application-admin/src/main/java/com/nowait/applicationadmin/user/serivce/UserService.java @@ -60,7 +60,13 @@ public ManagerLoginResponseDto login(ManagerLoginRequestDto managerLoginRequestD ); MemberDetails memberDetails = (MemberDetails) authentication.getPrincipal(); User user = userRepository.getReferenceById(memberDetails.getId()); - String accessToken = jwtUtil.createAccessToken("accessToken", user.getId(), String.valueOf(user.getRole()), accessTokenExpiration); + + long currentAccessTokenExpiration = accessTokenExpiration; + if (user.getRole() == com.nowait.common.enums.Role.SUPER_ADMIN) { + currentAccessTokenExpiration = 7L * 24 * 60 * 60 * 1000L; // 7일 + } + + String accessToken = jwtUtil.createAccessToken("accessToken", user.getId(), String.valueOf(user.getRole()), currentAccessTokenExpiration); return ManagerLoginResponseDto.fromEntity(user,accessToken); } } diff --git a/application-config/src/main/java/com/example/applicationconfig/config/AsyncConfig.java b/application-config/src/main/java/com/example/applicationconfig/config/AsyncConfig.java new file mode 100644 index 00000000..9c839fbb --- /dev/null +++ b/application-config/src/main/java/com/example/applicationconfig/config/AsyncConfig.java @@ -0,0 +1,21 @@ +package com.example.applicationconfig.config; + +import java.util.concurrent.Executor; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; + +@Configuration +public class AsyncConfig { + @Bean(name = "s3UploadExecutor") + public Executor s3UploadExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(5); + executor.setMaxPoolSize(10); + executor.setQueueCapacity(100); + executor.setThreadNamePrefix("S3Upload-"); + executor.initialize(); + return executor; + } +} diff --git a/application-config/src/main/java/com/example/applicationconfig/config/CorsConfig.java b/application-config/src/main/java/com/example/applicationconfig/config/CorsConfig.java new file mode 100644 index 00000000..d8615194 --- /dev/null +++ b/application-config/src/main/java/com/example/applicationconfig/config/CorsConfig.java @@ -0,0 +1,28 @@ +package com.example.applicationconfig.config; + +import java.util.List; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +@Configuration +public class CorsConfig { + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration config = new CorsConfiguration(); + + config.setAllowCredentials(true); // 쿠키나 인증헤더 자격증명 허용 + config.setAllowedOrigins(List.of("http://localhost:3000")); // 허용할 출처 설정 + config.setAllowedMethods(List.of("GET", "POST", "PATCH", "PUT", "DELETE", "OPTIONS")); // 메서드 허용 + config.setAllowedHeaders(List.of("*")); //클라이언트가 보낼 수 있는 헤더 + config.setExposedHeaders(List.of("Authorization")); //클라이언트(브라우저)가 접근할 수 있는 헤더 지정 + // config.setAllowCredentials(true); // 쿠키 포함 허용 + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", config); //** 뜻은 모든 URL 경로에 적용한다는 의미 + return source; + } +} diff --git a/application-user/src/main/java/com/nowait/applicationuser/token/controller/TokenController.java b/application-user/src/main/java/com/nowait/applicationuser/token/controller/TokenController.java index c9298d04..009fafe4 100644 --- a/application-user/src/main/java/com/nowait/applicationuser/token/controller/TokenController.java +++ b/application-user/src/main/java/com/nowait/applicationuser/token/controller/TokenController.java @@ -40,10 +40,15 @@ public ResponseEntity refreshToken(@RequestBody RefreshTokenRequest request){ Long userId = jwtUtil.getUserId(refreshToken); String role = jwtUtil.getRole(refreshToken); + long currentAccessTokenExpiration = accessTokenExpiration; + if (role.equals("SUPER_ADMIN")) { + currentAccessTokenExpiration = 7L * 24 * 60 * 60 * 1000L; // 7일 + } + // 리프레시 토큰 유효성 검증 if (tokenService.validateToken(refreshToken, userId)){ // 유효한 토큰이라면, 새로운 accessToken, refreshToken 생성 - String newAccessToken = jwtUtil.createAccessToken("accessToken", userId, role, accessTokenExpiration); + String newAccessToken = jwtUtil.createAccessToken("accessToken", userId, role, currentAccessTokenExpiration); String newRefreshToken = jwtUtil.createRefreshToken("refreshToken", userId, refreshTokenExpiration); // DB에 새로운 refreshToken으로 교체 diff --git a/common/src/main/java/com/nowait/common/enums/Role.java b/common/src/main/java/com/nowait/common/enums/Role.java index 5aed5a91..ddbd6911 100644 --- a/common/src/main/java/com/nowait/common/enums/Role.java +++ b/common/src/main/java/com/nowait/common/enums/Role.java @@ -2,7 +2,8 @@ public enum Role { USER("USER"), - MANAGER("MANAGER"); + MANAGER("MANAGER"), + SUPER_ADMIN("SUPER_ADMIN"); private final String name; diff --git a/domain-token/src/main/java/com/example/domaintoken/entity/Token.java b/domain-token/src/main/java/com/example/domaintoken/entity/Token.java new file mode 100644 index 00000000..3bd47167 --- /dev/null +++ b/domain-token/src/main/java/com/example/domaintoken/entity/Token.java @@ -0,0 +1,54 @@ +package com.example.domaintoken.entity; + +import java.time.LocalDateTime; + +import com.nowait.user.entity.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 lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Table(name = "token") +@Getter +public class Token { + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long tokenId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column + private String refreshToken; + + private LocalDateTime expiredDate; + + @Builder + public Token(User user, String refreshToken, LocalDateTime expiredDate) { + this.user = user; + this.refreshToken = refreshToken; + this.expiredDate = expiredDate; + } + + // static method로 객체를 생성 - 생성 의도 파악 쉬웁 + public static Token toEntity(User user, String refreshToken, LocalDateTime expiredDate){ + return Token.builder() + .user(user) + .refreshToken(refreshToken) + .expiredDate(expiredDate) + .build(); + } + +} diff --git a/domain-token/src/main/java/com/example/domaintoken/exception/BusinessException.java b/domain-token/src/main/java/com/example/domaintoken/exception/BusinessException.java new file mode 100644 index 00000000..05dc5c67 --- /dev/null +++ b/domain-token/src/main/java/com/example/domaintoken/exception/BusinessException.java @@ -0,0 +1,16 @@ +package com.example.domaintoken.exception; + +import com.nowaiting.common.exception.ErrorMessage; + +public abstract class BusinessException extends RuntimeException { + private final ErrorMessage errorMessage; + + protected BusinessException(ErrorMessage errorMessage) { + super(errorMessage.getMessage()); + this.errorMessage = errorMessage; + } + + public String getCode() { + return errorMessage.getCode(); + } +} diff --git a/domain-token/src/main/java/com/example/domaintoken/repository/TokenRepository.java b/domain-token/src/main/java/com/example/domaintoken/repository/TokenRepository.java new file mode 100644 index 00000000..d813fd09 --- /dev/null +++ b/domain-token/src/main/java/com/example/domaintoken/repository/TokenRepository.java @@ -0,0 +1,11 @@ +package com.example.domaintoken.repository; + +import java.util.Optional; + +import org.springframework.data.jpa.repository.JpaRepository; + +import com.example.domaintoken.entity.Token; + +public interface TokenRepository extends JpaRepository { + Optional findByUserId(Long userId); +} diff --git a/infra-aws/src/main/java/com/example/infrastorage/config/AwsS3Config.java b/infra-aws/src/main/java/com/example/infrastorage/config/AwsS3Config.java new file mode 100644 index 00000000..4eda87c2 --- /dev/null +++ b/infra-aws/src/main/java/com/example/infrastorage/config/AwsS3Config.java @@ -0,0 +1,33 @@ +package com.example.infrastorage.config; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +import com.amazonaws.auth.AWSStaticCredentialsProvider; +import com.amazonaws.auth.BasicAWSCredentials; +import com.amazonaws.services.s3.AmazonS3Client; +import com.amazonaws.services.s3.AmazonS3ClientBuilder; + +@Configuration +public class AwsS3Config { + + @Value("${cloud.aws.credentials.access-key}") + private String accessKey; + + @Value("${cloud.aws.credentials.secret-key}") + private String secretKey; + + @Value("${cloud.aws.region.static}") + private String region; + + @Bean + public AmazonS3Client amazonS3Client() { + BasicAWSCredentials awsCredentials = new BasicAWSCredentials(accessKey, secretKey); + return (AmazonS3Client)AmazonS3ClientBuilder.standard() + .withRegion(region) + .withCredentials(new AWSStaticCredentialsProvider(awsCredentials)) + .build(); + } + +} diff --git a/infra-aws/src/main/java/com/example/infrastorage/s3/S3Service.java b/infra-aws/src/main/java/com/example/infrastorage/s3/S3Service.java new file mode 100644 index 00000000..20d9abf0 --- /dev/null +++ b/infra-aws/src/main/java/com/example/infrastorage/s3/S3Service.java @@ -0,0 +1,57 @@ +package com.example.infrastorage.s3; + +import java.io.InputStream; +import java.util.UUID; +import java.util.concurrent.CompletableFuture; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import com.amazonaws.services.s3.AmazonS3Client; +import com.amazonaws.services.s3.model.ObjectMetadata; + +import io.github.resilience4j.bulkhead.annotation.Bulkhead; +import lombok.RequiredArgsConstructor; + +@Service +@RequiredArgsConstructor +public class S3Service { + private final AmazonS3Client amazonS3Client; + + @Value("${cloud.aws.s3.bucket}") + private String bucket; + + public record S3UploadResult(String key, String url) { + } + + @Bulkhead(name = "s3UploadBulkhead", type = Bulkhead.Type.THREADPOOL) + @Async("s3UploadExecutor") + public CompletableFuture upload(String type, Long refId, MultipartFile file) { // TODO MultipartFile 분리 필요 (Spring에 의존하면 안 됨) + try (InputStream inputStream = file.getInputStream()) { + String key = createFileKey(type, refId, file.getOriginalFilename()); + ObjectMetadata metadata = new ObjectMetadata(); + metadata.setContentLength(file.getSize()); + + amazonS3Client.putObject(bucket, key, inputStream, metadata); + String url = amazonS3Client.getUrl(bucket, key).toString(); + + return CompletableFuture.completedFuture(new S3UploadResult(key, url)); + } catch (Exception e) { + throw new RuntimeException("S3 업로드 실패", e); + } + } + + public void delete(String filename) { + try { + amazonS3Client.deleteObject(bucket, filename); + } catch (Exception e) { + throw new RuntimeException("S3 파일 삭제 실패", e); + } + } + + private String createFileKey(String type, Long refId, String filename) { + return type + "/" + refId + "/" + UUID.randomUUID() + "-" + filename; + } +} diff --git a/security-front/src/main/java/com/nowait/auth/config/CorsConfig.java b/security-front/src/main/java/com/nowait/auth/config/CorsConfig.java new file mode 100644 index 00000000..34b293b5 --- /dev/null +++ b/security-front/src/main/java/com/nowait/auth/config/CorsConfig.java @@ -0,0 +1,28 @@ +package com.nowait.auth.config; + +import java.util.List; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +@Configuration +public class CorsConfig { + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration config = new CorsConfiguration(); + + config.setAllowCredentials(true); // 쿠키나 인증헤더 자격증명 허용 + config.setAllowedOrigins(List.of("http://localhost:3000", "http://localhost:8083")); // 허용할 출처 설정 + config.setAllowedMethods(List.of("GET", "POST", "PATCH", "PUT", "DELETE", "OPTIONS")); // 메서드 허용 + config.setAllowedHeaders(List.of("*")); //클라이언트가 보낼 수 있는 헤더 + config.setExposedHeaders(List.of("Authorization")); //클라이언트(브라우저)가 접근할 수 있는 헤더 지정 + // config.setAllowCredentials(true); // 쿠키 포함 허용 + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", config); //** 뜻은 모든 URL 경로에 적용한다는 의미 + return source; + } +} diff --git a/security-front/src/main/java/com/nowait/auth/config/SecurityConfig.java b/security-front/src/main/java/com/nowait/auth/config/SecurityConfig.java new file mode 100644 index 00000000..aa535f8c --- /dev/null +++ b/security-front/src/main/java/com/nowait/auth/config/SecurityConfig.java @@ -0,0 +1,93 @@ +package com.nowait.auth.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.authentication.AuthenticationProvider; +import org.springframework.security.authentication.dao.DaoAuthenticationProvider; +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; +import org.springframework.web.cors.CorsConfigurationSource; + +import com.nowait.auth.jwt.JwtAuthorizationFilter; +import com.nowait.auth.jwt.JwtUtil; +import com.nowait.auth.oauth2.CustomOAuth2UserService; +import com.nowait.auth.oauth2.OAuth2LoginSuccessHandler; +import com.nowait.auth.service.CustomUserDetailService; + +import lombok.RequiredArgsConstructor; + +@Configuration +@EnableWebSecurity // security 활성화 어노테이션 +@RequiredArgsConstructor +public class SecurityConfig { + private final CustomOAuth2UserService customOAuth2UserService; + private final OAuth2LoginSuccessHandler OAuth2LoginSuccessHandler; + private final JwtUtil jwtUtil; + private final CustomUserDetailService userDetailsService; + + @Bean + public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { + http + // CSRF 방어 기능 비활성화 (jwt 토큰을 사용할 것이기에 필요없음) + .csrf(AbstractHttpConfigurer::disable) + // 시큐리티 폼 로그인 비활성화 + .formLogin(AbstractHttpConfigurer::disable) + // HTTP Basic 인증 비활성화 + .httpBasic(AbstractHttpConfigurer::disable) + // oauth2 로그인 + // - userInfoEndPoint에서 사용자 정보 불러오고, + // - successHandler에서 로그인 성공 시 JWT 생성 및 반환로직 + .oauth2Login(oauth2 -> + oauth2.userInfoEndpoint(userInfoEndpoint -> + userInfoEndpoint.userService(customOAuth2UserService) + ).successHandler(OAuth2LoginSuccessHandler) + ) + // 세션 사용하지 않음 + .sessionManagement(session -> + session.sessionCreationPolicy(SessionCreationPolicy.STATELESS) + ) + .authorizeHttpRequests(auth -> auth + .requestMatchers( + "/oauth2/authorization/kakao", // 카카오 로그인 요청 + "/login/oauth2/code/**", // 카카오 인증 콜백 + "/api/refresh-token", // refresh token (토큰 갱신) + "orders/**", // 주문 관련 API + "/v1/menus/**", + "/v1/stores/**", + "/api/users/signup", + "/api/users/login", + "/swagger-ui/**", + "/v3/api-docs/**", + "/v3/api-docs.json", + "/api-docs/**", + "/swagger-resources/**", + "/webjars/**", + "/demo-ui.html" + ) + .permitAll() + .anyRequest().authenticated() // 그외 요청은 허가된 사람만 인가 + ) + // JWTFiler + .addFilterBefore(new JwtAuthorizationFilter(jwtUtil), UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } + @Bean + public AuthenticationProvider authenticationProvider() { + DaoAuthenticationProvider authenticationProvider = new DaoAuthenticationProvider(); + authenticationProvider.setUserDetailsService(userDetailsService); + authenticationProvider.setPasswordEncoder(passwordEncoder()); + return authenticationProvider; + } + +} diff --git a/security-front/src/main/java/com/nowait/auth/dto/CustomOAuth2User.java b/security-front/src/main/java/com/nowait/auth/dto/CustomOAuth2User.java new file mode 100644 index 00000000..af969966 --- /dev/null +++ b/security-front/src/main/java/com/nowait/auth/dto/CustomOAuth2User.java @@ -0,0 +1,60 @@ +package com.nowait.auth.dto; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.Map; + +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.oauth2.core.user.OAuth2User; + +import com.nowait.user.entity.User; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public class CustomOAuth2User implements OAuth2User { + private User user; + + // User 객체를 받는 생성자 + public CustomOAuth2User(User user) { + this.user = user; + } + + @Override + public Map getAttributes() { + return null; + } + + // 사용자가 가지는 권한 설정 + @Override + public Collection getAuthorities() { + Collection authorities = new ArrayList<>(); + + authorities.add(new GrantedAuthority() { + @Override + public String getAuthority() { + return user.getRole().getName(); // 유저의 권한 리턴 + } + }); + + return authorities; + } + + @Override + public String getName() { + return user.getEmail(); + } + + public User getUser() { + return user; + } + + public Long getUserId() { + return user.getId(); + } + + public String getNickname() { + return user.getNickname(); + } + +} diff --git a/security-front/src/main/java/com/nowait/auth/dto/KaKaoResponse.java b/security-front/src/main/java/com/nowait/auth/dto/KaKaoResponse.java new file mode 100644 index 00000000..e745bd26 --- /dev/null +++ b/security-front/src/main/java/com/nowait/auth/dto/KaKaoResponse.java @@ -0,0 +1,40 @@ +package com.nowait.auth.dto; + +import java.util.Map; + +import lombok.RequiredArgsConstructor; + +// 카카오 OAuth2 응답에서 필요한 정보를 추출하는 역할 +@RequiredArgsConstructor +public class KaKaoResponse implements OAuth2Response { + private final Map attributes; + + @Override + public String getProvider() { + return "kakao"; + } + + @Override + public String getProviderId() { + return attributes.get("id").toString(); + } + + @Override + public String getEmail() { + Map kakaoAccount = (Map)attributes.get("kakao_account"); + return kakaoAccount.get("email").toString(); + } + + @Override + public String getNickName() { + Map properties = (Map)attributes.get("properties"); + return properties.get("nickname").toString(); + } + + @Override + public String getProfileImage() { + Map properties = (Map)attributes.get("properties"); + return properties.get("profile_image").toString(); + } + +} diff --git a/security-front/src/main/java/com/nowait/auth/dto/OAuth2Response.java b/security-front/src/main/java/com/nowait/auth/dto/OAuth2Response.java new file mode 100644 index 00000000..de987480 --- /dev/null +++ b/security-front/src/main/java/com/nowait/auth/dto/OAuth2Response.java @@ -0,0 +1,19 @@ +package com.nowait.auth.dto; + +public interface OAuth2Response { + // 제공자 (ex. naver, kakao) + String getProvider(); + + // 제공자에서 발급해주는 아이디 (번호) + String getProviderId(); + + // 아래 이메일, 닉네임, 프로필이미지는 내가 카카오 developers에서 발급받겠다고 신청한 정보들이다 + // 이메일 + String getEmail(); + + // 닉네임 + String getNickName(); + + // 프로필이미지 + String getProfileImage(); +} diff --git a/security-front/src/main/java/com/nowait/auth/jwt/JwtAuthorizationFilter.java b/security-front/src/main/java/com/nowait/auth/jwt/JwtAuthorizationFilter.java new file mode 100644 index 00000000..97a341d3 --- /dev/null +++ b/security-front/src/main/java/com/nowait/auth/jwt/JwtAuthorizationFilter.java @@ -0,0 +1,93 @@ +package com.nowait.auth.jwt; + +import java.io.IOException; + +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.web.filter.OncePerRequestFilter; + +import com.nowait.auth.dto.CustomOAuth2User; +import com.nowait.common.enums.Role; +import com.nowait.common.enums.SocialType; +import com.nowait.user.entity.User; + +import io.jsonwebtoken.ExpiredJwtException; +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +// JWT 검증 필터 +// 1. 헤더에서 accessToken 추출, 2. 토큰 검증, 3. 유효하면 사용자정보를 SecurityContextHolder에 세팅 +// 그러면, 이후 컨트롤러에서 @AuthenticationPrincipal에서 저장했던 사용자 정보를 꺼내쓸 수 있음 +@RequiredArgsConstructor +@Slf4j +public class JwtAuthorizationFilter extends OncePerRequestFilter { + private final JwtUtil jwtUtil; + + @Override + protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) + throws ServletException, IOException { + String header = request.getHeader("Authorization"); + + // 인증헤더 Bearer가 없다면, 다음 필터로 넘김 + if (header == null || !header.startsWith("Bearer ")) { + filterChain.doFilter(request, response); + + log.info("JwtAuthorizationFilter 1 "); + return; + } + + log.info("header :: {}, header.substring(7) :: {}", header, header.substring(7)); + String accessToken = header.substring(7); + + // 토큰 만료 여부 확인, 만료 시 다음 필터로 넘기지 않음 + try { + jwtUtil.isExpired(accessToken); + } catch (ExpiredJwtException e) { + + // response status code + msg + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.getWriter().print("access token expired"); + + log.info("JwtAuthorizationFilter 2 "); + return; + } + + // 토큰이 accessToken 종류인지 확인 + String tokenCategory = jwtUtil.getTokenCategory(accessToken); + + if (!tokenCategory.equals("accessToken")) { + //response status code + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.getWriter().print("invalid access token"); + + log.info("JwtAuthorizationFilter 3 "); + return; + } + + // userId와 role 값 추출 + Long userId = jwtUtil.getUserId(accessToken); + String roleString = jwtUtil.getRole(accessToken); + + User user = User.createUserWithId(userId, "sampleEmail", "sampleNickname", "sampleProfileImg" + , SocialType.KAKAO, Role.fromString(roleString)); + + CustomOAuth2User customOAuth2User = new CustomOAuth2User(user); + + // 스프링 시큐리티 인증 토큰 생성 + UsernamePasswordAuthenticationToken usernamePasswordAuthenticationToken = new UsernamePasswordAuthenticationToken( + customOAuth2User, null, customOAuth2User.getAuthorities()); + + // 생성한 인증 정보를 SecurityContext에 설정 + SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken); + + log.info("JwtAuthorizationFilter 4 "); + + filterChain.doFilter(request, response); + + } + +} diff --git a/security-front/src/main/java/com/nowait/auth/jwt/JwtUtil.java b/security-front/src/main/java/com/nowait/auth/jwt/JwtUtil.java new file mode 100644 index 00000000..fd0620e9 --- /dev/null +++ b/security-front/src/main/java/com/nowait/auth/jwt/JwtUtil.java @@ -0,0 +1,76 @@ +package com.nowait.auth.jwt; + +import java.nio.charset.StandardCharsets; +import java.util.Date; + +import javax.crypto.SecretKey; +import javax.crypto.spec.SecretKeySpec; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import io.jsonwebtoken.Jwts; + +@Component +public class JwtUtil { + private final SecretKey secretKey; + + // 시크릿 키를 암호화하여, 키 생성 + public JwtUtil(@Value("${jwt.secret}") String secret) { + this.secretKey = new SecretKeySpec( + secret.getBytes(StandardCharsets.UTF_8), + Jwts.SIG.HS256.key().build().getAlgorithm() + ); + } + + public String createAccessToken(String tokenCategory, Long userId, String role, Long expiredMs) { + return Jwts.builder() + .claim("tokenCategory", tokenCategory) // accessToken + .claim("userId", userId) + .claim("role", role) + .issuedAt(new Date(System.currentTimeMillis())) + .expiration(new Date(System.currentTimeMillis() + expiredMs)) + .signWith(secretKey) + .compact(); + } + + public String createRefreshToken(String tokenCategory, Long userId, Long expiredMs) { + return Jwts.builder() + .claim("tokenCategory", tokenCategory) // refreshToken + .claim("userId", userId) + .issuedAt(new Date(System.currentTimeMillis())) + .expiration(new Date(System.currentTimeMillis() + expiredMs)) + .signWith(secretKey) + .compact(); + } + + public String getTokenCategory(String token) { + return Jwts.parser().verifyWith(secretKey).build() + .parseClaimsJws(token) + .getBody() + .get("tokenCategory", String.class); + } + + public String getRole(String token) { + return Jwts.parser().verifyWith(secretKey).build() + .parseClaimsJws(token) + .getBody() + .get("role", String.class); + } + + public Long getUserId(String token) { + return Jwts.parser().verifyWith(secretKey).build() + .parseClaimsJws(token) + .getBody() + .get("userId", Long.class); + } + + public Boolean isExpired(String token) { + return Jwts.parser().verifyWith(secretKey).build() + .parseSignedClaims(token) + .getPayload() + .getExpiration() + .before(new Date()); + } + +} diff --git a/security-front/src/main/java/com/nowait/auth/oauth2/CustomOAuth2UserService.java b/security-front/src/main/java/com/nowait/auth/oauth2/CustomOAuth2UserService.java new file mode 100644 index 00000000..b51577f7 --- /dev/null +++ b/security-front/src/main/java/com/nowait/auth/oauth2/CustomOAuth2UserService.java @@ -0,0 +1,70 @@ +package com.nowait.auth.oauth2; + +import java.util.Optional; + +import org.springframework.security.oauth2.client.userinfo.DefaultOAuth2UserService; +import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.user.OAuth2User; +import org.springframework.stereotype.Service; + +import com.nowait.auth.dto.CustomOAuth2User; +import com.nowait.auth.dto.KaKaoResponse; +import com.nowait.auth.dto.OAuth2Response; +import com.nowait.common.enums.Role; +import com.nowait.common.enums.SocialType; +import com.nowait.user.entity.User; +import com.nowait.user.repository.UserRepository; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +// OAuth2 제공자(카카오)로부터 제공받은 사용자 정보를, 우리 서비스에 맞게 가공, 변환 +@Service +@RequiredArgsConstructor +@Slf4j +public class CustomOAuth2UserService extends DefaultOAuth2UserService { + private final UserRepository userRepository; + + @Override + public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2AuthenticationException { + OAuth2User oAuth2User = super.loadUser(userRequest); + + log.info("CustomOAuth2UserService :: {}", oAuth2User); + log.info("oAuthUser.getAttributes :: {}", oAuth2User.getAttributes()); + + String registrationId = userRequest.getClientRegistration().getRegistrationId(); + OAuth2Response oAuth2Response = null; + + if (registrationId.equals("kakao")) { + oAuth2Response = new KaKaoResponse(oAuth2User.getAttributes()); + } else { + throw new OAuth2AuthenticationException("지원하지 않는 OAuth2 Provider 입니다."); + } + + // DB에 유저가 있는지 판단 + Optional foundUser = userRepository.findByEmail(oAuth2Response.getEmail()); + + // DB에 유저 없으면 - 회원가입 + if (foundUser.isEmpty()) { + + User user = User.builder() + .email(oAuth2Response.getEmail()) + .nickname(oAuth2Response.getNickName()) + .profileImage(oAuth2Response.getProfileImage()) + .socialType(SocialType.KAKAO) + .role(Role.USER) // 일반 유저 설정 + .build(); + + userRepository.save(user); + + return new CustomOAuth2User(user); + } else { + // DB에 유저 존재하면 - 로그인 진행 (이때 로그인 처리는 안하고, OAuth2LoginSuccessHandler에서 담당함) + User user = foundUser.get(); + + return new CustomOAuth2User(user); + } + } + +} diff --git a/security-front/src/main/java/com/nowait/auth/oauth2/OAuth2LoginSuccessHandler.java b/security-front/src/main/java/com/nowait/auth/oauth2/OAuth2LoginSuccessHandler.java new file mode 100644 index 00000000..9364e1cc --- /dev/null +++ b/security-front/src/main/java/com/nowait/auth/oauth2/OAuth2LoginSuccessHandler.java @@ -0,0 +1,80 @@ +package com.nowait.auth.oauth2; + +import java.io.IOException; +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.Iterator; +import java.util.Map; + +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler; +import org.springframework.stereotype.Component; + +import com.example.domaintoken.entity.Token; +import com.example.domaintoken.repository.TokenRepository; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.nowait.auth.dto.CustomOAuth2User; +import com.nowait.auth.jwt.JwtUtil; +import com.nowait.user.entity.User; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +// 카카오 로그인 성공 시, 콜백 핸들러 +// 1. JWT 토큰 발급 +// - 이때, JWT payload는 보안상 최소한의 정보(userId, role)만 담겠다 +// 2. refreshToken만 DB에 저장 +// 3. JSON 응답으로, accessToken과 refreshToken 을 반환해준다. +@Component +@RequiredArgsConstructor +@Slf4j +public class OAuth2LoginSuccessHandler extends SimpleUrlAuthenticationSuccessHandler { + private final JwtUtil jwtUtil; + private final TokenRepository tokenRepository; + + @Override + public void onAuthenticationSuccess(HttpServletRequest request, HttpServletResponse response, + Authentication authentication) throws IOException { + + // 1. CustomOAuth2UserService에서 설정한 OAuth2User 정보 가져오기 + CustomOAuth2User customUserDetails = (CustomOAuth2User)authentication.getPrincipal(); + + User user = customUserDetails.getUser(); + Long userId = customUserDetails.getUserId(); + String email = customUserDetails.getName(); + + Collection authorities = authentication.getAuthorities(); + Iterator iterator = authorities.iterator(); + GrantedAuthority auth = iterator.next(); + + String role = auth.getAuthority(); + + log.info("user, userId, email, role :: {} {} {} {}", user, userId, email, role); + + // 2. 1)의 사용자 정보를 담아, accessToken과 refreshToken 발행 + String accessToken = jwtUtil.createAccessToken("accessToken", userId, role, 60 * 60 * 1000L); // 유효기간 1시간 + String refreshToken = jwtUtil.createRefreshToken("refreshToken", userId, + 30 * 24 * 60 * 60 * 1000L); // 유효기간 30일 + + // 3. refreshToken을 DB에 저장 + Token refreshTokenEntity = Token.toEntity(user, refreshToken, LocalDateTime.now().plusDays(30)); + tokenRepository.save(refreshTokenEntity); + + // 4. JSON 응답으로, accessToken과 refreshToken 을 반환해준다. + response.setContentType("application/json"); + response.setCharacterEncoding("utf-8"); + + ObjectMapper objectMapper = new ObjectMapper(); // 객체 -> json 문자열로 변환 + String body = objectMapper.writeValueAsString( + Map.of( + "accessToken", accessToken, + "refreshToken", refreshToken + ) + ); + response.getWriter().write(body); + } + +} diff --git a/security-front/src/main/java/com/nowait/auth/service/CustomUserDetailService.java b/security-front/src/main/java/com/nowait/auth/service/CustomUserDetailService.java new file mode 100644 index 00000000..4476c9c4 --- /dev/null +++ b/security-front/src/main/java/com/nowait/auth/service/CustomUserDetailService.java @@ -0,0 +1,30 @@ +package com.nowait.auth.service; + +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.security.core.userdetails.UserDetailsService; +import org.springframework.security.core.userdetails.UsernameNotFoundException; +import org.springframework.stereotype.Component; + +import com.nowait.user.entity.MemberDetails; +import com.nowait.user.entity.User; +import com.nowait.user.repository.UserRepository; + +import lombok.RequiredArgsConstructor; + +@Component +@RequiredArgsConstructor +public class CustomUserDetailService implements UserDetailsService { + + private final UserRepository userRepository; + + @Override + public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException { + User user = userRepository.findByEmail(email).orElseThrow(); + return MemberDetails.create(user); + } + + public UserDetails loadUserById(Long id) throws UsernameNotFoundException { + User member = userRepository.findById(id).orElseThrow(); + return MemberDetails.create(member); + } +} diff --git a/security-front/src/main/java/com/nowait/exception/BusinessException.java b/security-front/src/main/java/com/nowait/exception/BusinessException.java new file mode 100644 index 00000000..60eec165 --- /dev/null +++ b/security-front/src/main/java/com/nowait/exception/BusinessException.java @@ -0,0 +1,14 @@ +package com.nowait.exception; + +public abstract class BusinessException extends RuntimeException { + private final ErrorMessage errorMessage; + + protected BusinessException(ErrorMessage errorMessage) { + super(errorMessage.getMessage()); + this.errorMessage = errorMessage; + } + + public String getCode() { + return errorMessage.getCode(); + } +} diff --git a/security-front/src/main/java/com/nowait/exception/ErrorMessage.java b/security-front/src/main/java/com/nowait/exception/ErrorMessage.java new file mode 100644 index 00000000..c365a92e --- /dev/null +++ b/security-front/src/main/java/com/nowait/exception/ErrorMessage.java @@ -0,0 +1,22 @@ +package com.nowait.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum ErrorMessage { + // global + INVALID_INPUT_VALUE("입력값이 올바르지 않습니다.", "global001"), + + // auth + UNAUTHORIZED("권한이 없습니다", "auth001"), + + // token + REFRESH_TOKEN_NOT_FOUND("기존 리프레시 토큰을 찾을 수 없습니다.", "token001"), + DOES_NOT_MATCH_REFRESH_TOKEN("기존 리프레시 토큰이 일치하지 않습니다.", "token002"); + + private final String message; + private final String code; + +} diff --git a/security-front/src/main/java/com/nowait/exception/ErrorResponse.java b/security-front/src/main/java/com/nowait/exception/ErrorResponse.java new file mode 100644 index 00000000..a15935e8 --- /dev/null +++ b/security-front/src/main/java/com/nowait/exception/ErrorResponse.java @@ -0,0 +1,26 @@ +package com.nowait.exception; + +import java.util.HashMap; +import java.util.Map; + +import lombok.Getter; + +@Getter +public class ErrorResponse { + private final String message; + private final String code; + private final Map errors; + + public ErrorResponse(String message, String code) { + this.message = message; + this.code = code; + errors = new HashMap<>(); + } + + public ErrorResponse(String message, String code, Map errors) { + this.message = message; + this.code = code; + this.errors = errors; + } + +} diff --git a/security-front/src/main/java/com/nowait/exception/GlobalExceptionHandler.java b/security-front/src/main/java/com/nowait/exception/GlobalExceptionHandler.java new file mode 100644 index 00000000..b9f7c1e2 --- /dev/null +++ b/security-front/src/main/java/com/nowait/exception/GlobalExceptionHandler.java @@ -0,0 +1,108 @@ +package com.nowait.exception; + +import static com.nowait.exception.ErrorMessage.*; +import static org.springframework.http.HttpStatus.*; +import static org.springframework.http.HttpStatus.UNAUTHORIZED; + +import java.util.Map; +import java.util.stream.Collectors; + +import org.springframework.http.converter.HttpMessageNotReadableException; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.validation.FieldError; +import org.springframework.validation.ObjectError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.MissingRequestValueException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.ResponseStatus; +import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.multipart.MultipartException; + +import io.swagger.v3.oas.annotations.Hidden; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Hidden +@RestControllerAdvice +public class GlobalExceptionHandler { + + // OAUTH 인증 실패 에러처리 메서드 + @ResponseStatus(value = BAD_REQUEST) + @ExceptionHandler(OAuth2AuthenticationException.class) + public ErrorResponse handlerOAuth2AuthenticationException(OAuth2AuthenticationException e) { + log.error("handleOAuth2AuthenticationException", e); + + return new ErrorResponse("OAuth 인증 실패 : " + e.getMessage(), INVALID_INPUT_VALUE.getCode()); + } + + @ResponseStatus(value = BAD_REQUEST) + @ExceptionHandler(BusinessException.class) + public ErrorResponse handleBusinessException(BusinessException e) { + log.error("handleBusinessException", e); + return new ErrorResponse(e.getMessage(), e.getCode()); + } + + @ResponseStatus(value = BAD_REQUEST) + @ExceptionHandler(MethodArgumentNotValidException.class) + public ErrorResponse handleMethodArgumentNotValidException(MethodArgumentNotValidException e) { + log.error("handleMethodArgumentNotValidException", e); + Map errors = getErrors(e); + return new ErrorResponse(INVALID_INPUT_VALUE.getMessage(), INVALID_INPUT_VALUE.getCode(), errors); + } + + @ResponseStatus(value = BAD_REQUEST) + @ExceptionHandler(HttpMessageNotReadableException.class) + public ErrorResponse handleHttpMessageNotReadableException(HttpMessageNotReadableException e) { + log.error("handleHttpMessageNotReadableException", e); + return new ErrorResponse(INVALID_INPUT_VALUE.getMessage(), INVALID_INPUT_VALUE.getCode()); + } + + @ResponseStatus(value = BAD_REQUEST) + @ExceptionHandler(IllegalArgumentException.class) + public ErrorResponse handleIllegalArgumentException(IllegalArgumentException e) { + log.error("handleIllegalArgumentException", e); + return new ErrorResponse(e.getMessage(), INVALID_INPUT_VALUE.getCode()); + } + + @ResponseStatus(value = BAD_REQUEST) + @ExceptionHandler(MissingRequestValueException.class) + public ErrorResponse handleMissingRequestValueException(MissingRequestValueException e) { + log.error("handleMissingRequestValueExceptionException", e); + return new ErrorResponse(INVALID_INPUT_VALUE.getMessage(), INVALID_INPUT_VALUE.getCode()); + } + + @ResponseStatus(value = UNAUTHORIZED) + @ExceptionHandler(UnauthorizedException.class) + public ErrorResponse handleUnauthorizedException(UnauthorizedException e) { + log.error("handleUnauthorizedExceptionException", e); + return new ErrorResponse(e.getMessage(), e.getCode()); + } + + @ResponseStatus(value = NOT_FOUND) + @ExceptionHandler(ResourceNotFoundException.class) + public ErrorResponse handleResourceNotFoundException(ResourceNotFoundException e) { + log.error("handleResourceNotFoundExceptionException", e); + return new ErrorResponse(e.getMessage(), e.getCode()); + } + + @ResponseStatus(value = BAD_REQUEST) + @ExceptionHandler(MultipartException.class) + public ErrorResponse handleMultipartException(MultipartException e) { + log.error("handleMultipartException", e); + return new ErrorResponse(e.getMessage(), INVALID_INPUT_VALUE.getCode()); + } + + + private static Map getErrors(MethodArgumentNotValidException e) { + return e.getBindingResult() + .getAllErrors() + .stream() + .filter(ObjectError.class::isInstance) + .collect(Collectors.toMap( + error -> error instanceof FieldError ? ((FieldError)error).getField() : error.getObjectName(), + ObjectError::getDefaultMessage, + (msg1, msg2) -> msg1 + ";" + msg2 + )); + } + +} diff --git a/security-front/src/main/java/com/nowait/exception/RefreshTokenNotFoundException.java b/security-front/src/main/java/com/nowait/exception/RefreshTokenNotFoundException.java new file mode 100644 index 00000000..5365ef52 --- /dev/null +++ b/security-front/src/main/java/com/nowait/exception/RefreshTokenNotFoundException.java @@ -0,0 +1,9 @@ +package com.nowait.exception; + +public class RefreshTokenNotFoundException extends ResourceNotFoundException { + + public RefreshTokenNotFoundException() { + super(ErrorMessage.REFRESH_TOKEN_NOT_FOUND); + } + +} diff --git a/security-front/src/main/java/com/nowait/exception/ResourceNotFoundException.java b/security-front/src/main/java/com/nowait/exception/ResourceNotFoundException.java new file mode 100644 index 00000000..ae4e4b7b --- /dev/null +++ b/security-front/src/main/java/com/nowait/exception/ResourceNotFoundException.java @@ -0,0 +1,14 @@ +package com.nowait.exception; + +public abstract class ResourceNotFoundException extends RuntimeException { + private final ErrorMessage errorMessage; + + protected ResourceNotFoundException(ErrorMessage errorMessage) { + super(errorMessage.getMessage()); + this.errorMessage = errorMessage; + } + + public String getCode() { + return errorMessage.getCode(); + } +} diff --git a/security-front/src/main/java/com/nowait/exception/TokenBadRequestException.java b/security-front/src/main/java/com/nowait/exception/TokenBadRequestException.java new file mode 100644 index 00000000..504852ee --- /dev/null +++ b/security-front/src/main/java/com/nowait/exception/TokenBadRequestException.java @@ -0,0 +1,8 @@ +package com.nowait.exception; + +public class TokenBadRequestException extends BusinessException { + public TokenBadRequestException() { + super(ErrorMessage.DOES_NOT_MATCH_REFRESH_TOKEN); + } + +} diff --git a/security-front/src/main/java/com/nowait/exception/UnauthorizedException.java b/security-front/src/main/java/com/nowait/exception/UnauthorizedException.java new file mode 100644 index 00000000..65a8fd0a --- /dev/null +++ b/security-front/src/main/java/com/nowait/exception/UnauthorizedException.java @@ -0,0 +1,19 @@ +package com.nowait.exception; + +public class UnauthorizedException extends RuntimeException { + private final ErrorMessage errorMessage; + + public UnauthorizedException() { + super(ErrorMessage.UNAUTHORIZED.getMessage()); + this.errorMessage = ErrorMessage.UNAUTHORIZED; + } + + public UnauthorizedException(ErrorMessage errorMessage) { + super(errorMessage.getMessage()); + this.errorMessage = errorMessage; + } + + public String getCode() { + return errorMessage.getCode(); + } +} From e8351c08db10ac9e4f6703e47679b79e6664014f Mon Sep 17 00:00:00 2001 From: jeonghyemin Date: Thu, 3 Jul 2025 18:07:01 +0900 Subject: [PATCH 2/2] =?UTF-8?q?fix(Bookmark,user):=EC=98=A4=ED=83=80=20?= =?UTF-8?q?=EB=B0=8F=20=EA=B5=AC=EC=B2=B4=EC=A0=81=20=EC=97=90=EB=9F=AC?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - jwt 토큰 에러 로그 구체화 - bookmark 참조비교에서 값비교로 변경 --- .../token/service/TokenService.java | 10 +++++----- .../bookmark/service/BookmarkService.java | 3 ++- .../com/nowait/auth/config/SecurityConfig.java | 2 +- .../com/nowait/auth/dto/CustomOAuth2User.java | 9 ++------- .../nowait/auth/jwt/JwtAuthorizationFilter.java | 15 ++++++++++----- 5 files changed, 20 insertions(+), 19 deletions(-) diff --git a/application-admin/src/main/java/com/nowait/applicationadmin/token/service/TokenService.java b/application-admin/src/main/java/com/nowait/applicationadmin/token/service/TokenService.java index 3ba54b63..746b86cb 100644 --- a/application-admin/src/main/java/com/nowait/applicationadmin/token/service/TokenService.java +++ b/application-admin/src/main/java/com/nowait/applicationadmin/token/service/TokenService.java @@ -23,29 +23,29 @@ public class TokenService { private final JwtUtil jwtUtil; @Transactional - public Boolean validateToken(String token, Long userId){ + public boolean validateToken(String token, Long userId){ // DB에서 해당 userId와 일치하는 리프레시토큰을 찾는다. Optional savedToken = tokenRepository.findByUserId(userId); // DB에서 userId에 대응되는 리프레시토큰 없으면, 유효하지 않음 if (savedToken.isEmpty()){ - log.info("여기에 걸렸니 ? -- 1 "); + log.debug("DB에 현재 userId에 대응되는 리프레시 토큰이 없습니다"); return false; } // 리프레시 토큰이 DB에 저장된 토큰과 일치하는지 확인 if (!savedToken.get().getRefreshToken().equals(token)){ - log.info("여기에 걸렸니 ? -- 2 "); + log.debug("DB에 저장된 리프레시 토큰와 현재 전달받은 리프레시 토큰 일치하지 않습니다"); return false; } // 리프레시 토큰의 만료여부 확인 if(jwtUtil.isExpired(token)){ - log.info("여기에 걸렸니 ? -- 3 "); + log.debug("리프레시 토큰이 만료된 토큰입니다"); return false; // 만료된 토큰은 유효하지 않음 } - log.info("여기에 걸렸니 ? -- 4 "); + log.info("리프레시 토큰이 유효한 토큰입니다"); return true; // 모든 조건 만족 시, 유효한 토큰 } diff --git a/application-user/src/main/java/com/nowait/applicationuser/bookmark/service/BookmarkService.java b/application-user/src/main/java/com/nowait/applicationuser/bookmark/service/BookmarkService.java index 5aae6553..31fdc89e 100644 --- a/application-user/src/main/java/com/nowait/applicationuser/bookmark/service/BookmarkService.java +++ b/application-user/src/main/java/com/nowait/applicationuser/bookmark/service/BookmarkService.java @@ -1,6 +1,7 @@ package com.nowait.applicationuser.bookmark.service; import java.util.List; +import java.util.Objects; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -59,7 +60,7 @@ public String deleteBookmark(Long bookmarkId, CustomOAuth2User customOAuth2User) parameterValidation(bookmarkId, customOAuth2User); Bookmark bookmark = bookmarkRepository.findById(bookmarkId) .orElseThrow(() -> new EntityNotFoundException(bookmarkId + " bookmark not found.")); - if (bookmark.getUser().getId() != customOAuth2User.getUserId()) { + if (!Objects.equals(bookmark.getUser().getId(), customOAuth2User.getUserId())) { throw new IllegalArgumentException("you can only delete your own bookmark"); } bookmarkRepository.delete(bookmark); diff --git a/security-front/src/main/java/com/nowait/auth/config/SecurityConfig.java b/security-front/src/main/java/com/nowait/auth/config/SecurityConfig.java index aa535f8c..c4ebb970 100644 --- a/security-front/src/main/java/com/nowait/auth/config/SecurityConfig.java +++ b/security-front/src/main/java/com/nowait/auth/config/SecurityConfig.java @@ -57,7 +57,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { "/oauth2/authorization/kakao", // 카카오 로그인 요청 "/login/oauth2/code/**", // 카카오 인증 콜백 "/api/refresh-token", // refresh token (토큰 갱신) - "orders/**", // 주문 관련 API + "/orders/**", // 주문 관련 API "/v1/menus/**", "/v1/stores/**", "/api/users/signup", diff --git a/security-front/src/main/java/com/nowait/auth/dto/CustomOAuth2User.java b/security-front/src/main/java/com/nowait/auth/dto/CustomOAuth2User.java index af969966..61432393 100644 --- a/security-front/src/main/java/com/nowait/auth/dto/CustomOAuth2User.java +++ b/security-front/src/main/java/com/nowait/auth/dto/CustomOAuth2User.java @@ -13,16 +13,11 @@ @RequiredArgsConstructor public class CustomOAuth2User implements OAuth2User { - private User user; - - // User 객체를 받는 생성자 - public CustomOAuth2User(User user) { - this.user = user; - } + private final User user; @Override public Map getAttributes() { - return null; + return Map.of("email", user.getEmail(), "nickname", user.getNickname()); } // 사용자가 가지는 권한 설정 diff --git a/security-front/src/main/java/com/nowait/auth/jwt/JwtAuthorizationFilter.java b/security-front/src/main/java/com/nowait/auth/jwt/JwtAuthorizationFilter.java index 97a341d3..dcbbd985 100644 --- a/security-front/src/main/java/com/nowait/auth/jwt/JwtAuthorizationFilter.java +++ b/security-front/src/main/java/com/nowait/auth/jwt/JwtAuthorizationFilter.java @@ -36,7 +36,7 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse if (header == null || !header.startsWith("Bearer ")) { filterChain.doFilter(request, response); - log.info("JwtAuthorizationFilter 1 "); + log.debug("JwtAuthorizationFilter: Authorization 헤더가 없거나 Bearer 토큰 형식이 아님. JWT 인증 필터를 건너뜁니다. [header={}] ", header); return; } @@ -52,7 +52,7 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.getWriter().print("access token expired"); - log.info("JwtAuthorizationFilter 2 "); + log.warn("JwtAuthorizationFilter: 만료된 AccessToken입니다. 토큰 인증 거부, URI: {}", request.getRequestURI()); return; } @@ -64,14 +64,19 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.getWriter().print("invalid access token"); - log.info("JwtAuthorizationFilter 3 "); + log.warn("JwtAuthorizationFilter: 잘못된 토큰 유형(accessToken 아님)으로 인증 요청. URI: {}, tokenCategory: {}", request.getRequestURI(), tokenCategory); return; } // userId와 role 값 추출 Long userId = jwtUtil.getUserId(accessToken); String roleString = jwtUtil.getRole(accessToken); - + if (userId == null || roleString == null) { + log.warn("JwtAuthorizationFilter: JWT에서 userId 또는 role 추출 실패. 토큰: {}", accessToken); + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + response.getWriter().print("invalid token"); + return; + } User user = User.createUserWithId(userId, "sampleEmail", "sampleNickname", "sampleProfileImg" , SocialType.KAKAO, Role.fromString(roleString)); @@ -84,7 +89,7 @@ protected void doFilterInternal(HttpServletRequest request, HttpServletResponse // 생성한 인증 정보를 SecurityContext에 설정 SecurityContextHolder.getContext().setAuthentication(usernamePasswordAuthenticationToken); - log.info("JwtAuthorizationFilter 4 "); + log.info("JwtAuthorizationFilter: 인증 성공. userId={}, role={}, URI={}", userId, roleString, request.getRequestURI()); filterChain.doFilter(request, response);