diff --git a/README.md b/README.md index 42b1f59e0..1ee7158b8 100644 --- a/README.md +++ b/README.md @@ -10,4 +10,44 @@ * 토큰 받기를 읽고 액세스 토큰을 추출한다. * 앱 키, 인가 코드가 절대 유출되지 않도록 한다. * 특히 시크릿 키는 GitHub나 클라이언트 코드 등 외부에서 볼 수 있는 곳에 추가하지 않는다. -* (선택) 인가 코드를 받는 방법이 불편한 경우 카카오 로그인 화면을 구현한다. \ No newline at end of file +* (선택) 인가 코드를 받는 방법이 불편한 경우 카카오 로그인 화면을 구현한다. + +### 🚀 2단계 - 주문하기 +*** +#### 기능 요구 사항 +카카오톡 메시지 API를 사용하여 주문하기 기능을 구현한다. + +* 주문할 때 수령인에게 보낼 메시지를 작성할 수 있다. +* 상품 옵션과 해당 수량을 선택하여 주문하면 해당 상품 옵션의 수량이 차감된다. +* 해당 상품이 위시 리스트에 있는 경우 위시 리스트에서 삭제한다. +* 나에게 보내기를 읽고 주문 내역을 카카오톡 메시지로 전송한다. + * 메시지는 메시지 템플릿의 기본 템플릿이나 사용자 정의 템플릿을 사용하여 자유롭게 작성한다. + +아래 예시와 같이 HTTP 메시지를 주고받도록 구현한다. + +##### Request +``` +POST /api/orders HTTP/1.1 +Authorization: Bearer {token} +Content-Type: application/json + +{ + "optionId": 1, + "quantity": 2, + "message": "Please handle this order with care." +} +``` + +##### Response +``` +HTTP/1.1 201 Created +Content-Type: application/json + +{ +"id": 1, +"optionId": 1, +"quantity": 2, +"orderDateTime": "2024-07-21T10:00:00", +"message": "Please handle this order with care." +} +``` diff --git a/src/main/java/gift/authentication/token/Token.java b/src/main/java/gift/authentication/token/Token.java index 8ff73f026..ba75ba991 100644 --- a/src/main/java/gift/authentication/token/Token.java +++ b/src/main/java/gift/authentication/token/Token.java @@ -15,6 +15,10 @@ public static Token from(String value) { return new Token(value); } + public static Token fromBearer(String value) { + return new Token(value.replace("Bearer ", "")); + } + public String getValue() { return value; } diff --git a/src/main/java/gift/config/KakaoProperties.java b/src/main/java/gift/config/KakaoProperties.java index baa7b457c..04355b18b 100644 --- a/src/main/java/gift/config/KakaoProperties.java +++ b/src/main/java/gift/config/KakaoProperties.java @@ -1,5 +1,6 @@ package gift.config; +import java.net.URI; import org.springframework.boot.context.properties.ConfigurationProperties; import org.springframework.boot.context.properties.bind.ConstructorBinding; @@ -13,10 +14,12 @@ public class KakaoProperties { private final String userInfoUrl; private final String tokenUrl; private final String responseType; + private final String messageUrl; @ConstructorBinding public KakaoProperties(String clientId, String redirectUri, String contentType, - String grantType, String userInfoUrl, String tokenUrl, String responseType) { + String grantType, String userInfoUrl, String tokenUrl, String responseType, + String messageUrl) { this.clientId = clientId; this.redirectUri = redirectUri; this.contentType = contentType; @@ -24,6 +27,7 @@ public KakaoProperties(String clientId, String redirectUri, String contentType, this.userInfoUrl = userInfoUrl; this.tokenUrl = tokenUrl; this.responseType = responseType; + this.messageUrl = messageUrl; } public String getClientId() { @@ -53,4 +57,20 @@ public String getTokenUrl() { public String getResponseType() { return responseType; } + + public String getMessageUrl() { + return messageUrl; + } + + public URI getUserInfoUrlAsUri() { + return URI.create(userInfoUrl); + } + + public URI getTokenUrlAsUri() { + return URI.create(tokenUrl); + } + + public URI getMessageUrlAsUri() { + return URI.create(messageUrl); + } } diff --git a/src/main/java/gift/domain/Order.java b/src/main/java/gift/domain/Order.java new file mode 100644 index 000000000..6595048f2 --- /dev/null +++ b/src/main/java/gift/domain/Order.java @@ -0,0 +1,132 @@ +package gift.domain; + +import gift.domain.base.BaseEntity; +import gift.domain.base.BaseTimeEntity; +import gift.domain.constants.OrderStatus; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.Table; +import org.hibernate.annotations.ColumnDefault; +import org.hibernate.annotations.DynamicInsert; + +@Entity +@DynamicInsert +@Table(name = "orders") +public class Order extends BaseEntity { + + @Column(nullable = false) + private Long memberId; + + @Column(nullable = false) + private Long productId; + + @Column(nullable = false) + private Long productOptionId; + + @Column(nullable = false) + private Integer quantity; + + @Column(nullable = false) + @ColumnDefault("''") + private String message; + + @Column(nullable = false) + @Enumerated(EnumType.STRING) + @ColumnDefault("'ORDERED'") + private OrderStatus orderStatus; + + protected Order() {} + + public static class Builder extends BaseTimeEntity.Builder { + + private Long memberId; + + private Long productId; + + private Long productOptionId; + + private Integer quantity; + + private String message; + + public Builder memberId(Long memberId) { + this.memberId = memberId; + return this; + } + + public Builder productId(Long productId) { + this.productId = productId; + return this; + } + + public Builder productOptionId(Long productOptionId) { + this.productOptionId = productOptionId; + return this; + } + + public Builder quantity(Integer quantity) { + this.quantity = quantity; + return this; + } + + public Builder message(String message) { + this.message = message; + return this; + } + + @Override + protected Builder self() { + return this; + } + + @Override + public Order build() { + return new Order(this); + } + } + + public Order(Builder builder) { + super(builder); + this.memberId = builder.memberId; + this.productId = builder.productId; + this.productOptionId = builder.productOptionId; + this.quantity = builder.quantity; + this.message = builder.message; + } + + public Long getMemberId() { + return memberId; + } + + public Long getProductId() { + return productId; + } + + public Long getProductOptionId() { + return productOptionId; + } + + public Integer getQuantity() { + return quantity; + } + + public String getMessage() { + return message; + } + + public OrderStatus getOrderStatus() { + return orderStatus; + } + + public Order complete() { + this.orderStatus = OrderStatus.COMPLETED; + return this; + } + + public Order cancel() { + this.orderStatus = OrderStatus.CANCELED; + return this; + } +} diff --git a/src/main/java/gift/domain/constants/OrderStatus.java b/src/main/java/gift/domain/constants/OrderStatus.java new file mode 100644 index 000000000..069537257 --- /dev/null +++ b/src/main/java/gift/domain/constants/OrderStatus.java @@ -0,0 +1,9 @@ +package gift.domain.constants; + +public enum OrderStatus { + + ORDERED, //주문됨 + CANCELED, //취소됨 + COMPLETED //배송완료 + +} diff --git a/src/main/java/gift/repository/OrderRepository.java b/src/main/java/gift/repository/OrderRepository.java new file mode 100644 index 000000000..2cd02aa2b --- /dev/null +++ b/src/main/java/gift/repository/OrderRepository.java @@ -0,0 +1,10 @@ +package gift.repository; + +import gift.domain.Order; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface OrderRepository extends JpaRepository { + +} diff --git a/src/main/java/gift/service/LoginService.java b/src/main/java/gift/service/LoginService.java index 03ec44394..dc78e70f1 100644 --- a/src/main/java/gift/service/LoginService.java +++ b/src/main/java/gift/service/LoginService.java @@ -9,9 +9,6 @@ import gift.web.client.dto.KakaoAccount; import gift.web.client.dto.KakaoInfo; import gift.web.dto.response.LoginResponse; -import gift.web.validation.exception.client.InvalidCredentialsException; -import java.net.URI; -import java.net.URISyntaxException; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -47,30 +44,22 @@ public LoginResponse kakaoLogin(final String authorizationCode){ return new LoginResponse(accessToken.getValue()); } - private KakaoToken getToken(String authorizationCode) { - try { - return kakaoClient.getToken( - new URI(kakaoProperties.getTokenUrl()), - authorizationCode, - kakaoProperties.getClientId(), - kakaoProperties.getRedirectUri(), - kakaoProperties.getGrantType()); - } catch (URISyntaxException e) { - throw new InvalidCredentialsException(e); - } + private KakaoToken getToken(final String authorizationCode) { + return kakaoClient.getToken( + kakaoProperties.getTokenUrlAsUri(), + authorizationCode, + kakaoProperties.getClientId(), + kakaoProperties.getRedirectUri(), + kakaoProperties.getGrantType()); } - private KakaoInfo getInfo(KakaoToken kakaoToken) { - try { - return kakaoClient.getKakaoInfo( - new URI(kakaoProperties.getUserInfoUrl()), - getBearerToken(kakaoToken)); - } catch (URISyntaxException e) { - throw new InvalidCredentialsException(e); - } + private KakaoInfo getInfo(final KakaoToken kakaoToken) { + return kakaoClient.getKakaoInfo( + kakaoProperties.getUserInfoUrlAsUri(), + getBearerToken(kakaoToken)); } - private String getBearerToken(KakaoToken kakaoToken) { + private String getBearerToken(final KakaoToken kakaoToken) { return kakaoToken.getTokenType() + " " + kakaoToken.getAccessToken(); } } diff --git a/src/main/java/gift/service/OrderService.java b/src/main/java/gift/service/OrderService.java new file mode 100644 index 000000000..3534ae8b6 --- /dev/null +++ b/src/main/java/gift/service/OrderService.java @@ -0,0 +1,84 @@ +package gift.service; + +import gift.authentication.token.JwtResolver; +import gift.authentication.token.Token; +import gift.config.KakaoProperties; +import gift.domain.Order; +import gift.repository.OrderRepository; +import gift.web.client.KakaoClient; +import gift.web.client.dto.KakaoCommerce; +import gift.web.dto.request.order.CreateOrderRequest; +import gift.web.dto.response.order.OrderResponse; +import gift.web.dto.response.product.ReadProductResponse; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +public class OrderService { + + private final KakaoClient kakaoClient; + private final KakaoProperties kakaoProperties; + + private final JwtResolver jwtResolver; + + private final ProductOptionService productOptionService; + private final ProductService productService; + private final OrderRepository orderRepository; + + public OrderService(KakaoClient kakaoClient, JwtResolver jwtResolver, ProductOptionService productOptionService, + ProductService productService, KakaoProperties kakaoProperties, + OrderRepository orderRepository) { + this.kakaoClient = kakaoClient; + this.jwtResolver = jwtResolver; + this.productOptionService = productOptionService; + this.productService = productService; + this.kakaoProperties = kakaoProperties; + this.orderRepository = orderRepository; + } + + /** + * 주문을 생성합니다
+ * 카카오 로그인을 통해 서비스를 이용 중인 회원은 나에게 보내기를 통해 알림을 전송합니다. + * @param accessToken 우리 서비스의 토큰 + * @param productId 구매할 상품 ID + * @param memberId 구매자 ID + * @param request 주문 요청 + * @return + */ + @Transactional + public OrderResponse createOrder(String accessToken, Long productId, Long memberId, CreateOrderRequest request) { + //상품 옵션 수량 차감 + productOptionService.subtractOptionStock(request); + + //주문 정보 저장 + Order order = orderRepository.save(request.toEntity(memberId, productId)); + + sendOrderMessageIfSocialMember(accessToken, productId, request); + return OrderResponse.from(order); + } + + /** + * 소셜 로그인을 통해 주문한 경우 카카오톡 메시지를 전송합니다 + * @param accessToken 우리 서비스의 토큰 + * @param productId 상품 ID + * @param request 주문 요청 + */ + private void sendOrderMessageIfSocialMember(String accessToken, Long productId, CreateOrderRequest request) { + jwtResolver.resolveSocialToken(Token.fromBearer(accessToken)) + .ifPresent(socialToken -> + kakaoClient.sendMessage( + kakaoProperties.getMessageUrlAsUri(), + getBearerToken(socialToken), + generateKakaoCommerce(productId, request).toJson() + )); + } + + private KakaoCommerce generateKakaoCommerce(Long productId, CreateOrderRequest request) { + ReadProductResponse productResponse = productService.readProductById(productId); + return KakaoCommerce.of(productResponse, request.getMessage()); + } + + private String getBearerToken(String token) { + return "Bearer " + token; + } +} diff --git a/src/main/java/gift/service/ProductOptionService.java b/src/main/java/gift/service/ProductOptionService.java index 9663b89a5..dc5f7650c 100644 --- a/src/main/java/gift/service/ProductOptionService.java +++ b/src/main/java/gift/service/ProductOptionService.java @@ -2,6 +2,8 @@ import gift.domain.ProductOption; import gift.repository.ProductOptionRepository; +import gift.repository.ProductRepository; +import gift.web.dto.request.order.CreateOrderRequest; import gift.web.dto.request.productoption.CreateProductOptionRequest; import gift.web.dto.request.productoption.SubtractProductOptionQuantityRequest; import gift.web.dto.request.productoption.UpdateProductOptionRequest; @@ -21,13 +23,17 @@ public class ProductOptionService { private final ProductOptionRepository productOptionRepository; + private final ProductRepository productRepository; - public ProductOptionService(ProductOptionRepository productOptionRepository) { + public ProductOptionService(ProductOptionRepository productOptionRepository, + ProductRepository productRepository) { this.productOptionRepository = productOptionRepository; + this.productRepository = productRepository; } @Transactional public CreateProductOptionResponse createOption(Long productId, CreateProductOptionRequest request) { + validateExistsProduct(productId); String optionName = request.getName(); validateOptionNameExists(productId, optionName); @@ -36,6 +42,15 @@ public CreateProductOptionResponse createOption(Long productId, CreateProductOpt return CreateProductOptionResponse.fromEntity(createdOption); } + /** + * 상품이 존재하는지 확인합니다. + * @param productId 상품 아이디 + */ + private void validateExistsProduct(Long productId) { + productRepository.findById(productId) + .orElseThrow(() -> new ResourceNotFoundException("상품 아이디: ", productId.toString())); + } + /** * 상품 옵션 이름이 이미 존재하는지 확인합니다.
* 이미 존재한다면 {@link AlreadyExistsException} 을 발생시킵니다. @@ -131,6 +146,10 @@ public SubtractProductOptionQuantityResponse subtractOptionStock(Long optionId, return SubtractProductOptionQuantityResponse.fromEntity(option); } + public SubtractProductOptionQuantityResponse subtractOptionStock(CreateOrderRequest request) { + return subtractOptionStock(request.getOptionId(), new SubtractProductOptionQuantityRequest(request.getQuantity())); + } + @Transactional public void deleteOption(Long optionId) { ProductOption option = productOptionRepository.findById(optionId) diff --git a/src/main/java/gift/web/client/KakaoClient.java b/src/main/java/gift/web/client/KakaoClient.java index 401e1c28e..af3986a9d 100644 --- a/src/main/java/gift/web/client/KakaoClient.java +++ b/src/main/java/gift/web/client/KakaoClient.java @@ -1,10 +1,15 @@ package gift.web.client; +import static org.springframework.http.HttpHeaders.AUTHORIZATION; + import gift.authentication.token.KakaoToken; import gift.web.client.dto.KakaoInfo; +import gift.web.client.dto.KakaoMessageResult; import java.net.URI; import org.springframework.cloud.openfeign.FeignClient; +import org.springframework.http.MediaType; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestParam; @@ -14,7 +19,7 @@ public interface KakaoClient { @PostMapping KakaoInfo getKakaoInfo( URI uri, - @RequestHeader("Authorization") String accessToken); + @RequestHeader(AUTHORIZATION) String accessToken); @PostMapping KakaoToken getToken( @@ -23,4 +28,17 @@ KakaoToken getToken( @RequestParam("client_id") String clientId, @RequestParam("redirect_uri") String redirectUrl, @RequestParam("grant_type") String grantType); + + /** + * 카카오톡 메시지 - 나에게 보내기 + * @param uri {@link https://kapi.kakao.com/v2/api/talk/memo/default/send} 으로 고정 + * @param accessToken Bearer Token + * @param templateObject 메시지 구성 요소를 담은 객체(Object) 피드, 리스트, 위치, 커머스, 텍스트, 캘린더 중 하나 + */ + @PostMapping(consumes = MediaType.APPLICATION_FORM_URLENCODED_VALUE) + KakaoMessageResult sendMessage( + URI uri, + @RequestHeader(AUTHORIZATION) String accessToken, + @RequestBody String templateObject + ); } diff --git a/src/main/java/gift/web/client/dto/KakaoCommerce.java b/src/main/java/gift/web/client/dto/KakaoCommerce.java new file mode 100644 index 000000000..aec159aab --- /dev/null +++ b/src/main/java/gift/web/client/dto/KakaoCommerce.java @@ -0,0 +1,207 @@ +package gift.web.client.dto; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.PropertyNamingStrategies; +import com.fasterxml.jackson.databind.annotation.JsonNaming; +import gift.web.dto.response.product.ReadProductResponse; + +@JsonNaming(value = PropertyNamingStrategies.SnakeCaseStrategy.class) +public class KakaoCommerce { + + private final String objectType = "commerce"; + private Content content; + private Commerce commerce; + + @JsonNaming(value = PropertyNamingStrategies.SnakeCaseStrategy.class) + static class Content { + private String title; + private String imageUrl; + private Integer imageWidth; + private Integer imageHeight; + private String description; + private Link link; + + public Content(String title, String imageUrl, Integer imageWidth, Integer imageHeight, + String description, Link link) { + this.title = title; + this.imageUrl = imageUrl; + this.imageWidth = imageWidth; + this.imageHeight = imageHeight; + this.description = description; + this.link = link; + } + + @JsonNaming(value = PropertyNamingStrategies.SnakeCaseStrategy.class) + static class Link { + private String webUrl; + private String mobileWebUrl; + private String androidExecutionParams; + private String iosExecutionParams; + + public Link(String webUrl, String mobileWebUrl, String androidExecutionParams, + String iosExecutionParams) { + this.webUrl = webUrl; + this.mobileWebUrl = mobileWebUrl; + this.androidExecutionParams = androidExecutionParams; + this.iosExecutionParams = iosExecutionParams; + } + + public String getWebUrl() { + return webUrl; + } + + public String getMobileWebUrl() { + return mobileWebUrl; + } + + public String getAndroidExecutionParams() { + return androidExecutionParams; + } + + public String getIosExecutionParams() { + return iosExecutionParams; + } + } + + public String getTitle() { + return title; + } + + public String getImageUrl() { + return imageUrl; + } + + public Integer getImageWidth() { + return imageWidth; + } + + public Integer getImageHeight() { + return imageHeight; + } + + public String getDescription() { + return description; + } + + public Link getLink() { + return link; + } + } + + @JsonNaming(value = PropertyNamingStrategies.SnakeCaseStrategy.class) + static class Commerce { + private Integer regularPrice; + private Integer discountPrice; + private Integer discountRate; + + public Commerce(Integer regularPrice, Integer discountPrice, Integer discountRate) { + this.regularPrice = regularPrice; + this.discountPrice = discountPrice; + this.discountRate = discountRate; + } + + public Commerce(Integer regularPrice) { + this.regularPrice = regularPrice; + } + + public Integer getRegularPrice() { + return regularPrice; + } + + public Integer getDiscountPrice() { + return discountPrice; + } + + public Integer getDiscountRate() { + return discountRate; + } + } + + public KakaoCommerce() { + + } + + public KakaoCommerce setCommerce(Integer regularPrice, Integer discountPrice, Integer discountRate) { + this.commerce = new Commerce(regularPrice, discountPrice, discountRate); + return this; + } + + public KakaoCommerce setCommerce(Integer regularPrice) { + this.commerce = new Commerce(regularPrice); + return this; + } + + public KakaoCommerce setContent(String title, String imageUrl, Integer imageWidth, Integer imageHeight, String description, String webUrl, String mobileWebUrl, String androidExecutionParams, String iosExecutionParams) { + this.content = new Content(title, imageUrl, imageWidth, imageHeight, description, + new Content.Link(webUrl, mobileWebUrl, androidExecutionParams, iosExecutionParams)); + return this; + } + + public String getObjectType() { + return objectType; + } + + public Content getContent() { + return content; + } + + public Commerce getCommerce() { + return commerce; + } + + /** + * 카카오 상거래 메시지 생성 - 할인 적용 + * @param product 상품 정보 + * @param discountRate 할인율 (0 ~ 100) + * @param message 메시지 + * @return + */ + public static KakaoCommerce of(ReadProductResponse product, int discountRate, String message) { + return new KakaoCommerce().setContent( + product.getName(), + product.getImageUrl().toString(), + 640, + 640, + message, + "https://localhost:8080", + "https://localhost:8080", + "contentId=" + product.getId(), + "contentId=" + product.getId() + ).setCommerce(product.getPrice(), product.getPrice(), 0); + } + + /** + * 카카오 상거래 메시지 생성 - 할인 없음 + * @param product 상품 정보 + * @param message 메시지 + * @return + */ + public static KakaoCommerce of(ReadProductResponse product, String message) { + return new KakaoCommerce().setContent( + product.getName(), + product.getImageUrl().toString(), + 640, + 640, + message, + "https://localhost:8080", + "https://localhost:8080", + "contentId=" + product.getId(), + "contentId=" + product.getId() + ).setCommerce(product.getPrice()); + } + + public String toJson() { + ObjectMapper mapper = new ObjectMapper(); + try { + return "template_object=" + mapper.writeValueAsString(this); + } catch (JsonProcessingException e) { + throw new RuntimeException("JSON 변환 실패", e); + } + } + + public String getContentDescription() { + return content.getDescription(); + } +} + diff --git a/src/main/java/gift/web/client/dto/KakaoMessageResult.java b/src/main/java/gift/web/client/dto/KakaoMessageResult.java new file mode 100644 index 000000000..5517133b0 --- /dev/null +++ b/src/main/java/gift/web/client/dto/KakaoMessageResult.java @@ -0,0 +1,17 @@ +package gift.web.client.dto; + +import com.fasterxml.jackson.annotation.JsonCreator; + +public class KakaoMessageResult { + + private final Integer resultCode; + + @JsonCreator + public KakaoMessageResult(Integer resultCode) { + this.resultCode = resultCode; + } + + public Integer getResultCode() { + return resultCode; + } +} diff --git a/src/main/java/gift/web/controller/api/ProductApiController.java b/src/main/java/gift/web/controller/api/ProductApiController.java index 2e2ec7181..5b176fbd5 100644 --- a/src/main/java/gift/web/controller/api/ProductApiController.java +++ b/src/main/java/gift/web/controller/api/ProductApiController.java @@ -1,15 +1,18 @@ package gift.web.controller.api; import gift.authentication.annotation.LoginMember; +import gift.service.OrderService; import gift.service.ProductOptionService; import gift.service.ProductService; import gift.service.WishProductService; import gift.web.dto.MemberDetails; +import gift.web.dto.request.order.CreateOrderRequest; import gift.web.dto.request.product.CreateProductRequest; import gift.web.dto.request.product.UpdateProductRequest; import gift.web.dto.request.productoption.CreateProductOptionRequest; import gift.web.dto.request.productoption.UpdateProductOptionRequest; import gift.web.dto.request.wishproduct.CreateWishProductRequest; +import gift.web.dto.response.order.OrderResponse; import gift.web.dto.response.product.CreateProductResponse; import gift.web.dto.response.product.ReadAllProductsResponse; import gift.web.dto.response.product.ReadProductResponse; @@ -23,6 +26,7 @@ import java.util.NoSuchElementException; import org.springframework.data.domain.Pageable; import org.springframework.data.web.PageableDefault; +import org.springframework.http.HttpHeaders; import org.springframework.http.ResponseEntity; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.DeleteMapping; @@ -31,6 +35,7 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @@ -42,11 +47,14 @@ public class ProductApiController { private final ProductService productService; private final WishProductService wishProductService; private final ProductOptionService productOptionService; + private final OrderService orderService; - public ProductApiController(ProductService productService, WishProductService wishProductService, ProductOptionService productOptionService) { + public ProductApiController(ProductService productService, WishProductService wishProductService, ProductOptionService productOptionService, + OrderService orderService) { this.productService = productService; this.wishProductService = wishProductService; this.productOptionService = productOptionService; + this.orderService = orderService; } @GetMapping @@ -114,6 +122,17 @@ public ResponseEntity createOption(@PathVariable Lo return ResponseEntity.created(location).body(response); } + @PostMapping("/{productId}/order") + public ResponseEntity orderProduct( + @RequestHeader(HttpHeaders.AUTHORIZATION) String accessToken, + @PathVariable Long productId, + @RequestBody @Validated CreateOrderRequest request, + @LoginMember MemberDetails memberDetails + ) { + OrderResponse response = orderService.createOrder(accessToken, productId, memberDetails.getId(), request); + return ResponseEntity.ok(response); + } + @PutMapping("/{productId}/options/{optionId}") public ResponseEntity updateOption(@PathVariable Long productId, @PathVariable Long optionId, @Validated @RequestBody UpdateProductOptionRequest request) { UpdateProductOptionResponse response = productOptionService.updateOption(optionId, productId, request); diff --git a/src/main/java/gift/web/controller/view/ProductViewController.java b/src/main/java/gift/web/controller/view/ProductViewController.java index d13e7df69..44aa5e0ae 100644 --- a/src/main/java/gift/web/controller/view/ProductViewController.java +++ b/src/main/java/gift/web/controller/view/ProductViewController.java @@ -1,6 +1,7 @@ package gift.web.controller.view; import gift.authentication.annotation.LoginMember; +import gift.config.KakaoProperties; import gift.service.ProductService; import gift.web.dto.MemberDetails; import gift.web.dto.form.CreateProductForm; @@ -19,8 +20,11 @@ public class ProductViewController { private final ProductService productService; - public ProductViewController(ProductService productService) { + private final KakaoProperties kakaoProperties; + + public ProductViewController(ProductService productService, KakaoProperties kakaoProperties) { this.productService = productService; + this.kakaoProperties = kakaoProperties; } @GetMapping("/products") @@ -45,7 +49,9 @@ public String editForm(@PathVariable Long id, Model model) { } @GetMapping("/login") - public String loginForm() { + public String loginForm(Model model) { + model.addAttribute("clientId", kakaoProperties.getClientId()); + model.addAttribute("redirectUri", kakaoProperties.getRedirectUri()); return "form/login-form"; } diff --git a/src/main/java/gift/web/dto/request/order/CreateOrderRequest.java b/src/main/java/gift/web/dto/request/order/CreateOrderRequest.java new file mode 100644 index 000000000..13e28791f --- /dev/null +++ b/src/main/java/gift/web/dto/request/order/CreateOrderRequest.java @@ -0,0 +1,46 @@ +package gift.web.dto.request.order; + +import gift.domain.Order; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotEmpty; +import jakarta.validation.constraints.NotNull; + +public class CreateOrderRequest { + + @NotNull + private final Long optionId; + + @Min(1) + private final Integer quantity; + + @NotEmpty + private final String message; + + public CreateOrderRequest(Long optionId, Integer quantity, String message) { + this.optionId = optionId; + this.quantity = quantity; + this.message = message; + } + + public Order toEntity(Long memberId, Long productId) { + return new Order.Builder() + .memberId(memberId) + .productId(productId) + .productOptionId(optionId) + .quantity(quantity) + .message(message) + .build(); + } + + public Long getOptionId() { + return optionId; + } + + public Integer getQuantity() { + return quantity; + } + + public String getMessage() { + return message; + } +} diff --git a/src/main/java/gift/web/dto/response/order/OrderResponse.java b/src/main/java/gift/web/dto/response/order/OrderResponse.java new file mode 100644 index 000000000..6601555e4 --- /dev/null +++ b/src/main/java/gift/web/dto/response/order/OrderResponse.java @@ -0,0 +1,41 @@ +package gift.web.dto.response.order; + +import gift.domain.Order; + +public class OrderResponse { + + private Long productId; + + private Long optionId; + + private Integer quantity; + + private String message; + + public OrderResponse(Long productId, Long optionId, Integer quantity, String message) { + this.productId = productId; + this.optionId = optionId; + this.quantity = quantity; + this.message = message; + } + + public static OrderResponse from(Order order) { + return new OrderResponse(order.getProductId(), order.getProductOptionId(), order.getQuantity(), order.getMessage()); + } + + public Long getProductId() { + return productId; + } + + public Long getOptionId() { + return optionId; + } + + public Integer getQuantity() { + return quantity; + } + + public String getMessage() { + return message; + } +} \ No newline at end of file diff --git a/src/main/java/gift/web/dto/response/product/ReadProductResponse.java b/src/main/java/gift/web/dto/response/product/ReadProductResponse.java index 48ba51892..a9fc4e3c9 100644 --- a/src/main/java/gift/web/dto/response/product/ReadProductResponse.java +++ b/src/main/java/gift/web/dto/response/product/ReadProductResponse.java @@ -2,6 +2,8 @@ import gift.domain.Product; import gift.web.dto.response.category.ReadCategoryResponse; +import gift.web.dto.response.productoption.ReadProductOptionResponse; +import java.util.List; public class ReadProductResponse { @@ -9,19 +11,28 @@ public class ReadProductResponse { private final String name; private final Integer price; private final String imageUrl; + private final List productOptions; private final ReadCategoryResponse category; - private ReadProductResponse(Long id, String name, Integer price, String imageUrl, - ReadCategoryResponse category) { + public ReadProductResponse(Long id, String name, Integer price, String imageUrl, + List productOptions, ReadCategoryResponse category) { this.id = id; this.name = name; this.price = price; this.imageUrl = imageUrl; + this.productOptions = productOptions; this.category = category; } public static ReadProductResponse fromEntity(Product product) { - return new ReadProductResponse(product.getId(), product.getName(), product.getPrice(), product.getImageUrl().toString(), ReadCategoryResponse.fromEntity(product.getCategory())); + List productOptions = product.getProductOptions() + .stream() + .map(ReadProductOptionResponse::fromEntity) + .toList(); + + ReadCategoryResponse category = ReadCategoryResponse.fromEntity(product.getCategory()); + + return new ReadProductResponse(product.getId(), product.getName(), product.getPrice(), product.getImageUrl().toString(), productOptions, category); } public Long getId() { @@ -40,6 +51,10 @@ public String getImageUrl() { return imageUrl; } + public List getProductOptions() { + return productOptions; + } + public ReadCategoryResponse getCategory() { return category; } diff --git a/src/main/resources/static/js/script.js b/src/main/resources/static/js/script.js index 9fed7fed9..98b615c83 100644 --- a/src/main/resources/static/js/script.js +++ b/src/main/resources/static/js/script.js @@ -140,44 +140,6 @@ function giftLogin() { }); } -function kakaoLogin() { - fetch('https://kauth.kakao.com/oauth/authorize?client_id=c14cc9f825429533e917e1b1be966e08&redirect_uri=http://localhost:8080/api/login/oauth2/kakao&response_type=code') - .then(response => { - if (!response.ok) { - return response.json().then(errorData => { - throw new Error(errorData.description); - }); - } - return response.json(); - }) - .then(data => { - console.log(data); - localStorage.setItem('accessToken', data.accessToken); - - fetch('/view/login-callback', { - method: 'GET', - headers: { - 'Authorization': 'Bearer ' + data.accessToken - } - }) - .then(response => { - if (!response.ok) { - throw new Error('페이지 로드 실패: ' + response.statusText); - } - return response.text(); - }) - .then(html => { - document.write(html); - }) - .catch(error => { - console.error('페이지 로드 실패: ', error); - }); - }) - .catch(error => { - console.error('알 수 없는 에러가 발생했습니다! ', error); - }); -} - function registerUser() { const form = document.getElementById('registerForm'); const formData = new FormData(form); diff --git a/src/main/resources/templates/form/login-form.html b/src/main/resources/templates/form/login-form.html index eeb3b6e3f..ac13998d2 100644 --- a/src/main/resources/templates/form/login-form.html +++ b/src/main/resources/templates/form/login-form.html @@ -23,7 +23,7 @@

로그인