diff --git a/README.md b/README.md index 165211360..97f5c2c7d 100644 --- a/README.md +++ b/README.md @@ -22,4 +22,41 @@ - 카카오 로그인 화면 만들기 - 버튼 클릭 시 로그인 화면으로 이동 - 액세스 토큰 얻기 성공 화면 만들기 + + +
+2단계 - 주문하기 + +- 액세스 토큰으로부터 정보 추출하기 + - KakaoAccountDTO 생성 + - KakaoUserInfoDTO 생성 + - KakaoService에 메서드 추가 + - KakaoProperties에서 url 관리하기 + - 액세스 토큰으로부터 이메일 뽑아와서 멤버로 저장하기 + - JwtToken 생성하기 + - KakaoLoginController 수정 + - kakao_access_token.html 수정 + +- 주문하기 + - Order 엔티티 만들기 + - OrderDTO 만들기 + - OrderRequestDTO + - 주문 버튼 눌렀을 떄 넘겨줄 정보 + - OrderResponseDTO + - 카카오톡 메시지 보낼 때 필요한 정보 + - OrderRepository 만들기 + - OrderService 만들기 + - OrderController 만들기 + - order_form 이동 시 예시 데이터 넣기 + - Order가 생성되면 주문이 된 것이므로 수량 차감하기 + - option_list.html 수정 + - order_form으로 이동하는 버튼 만들기 + - order_form 생성 + +- 카카오톡 메시지 보내기 + - KakaoService에 메서드 추가 + - 카카오톡 메시지 보내는 메서드 추가 + - 메시지 템플릿 생성 + - OrderController 수정 + - 주문 버튼을 눌렀을 때 메시지 보내도록 수정
\ No newline at end of file diff --git a/build.gradle b/build.gradle index e0aeaa97d..70e129b18 100644 --- a/build.gradle +++ b/build.gradle @@ -23,6 +23,7 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-validation' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' + implementation 'org.slf4j:slf4j-api:1.7.30' compileOnly 'io.jsonwebtoken:jjwt-api:0.12.6' runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.6' runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.12.6' diff --git a/src/main/java/gift/config/KakaoProperties.java b/src/main/java/gift/config/KakaoProperties.java index ec5ef6543..89caab49c 100644 --- a/src/main/java/gift/config/KakaoProperties.java +++ b/src/main/java/gift/config/KakaoProperties.java @@ -9,8 +9,11 @@ public record KakaoProperties( String clientId, String redirectUrl, String authUrl, - String tokenUrl + String tokenUrl, + String userInfoUrl, + String sendMessageUrl ) { + public String generateLoginUrl() { return String.format("%s?response_type=code&client_id=%s&redirect_uri=%s", authUrl, clientId, redirectUrl); diff --git a/src/main/java/gift/controller/KakaoLoginController.java b/src/main/java/gift/controller/KakaoLoginController.java index 8714e65b0..03534c74b 100644 --- a/src/main/java/gift/controller/KakaoLoginController.java +++ b/src/main/java/gift/controller/KakaoLoginController.java @@ -2,6 +2,7 @@ import gift.config.KakaoProperties; +import gift.model.Member; import gift.service.KakaoService; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; @@ -33,7 +34,12 @@ public String kakaoAccessToken(@RequestParam(value = "code") String authorizatio RedirectAttributes redirectAttributes) { if (authorizationCode != null) { String accessToken = kakaoService.getAccessToken(authorizationCode); + String email = kakaoService.getUserEmail(accessToken); + Member member = kakaoService.saveKakaoUser(email); + String jwtToken = kakaoService.generateToken(member.getEmail(), member.getRole()); redirectAttributes.addFlashAttribute("accessToken", accessToken); + redirectAttributes.addFlashAttribute("email", email); + redirectAttributes.addFlashAttribute("jwtToken", jwtToken); return "redirect:/kakao/success"; } String loginUrl = kakaoService.generateKakaoLoginUrl(); diff --git a/src/main/java/gift/controller/OrderController.java b/src/main/java/gift/controller/OrderController.java new file mode 100644 index 000000000..ebd74918c --- /dev/null +++ b/src/main/java/gift/controller/OrderController.java @@ -0,0 +1,58 @@ +package gift.controller; + +import gift.annotation.LoginMember; +import gift.dto.OrderRequestDTO; +import gift.dto.OrderResponseDTO; +import gift.model.Member; +import gift.service.KakaoService; +import gift.service.OrderService; +import jakarta.validation.Valid; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +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; + +@Controller +@RequestMapping("/orders/{optionId}") +public class OrderController { + + private static final Logger logger = LoggerFactory.getLogger(OrderController.class); + + private final OrderService orderService; + private final KakaoService kakaoService; + + public OrderController(OrderService orderService, KakaoService kakaoService) { + this.orderService = orderService; + this.kakaoService = kakaoService; + } + + @GetMapping + public String showOrderForm(@PathVariable("optionId") Long optionId, Model model) { + OrderRequestDTO orderRequestDTO = new OrderRequestDTO(optionId, 1L, "임시 메시지", null); + model.addAttribute("orderRequestDTO", orderRequestDTO); + return "order_form"; + } + + @PostMapping + public String addOrder(@PathVariable("optionId") Long optionId, + @RequestBody @Valid OrderRequestDTO orderRequestDTO, @LoginMember Member member) { + if (member == null) { + return "redirect:/members/login"; + } + OrderResponseDTO orderResponseDTO = orderService.createOrder(orderRequestDTO, + member.getEmail()); + String accessToken = orderRequestDTO.accessToken(); + try { + kakaoService.sendKakaoMessage(accessToken, orderResponseDTO); + } catch (Exception e) { + logger.error("카카오톡 메시지 전송 실패"); + } + return "redirect:/admin/products"; + } + +} diff --git a/src/main/java/gift/dto/KakaoAccountDTO.java b/src/main/java/gift/dto/KakaoAccountDTO.java new file mode 100644 index 000000000..485cd6d3d --- /dev/null +++ b/src/main/java/gift/dto/KakaoAccountDTO.java @@ -0,0 +1,10 @@ +package gift.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record KakaoAccountDTO( + @JsonProperty("email") + String email +) { + +} diff --git a/src/main/java/gift/dto/KakaoUserInfoDTO.java b/src/main/java/gift/dto/KakaoUserInfoDTO.java new file mode 100644 index 000000000..7398ea492 --- /dev/null +++ b/src/main/java/gift/dto/KakaoUserInfoDTO.java @@ -0,0 +1,12 @@ +package gift.dto; + +import com.fasterxml.jackson.annotation.JsonProperty; + +public record KakaoUserInfoDTO( + @JsonProperty("id") + Long id, + @JsonProperty("kakao_account") + KakaoAccountDTO kakaoAccountDTO +) { + +} diff --git a/src/main/java/gift/dto/OptionSubtractQuantityDTO.java b/src/main/java/gift/dto/OptionSubtractQuantityDTO.java deleted file mode 100644 index 45bf904c1..000000000 --- a/src/main/java/gift/dto/OptionSubtractQuantityDTO.java +++ /dev/null @@ -1,12 +0,0 @@ -package gift.dto; - -import jakarta.validation.constraints.Min; -import jakarta.validation.constraints.NotNull; - -public record OptionSubtractQuantityDTO( - @NotNull(message = "차감할 수량을 입력해야 합니다.") - @Min(value = 1, message = "차감할 수량은 0보다 커야 합니다.") - Long subtractQuantity -) { - -} diff --git a/src/main/java/gift/dto/OrderRequestDTO.java b/src/main/java/gift/dto/OrderRequestDTO.java new file mode 100644 index 000000000..2e60abb9c --- /dev/null +++ b/src/main/java/gift/dto/OrderRequestDTO.java @@ -0,0 +1,10 @@ +package gift.dto; + +public record OrderRequestDTO( + Long optionId, + Long quantity, + String message, + String accessToken +) { + +} diff --git a/src/main/java/gift/dto/OrderResponseDTO.java b/src/main/java/gift/dto/OrderResponseDTO.java new file mode 100644 index 000000000..91ca541c5 --- /dev/null +++ b/src/main/java/gift/dto/OrderResponseDTO.java @@ -0,0 +1,13 @@ +package gift.dto; + +import java.time.LocalDateTime; + +public record OrderResponseDTO( + Long id, + Long optionId, + Long quantity, + LocalDateTime orderDateTime, + String message +) { + +} diff --git a/src/main/java/gift/dto/TemplateObjectDTO.java b/src/main/java/gift/dto/TemplateObjectDTO.java new file mode 100644 index 000000000..1df010281 --- /dev/null +++ b/src/main/java/gift/dto/TemplateObjectDTO.java @@ -0,0 +1,26 @@ +package gift.dto; + +import java.util.Map; +import java.util.HashMap; + +public record TemplateObjectDTO( + String object_type, + String text, + Map link +) { + public TemplateObjectDTO(Long id, Long optionId, Long quantity, String orderDateTime, String message) { + this( + "text", + String.format("주문 정보:\n주문 ID: %d\n옵션 ID: %d\n수량: %d\n주문 시간: %s\n메시지: %s", + id, optionId, quantity, orderDateTime, message), + createLink() + ); + } + + private static Map createLink() { + Map link = new HashMap<>(); + link.put("web_url", "http://localhost:8080/admin/products"); + link.put("mobile_web_url", "http://localhost:8080/admin/products"); + return link; + } +} diff --git a/src/main/java/gift/model/Order.java b/src/main/java/gift/model/Order.java new file mode 100644 index 000000000..cb417c981 --- /dev/null +++ b/src/main/java/gift/model/Order.java @@ -0,0 +1,75 @@ +package gift.model; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import java.time.LocalDateTime; + +@Entity +@Table(name = "orders") +public class Order { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne + @JoinColumn(name = "option_id", nullable = false) + private Option option; + + @Column(name = "quantity", nullable = false) + private Long quantity; + + @Column(name = "order_date_time", nullable = false) + private LocalDateTime orderDateTime; + + @Column(name = "message") + private String message; + + @ManyToOne + @JoinColumn(name = "member_id", nullable = false) + private Member member; + + protected Order() { + } + + public Order(Long id, Option option, Long quantity, LocalDateTime orderDateTime, String message, + Member member) { + this.id = id; + this.option = option; + this.quantity = quantity; + this.orderDateTime = orderDateTime; + this.message = message; + this.member = member; + } + + public Long getId() { + return id; + } + + public Option getOption() { + return option; + } + + public Long getQuantity() { + return quantity; + } + + public LocalDateTime getOrderDateTime() { + return orderDateTime; + } + + public String getMessage() { + return message; + } + + public Member getMember() { + return member; + } + +} \ No newline at end of file diff --git a/src/main/java/gift/repository/OrderRepository.java b/src/main/java/gift/repository/OrderRepository.java new file mode 100644 index 000000000..28c6ddc30 --- /dev/null +++ b/src/main/java/gift/repository/OrderRepository.java @@ -0,0 +1,8 @@ +package gift.repository; + +import gift.model.Order; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface OrderRepository extends JpaRepository { + +} diff --git a/src/main/java/gift/service/KakaoService.java b/src/main/java/gift/service/KakaoService.java index 17ec05a90..57db9a887 100644 --- a/src/main/java/gift/service/KakaoService.java +++ b/src/main/java/gift/service/KakaoService.java @@ -1,11 +1,23 @@ package gift.service; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; import gift.config.KakaoProperties; import gift.dto.KakaoAccessTokenDTO; +import gift.dto.KakaoUserInfoDTO; +import gift.dto.MemberDTO; +import gift.dto.OrderResponseDTO; +import gift.dto.TemplateObjectDTO; +import gift.model.Member; +import gift.repository.MemberRepository; +import gift.util.JwtUtil; import java.net.URI; +import java.util.Random; +import org.springframework.http.HttpHeaders; import org.springframework.http.MediaType; import org.springframework.http.ResponseEntity; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import org.springframework.util.LinkedMultiValueMap; import org.springframework.web.client.RestClient; @@ -14,9 +26,14 @@ public class KakaoService { private final KakaoProperties kakaoProperties; private final RestClient restClient = RestClient.builder().build(); + private final MemberRepository memberRepository; + private final JwtUtil jwtUtil; - public KakaoService(KakaoProperties kakaoProperties) { + public KakaoService(KakaoProperties kakaoProperties, MemberRepository memberRepository, + JwtUtil jwtUtil) { this.kakaoProperties = kakaoProperties; + this.memberRepository = memberRepository; + this.jwtUtil = jwtUtil; } public String generateKakaoLoginUrl() { @@ -44,4 +61,79 @@ private LinkedMultiValueMap createBody(String authorizationCode) body.add("code", authorizationCode); return body; } + + public String getUserEmail(String accessToken) { + String url = kakaoProperties.userInfoUrl(); + ResponseEntity response = restClient.get() + .uri(URI.create(url)) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) + .retrieve() + .toEntity(KakaoUserInfoDTO.class); + KakaoUserInfoDTO kakaoUserInfoDTO = response.getBody(); + return kakaoUserInfoDTO.kakaoAccountDTO().email(); + } + + @Transactional + public Member saveKakaoUser(String email) { + Member member = memberRepository.findByEmail(email); + if (member == null) { + String name = email.split("@")[0]; + String password = generateRandomPassword(); + MemberDTO memberDTO = new MemberDTO(name, email, password); + member = new Member(null, memberDTO.name(), memberDTO.email(), memberDTO.password(), + "user"); + memberRepository.save(member); + } + return member; + } + + public String generateToken(String email, String role) { + String jwtToken = jwtUtil.generateToken(email, role); + return jwtToken; + } + + public void sendKakaoMessage(String accessToken, OrderResponseDTO orderResponseDTO) { + String url = kakaoProperties.sendMessageUrl(); + String templateObjectJson = getTemplateObjectJson(orderResponseDTO); + final LinkedMultiValueMap body = new LinkedMultiValueMap<>(); + body.add("template_object", templateObjectJson); + ResponseEntity response = restClient.post() + .uri(URI.create(url)) + .header(HttpHeaders.AUTHORIZATION, "Bearer " + accessToken) + .contentType(MediaType.APPLICATION_FORM_URLENCODED) + .body(body) + .retrieve() + .toEntity(String.class); + } + + private static String getTemplateObjectJson(OrderResponseDTO orderResponseDTO) { + TemplateObjectDTO templateObjectDTO = new TemplateObjectDTO( + orderResponseDTO.id(), + orderResponseDTO.optionId(), + orderResponseDTO.quantity(), + orderResponseDTO.orderDateTime().toString(), + orderResponseDTO.message() + ); + ObjectMapper objectMapper = new ObjectMapper(); + String templateObjectJson; + try { + templateObjectJson = objectMapper.writeValueAsString(templateObjectDTO); + } catch (JsonProcessingException e) { + throw new IllegalArgumentException("JSON으로 변환에 실패했습니다."); + } + return templateObjectJson; + } + + private String generateRandomPassword() { + int leftLimit = 48; + int rightLimit = 122; + int targetStringLength = 20; + Random random = new Random(); + String generatedPassword = random.ints(leftLimit, rightLimit + 1) + .filter(i -> (i <= 57 || i >= 65) && (i <= 90 || i >= 97)) + .limit(targetStringLength) + .collect(StringBuilder::new, StringBuilder::appendCodePoint, StringBuilder::append) + .toString(); + return generatedPassword; + } } \ No newline at end of file diff --git a/src/main/java/gift/service/OptionService.java b/src/main/java/gift/service/OptionService.java index d0cd6f8c0..40c775b3e 100644 --- a/src/main/java/gift/service/OptionService.java +++ b/src/main/java/gift/service/OptionService.java @@ -1,7 +1,6 @@ package gift.service; import gift.dto.OptionDTO; -import gift.dto.OptionSubtractQuantityDTO; import gift.model.Option; import gift.model.Product; import gift.repository.OptionRepository; @@ -61,9 +60,8 @@ public void deleteOption(Long optionId, Long productId) { } @Transactional - public void subtractQuantity(Long optionId, OptionSubtractQuantityDTO optionSubtractQuantityDTO) { + public void subtractQuantity(Long optionId, Long subtractQuantity) { Option option = optionRepository.findById(optionId).orElse(null); - Long subtractQuantity = optionSubtractQuantityDTO.subtractQuantity(); option.subtractQuantity(subtractQuantity); optionRepository.save(option); } diff --git a/src/main/java/gift/service/OrderService.java b/src/main/java/gift/service/OrderService.java new file mode 100644 index 000000000..26d2e3066 --- /dev/null +++ b/src/main/java/gift/service/OrderService.java @@ -0,0 +1,62 @@ +package gift.service; + + +import gift.dto.OrderRequestDTO; +import gift.dto.OrderResponseDTO; +import gift.model.Member; +import gift.model.Option; +import gift.model.Order; +import gift.repository.OrderRepository; +import java.time.LocalDateTime; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +@Service +@Transactional(readOnly = true) +public class OrderService { + + private static final Logger logger = LoggerFactory.getLogger(OrderService.class); + + private final OrderRepository orderRepository; + private final OptionService optionService; + private final MemberService memberService; + private final WishlistService wishlistService; + + public OrderService(OrderRepository orderRepository, OptionService optionService, + MemberService memberService, + WishlistService wishlistService) { + this.orderRepository = orderRepository; + this.optionService = optionService; + this.memberService = memberService; + this.wishlistService = wishlistService; + } + + @Transactional + public OrderResponseDTO createOrder(OrderRequestDTO orderRequestDTO, String email) { + Option option = optionService.findOptionById(orderRequestDTO.optionId()); + Member member = memberService.findMemberByEmail(email); + Long quantity = orderRequestDTO.quantity(); + optionService.subtractQuantity(option.getId(), quantity); + Order order = new Order(null, option, quantity, LocalDateTime.now(), + orderRequestDTO.message(), member); + orderRepository.save(order); + removeFromWishlist(member.getEmail(), option.getProduct().getId()); + OrderResponseDTO orderResponseDTO = new OrderResponseDTO(order.getId(), + order.getOption().getId(), order.getQuantity(), order.getOrderDateTime(), + order.getMessage()); + return orderResponseDTO; + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void removeFromWishlist(String email, Long productId) { + try { + wishlistService.removeWishlist(email, productId); + } catch (Exception e) { + logger.error("주문은 성공했지만 위시리스트에서의 삭제는 실패했습니다."); + } + } + +} diff --git a/src/main/java/gift/service/ProductService.java b/src/main/java/gift/service/ProductService.java index 1314ce4ba..312ed2f79 100644 --- a/src/main/java/gift/service/ProductService.java +++ b/src/main/java/gift/service/ProductService.java @@ -53,7 +53,8 @@ public void updateProduct(ProductDTO productDTO, Long id) { Product existingProduct = productRepository.findById(id).orElse(null); if (existingProduct != null) { Category category = categoryService.findCategoryById(productDTO.categoryId()); - existingProduct.updateProduct(productDTO.name(),productDTO.price(),category,productDTO.imageUrl()); + existingProduct.updateProduct(productDTO.name(), productDTO.price(), category, + productDTO.imageUrl()); productRepository.save(existingProduct); } } diff --git a/src/main/resources/db/schema.sql b/src/main/resources/db/schema.sql index c4370a176..ee50664d6 100644 --- a/src/main/resources/db/schema.sql +++ b/src/main/resources/db/schema.sql @@ -40,4 +40,16 @@ CREATE TABLE options product_id BIGINT NOT NULL, FOREIGN KEY (product_id) REFERENCES product (id), UNIQUE (product_id, name) +); + +CREATE TABLE orders +( + id BIGINT AUTO_INCREMENT PRIMARY KEY, + option_id BIGINT NOT NULL, + quantity BIGINT NOT NULL, + order_date_time TIMESTAMP NOT NULL, + message VARCHAR(255), + member_id BIGINT NOT NULL, + FOREIGN KEY (option_id) REFERENCES options (id), + FOREIGN KEY (member_id) REFERENCES member (id) ); \ No newline at end of file diff --git a/src/main/resources/templates/kakao_access_token.html b/src/main/resources/templates/kakao_access_token.html index 4df82dcd2..e3847f19c 100644 --- a/src/main/resources/templates/kakao_access_token.html +++ b/src/main/resources/templates/kakao_access_token.html @@ -2,29 +2,49 @@ - 카카오 액세스 토큰 얻기 + 카카오 로그인 성공 -

카카오 액세스 토큰 얻기

-

액세스 토큰:

- +

카카오 로그인 성공

+

카카오 로그인 성공

+ + + +상품 목록으로 이동 \ No newline at end of file diff --git a/src/main/resources/templates/login_success.html b/src/main/resources/templates/login_success.html index a63d60f67..d73a7a1df 100644 --- a/src/main/resources/templates/login_success.html +++ b/src/main/resources/templates/login_success.html @@ -6,7 +6,7 @@ + + +

주문하기

+
+
+ + +
+
+
+ + +
+
+ +
+ +
+
+ + \ No newline at end of file diff --git a/src/main/resources/templates/product_list.html b/src/main/resources/templates/product_list.html index 752d1acbd..d73df287d 100644 --- a/src/main/resources/templates/product_list.html +++ b/src/main/resources/templates/product_list.html @@ -5,7 +5,7 @@ 상품 목록