diff --git a/build.gradle b/build.gradle index f65b221..47b0440 100644 --- a/build.gradle +++ b/build.gradle @@ -1,6 +1,6 @@ plugins { id 'java' - id 'org.springframework.boot' version '3.5.5' + id 'org.springframework.boot' version '3.2.5' id 'io.spring.dependency-management' version '1.1.7' id 'org.asciidoctor.jvm.convert' version '3.3.2' } @@ -32,6 +32,7 @@ ext { dependencies { implementation 'org.springframework.boot:spring-boot-starter-thymeleaf' implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-webflux' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.thymeleaf.extras:thymeleaf-extras-springsecurity6' @@ -51,6 +52,12 @@ dependencies { implementation ('com.github.javafaker:javafaker:1.0.2') { exclude group: 'org.yaml', module: 'snakeyaml' } + + // Actuator + implementation 'org.springframework.boot:spring-boot-starter-actuator' + // Prometheus + implementation 'io.micrometer:micrometer-registry-prometheus' + } tasks.named('test') { diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..a9c734f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,19 @@ +version: '3.8' +services: + prometheus: + image: prom/prometheus + container_name: prometheus + ports: + - "9090:9090" + volumes: + - ./prometheus.yml:/etc/prometheus/prometheus.yml + command: + - '--config.file=/etc/prometheus/prometheus.yml' + + grafana: + image: grafana/grafana + container_name: grafana + ports: + - "3000:3000" + depends_on: + - prometheus \ No newline at end of file diff --git a/prometheus.yml b/prometheus.yml new file mode 100644 index 0000000..926d14f --- /dev/null +++ b/prometheus.yml @@ -0,0 +1,6 @@ +scrape_configs: + - job_name: 'shoppay-app' + metrics_path: '/actuator/prometheus' + scrape_interval: 15s + static_configs: + - targets: ['host.docker.internal:8080'] \ No newline at end of file diff --git a/src/main/java/org/zerock/shoppay/Controller/CartController.java b/src/main/java/org/zerock/shoppay/Controller/CartController.java index 45556da..5a27d05 100644 --- a/src/main/java/org/zerock/shoppay/Controller/CartController.java +++ b/src/main/java/org/zerock/shoppay/Controller/CartController.java @@ -1,6 +1,7 @@ package org.zerock.shoppay.Controller; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; @@ -18,7 +19,9 @@ import java.util.Map; @Controller +@RequestMapping("/cart") @RequiredArgsConstructor +@Slf4j public class CartController { private final CartService cartService; @@ -28,7 +31,7 @@ public class CartController { private String clientKey; // 장바구니 페이지 - @GetMapping("/cart") + @GetMapping public String cart(@AuthenticationPrincipal UserDetails userDetails, Model model) { if (userDetails != null) { Member member = memberService.findByEmail(userDetails.getUsername()); @@ -43,7 +46,7 @@ public String cart(@AuthenticationPrincipal UserDetails userDetails, Model model } // 장바구니에 상품 추가 (AJAX) - @PostMapping("/cart/add") + @PostMapping("/add") @ResponseBody public ResponseEntity> addToCart( @AuthenticationPrincipal UserDetails userDetails, @@ -76,7 +79,7 @@ public ResponseEntity> addToCart( } // 장바구니 아이템 수량 변경 (AJAX) - @PostMapping("/cart/update/{cartItemId}") + @PostMapping("/update/{cartItemId}") @ResponseBody public ResponseEntity> updateQuantity( @AuthenticationPrincipal UserDetails userDetails, @@ -110,22 +113,26 @@ public ResponseEntity> updateQuantity( } // 장바구니 아이템 삭제 (AJAX) - @DeleteMapping("/cart/remove/{cartItemId}") + @DeleteMapping("/remove/{cartItemId}") @ResponseBody public ResponseEntity> removeFromCart( @AuthenticationPrincipal UserDetails userDetails, @PathVariable Long cartItemId) { + log.info("Attempting to remove cart item. ID: {}", cartItemId); Map response = new HashMap<>(); try { if (userDetails == null) { + log.warn("Unauthorized attempt to remove cart item. User is not logged in."); response.put("success", false); response.put("message", "로그인이 필요합니다."); return ResponseEntity.ok(response); } + log.info("User '{}' is attempting to remove cart item ID: {}", userDetails.getUsername(), cartItemId); cartService.removeFromCart(cartItemId); + log.info("Successfully called cartService.removeFromCart for item ID: {}", cartItemId); Member member = memberService.findByEmail(userDetails.getUsername()); Cart cart = cartService.getCartWithItems(member); @@ -134,8 +141,10 @@ public ResponseEntity> removeFromCart( response.put("message", "장바구니에서 삭제되었습니다."); response.put("totalPrice", cart.getTotalPrice()); response.put("totalItems", cart.getTotalItems()); + log.info("Successfully removed cart item ID: {}. Returning success response.", cartItemId); } catch (Exception e) { + log.error("Error removing cart item ID: {}", cartItemId, e); response.put("success", false); response.put("message", "삭제 중 오류가 발생했습니다."); } @@ -144,7 +153,7 @@ public ResponseEntity> removeFromCart( } // 장바구니 비우기 - @PostMapping("/cart/clear") + @PostMapping("/clear") public String clearCart(@AuthenticationPrincipal UserDetails userDetails) { if (userDetails != null) { Member member = memberService.findByEmail(userDetails.getUsername()); @@ -154,7 +163,7 @@ public String clearCart(@AuthenticationPrincipal UserDetails userDetails) { } // 장바구니 아이템 개수 조회 (헤더용) - @GetMapping("/cart/count") + @GetMapping("/count") @ResponseBody public ResponseEntity getCartItemCount(@AuthenticationPrincipal UserDetails userDetails) { if (userDetails != null) { diff --git a/src/main/java/org/zerock/shoppay/Controller/OrderController.java b/src/main/java/org/zerock/shoppay/Controller/OrderController.java index 8c708c3..3e24f3c 100644 --- a/src/main/java/org/zerock/shoppay/Controller/OrderController.java +++ b/src/main/java/org/zerock/shoppay/Controller/OrderController.java @@ -1,17 +1,19 @@ package org.zerock.shoppay.Controller; import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.stereotype.Controller; import org.springframework.ui.Model; -import org.springframework.beans.factory.annotation.Value; import org.springframework.web.bind.annotation.*; import org.zerock.shoppay.Entity.Cart; import org.zerock.shoppay.Entity.CartItem; import org.zerock.shoppay.Entity.Member; import org.zerock.shoppay.Entity.Order; +import org.zerock.shoppay.dto.CheckoutItemDto; +import org.zerock.shoppay.dto.CreateOrderRequestDto; import org.zerock.shoppay.service.CartService; import org.zerock.shoppay.service.MemberService; import org.zerock.shoppay.service.OrderService; @@ -20,54 +22,20 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.stream.Collectors; @Controller @RequestMapping("/order") @RequiredArgsConstructor public class OrderController { - + private final OrderService orderService; private final CartService cartService; private final MemberService memberService; @Value("${toss.client.key}") private String TOSS_CLIENT_KEY; - - // 주문 생성 API (결제 전) - @PostMapping("/create") - @ResponseBody - public ResponseEntity> createOrder( - @RequestParam Long productId, - @RequestParam Integer quantity, - @RequestParam String customerName, - @RequestParam String customerEmail, - @RequestParam String customerPhone) { - - try { - // 고유한 주문 ID 생성 - String orderId = "order_" + productId + "_" + System.currentTimeMillis(); - - // 주문 생성 - Order order = orderService.createOrder( - orderId, productId, quantity, - customerName, customerEmail, customerPhone - ); - - Map response = new HashMap<>(); - response.put("success", true); - response.put("orderId", order.getOrderId()); - response.put("amount", order.getTotalAmount()); - - return ResponseEntity.ok(response); - - } catch (Exception e) { - Map error = new HashMap<>(); - error.put("success", false); - error.put("message", e.getMessage()); - return ResponseEntity.badRequest().body(error); - } - } - + // 주문 상태 확인 @GetMapping("/status/{orderId}") @ResponseBody @@ -75,40 +43,40 @@ public ResponseEntity> getOrderStatus(@PathVariable String o try { Order order = orderService.findById(orderId) .orElseThrow(() -> new RuntimeException("Order not found")); - + Map response = new HashMap<>(); response.put("orderId", order.getOrderId()); response.put("status", order.getStatus()); response.put("amount", order.getTotalAmount()); response.put("paidAt", order.getPaidAt()); - + return ResponseEntity.ok(response); - + } catch (Exception e) { Map error = new HashMap<>(); error.put("error", e.getMessage()); return ResponseEntity.badRequest().body(error); } } - + // 장바구니 결제 페이지 @GetMapping("/checkout") public String checkout( @AuthenticationPrincipal UserDetails userDetails, @RequestParam(required = false) List items, Model model) { - + if (userDetails == null) { return "redirect:/login"; } - + Member member = memberService.findByEmail(userDetails.getUsername()); Cart cart = cartService.getCartWithItems(member); - + // 선택된 아이템만 필터링 (items 파라미터가 있는 경우) List selectedItems = new ArrayList<>(); int totalPrice = 0; - + if (items != null && !items.isEmpty()) { // 선택된 아이템만 for (CartItem item : cart.getCartItems()) { @@ -122,98 +90,61 @@ public String checkout( selectedItems = cart.getCartItems(); totalPrice = cart.getTotalPrice(); } - - // 배송비 계산 - int deliveryFee = totalPrice >= 50000 ? 0 : 5000; - int finalTotal = totalPrice + deliveryFee; - - // JavaScript에서 사용할 간단한 데이터 구조 생성 - List> cartItemsData = new ArrayList<>(); - for (CartItem item : selectedItems) { - Map itemData = new HashMap<>(); - itemData.put("id", item.getId()); - itemData.put("quantity", item.getQuantity()); - itemData.put("totalPrice", item.getTotalPrice()); - Map productData = new HashMap<>(); - productData.put("id", item.getProduct().getId()); - productData.put("name", item.getProduct().getName()); - productData.put("price", item.getProduct().getPrice()); - itemData.put("product", productData); - cartItemsData.add(itemData); - } - + + // DTO를 사용하여 JavaScript에서 사용할 데이터 구조 생성 + List cartItemsData = selectedItems.stream() + .map(CheckoutItemDto::new) + .collect(Collectors.toList()); + Map memberData = new HashMap<>(); if (member != null) { memberData.put("email", member.getEmail()); memberData.put("name", member.getName()); } - + model.addAttribute("member", member); model.addAttribute("cartItems", selectedItems); model.addAttribute("cartItemsJson", cartItemsData); model.addAttribute("memberJson", memberData); model.addAttribute("subtotal", totalPrice); - model.addAttribute("deliveryFee", deliveryFee); - model.addAttribute("totalAmount", finalTotal); + model.addAttribute("totalAmount", totalPrice); // 배송비 없으므로 subtotal과 동일 model.addAttribute("clientKey", TOSS_CLIENT_KEY); return "order/checkout"; } - + // 장바구니 주문 생성 (AJAX) @PostMapping("/create-from-cart") @ResponseBody public ResponseEntity> createOrderFromCart( @AuthenticationPrincipal UserDetails userDetails, - @RequestBody Map requestData) { - + @RequestBody CreateOrderRequestDto requestDto) { + Map response = new HashMap<>(); - + try { if (userDetails == null) { response.put("success", false); response.put("message", "로그인이 필요합니다."); - return ResponseEntity.ok(response); + return ResponseEntity.status(401).body(response); } - + Member member = memberService.findByEmail(userDetails.getUsername()); - List cartItemIds = (List) requestData.get("cartItemIds"); - - // 선택된 카트 아이템들로 주문 생성 - String orderId = "ORDER_" + System.currentTimeMillis(); - int totalAmount = 0; - - // 선택된 아이템들의 총 금액 계산 - Cart cart = cartService.getCartWithItems(member); - List selectedItems = new ArrayList<>(); - - for (CartItem item : cart.getCartItems()) { - if (cartItemIds.contains(item.getId().intValue())) { - selectedItems.add(item); - totalAmount += item.getTotalPrice(); - } - } - - if (selectedItems.isEmpty()) { - response.put("success", false); - response.put("message", "선택된 상품이 없습니다."); - return ResponseEntity.ok(response); - } - - // 세션에 선택된 아이템 ID 저장 (결제 성공 후 처리용) - // 실제로는 DB에 임시 주문으로 저장하는 것이 좋음 - + + // OrderService를 호출하여 DB에 주문(PENDING 상태)을 미리 생성 + Order order = orderService.createOrderFromCart(member, requestDto.getCartItemIds()); + response.put("success", true); - response.put("orderId", orderId); - response.put("totalAmount", totalAmount); - response.put("selectedItemIds", cartItemIds); - response.put("message", "주문이 생성되었습니다."); - + response.put("orderId", order.getOrderId()); + response.put("totalAmount", order.getTotalAmount()); + response.put("message", "주문이 성공적으로 생성되었습니다."); + + return ResponseEntity.ok(response); + } catch (Exception e) { response.put("success", false); response.put("message", e.getMessage()); + return ResponseEntity.badRequest().body(response); } - - return ResponseEntity.ok(response); } } \ No newline at end of file diff --git a/src/main/java/org/zerock/shoppay/Controller/PaymentController.java b/src/main/java/org/zerock/shoppay/Controller/PaymentController.java index e8817f7..ed10205 100644 --- a/src/main/java/org/zerock/shoppay/Controller/PaymentController.java +++ b/src/main/java/org/zerock/shoppay/Controller/PaymentController.java @@ -1,50 +1,44 @@ package org.zerock.shoppay.Controller; -import jakarta.servlet.http.HttpServletRequest; import lombok.RequiredArgsConstructor; +import org.json.simple.JSONObject; import org.json.simple.parser.JSONParser; import org.json.simple.parser.ParseException; +import org.springframework.beans.factory.annotation.Value; import org.springframework.http.ResponseEntity; -import org.json.simple.JSONObject; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.userdetails.UserDetails; import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; import org.springframework.web.bind.annotation.GetMapping; +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.RequestParam; -import org.springframework.web.bind.annotation.ResponseBody; -import org.springframework.beans.factory.annotation.Value; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.ui.Model; -import org.zerock.shoppay.service.OrderService; -import org.zerock.shoppay.service.CartService; -import org.zerock.shoppay.service.MemberService; import org.zerock.shoppay.Entity.Member; import org.zerock.shoppay.Entity.Order; -import org.zerock.shoppay.Entity.OrderItem; -import org.zerock.shoppay.Entity.Cart; -import org.zerock.shoppay.Entity.CartItem; -import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.security.core.userdetails.UserDetails; -import org.springframework.web.bind.annotation.PostMapping; -import java.util.List; -import java.util.ArrayList; +import org.zerock.shoppay.dto.PaymentConfirmRequestDto; +import org.zerock.shoppay.service.CartService; +import org.zerock.shoppay.service.MemberService; +import org.zerock.shoppay.service.OrderService; -import java.io.*; +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.OutputStream; +import java.io.Reader; import java.net.HttpURLConnection; import java.net.URL; import java.nio.charset.StandardCharsets; import java.util.Base64; -import java.time.LocalDateTime; +import java.util.List; +import java.util.stream.Collectors; @Controller @RequiredArgsConstructor public class PaymentController { - - @Value("${toss.secret.key}") private String API_SECRET_KEY; - + @Value("${toss.client.key}") private String CLIENT_KEY; @@ -53,161 +47,70 @@ public class PaymentController { private final MemberService memberService; @PostMapping("/confirm/payment") - @ResponseBody public ResponseEntity confirmPayment( - HttpServletRequest request, - @RequestBody String jsonBody, + @RequestBody PaymentConfirmRequestDto requestDto, @AuthenticationPrincipal UserDetails userDetails) throws Exception { - JSONObject requestData = parseRequestData(jsonBody); - String orderId = (String) requestData.get("orderId"); - String paymentKey = (String) requestData.get("paymentKey"); - Long amount = Long.parseLong(requestData.get("amount").toString()); + // 1. DB에서 주문 정보를 미리 조회합니다. + Order order = orderService.findById(requestDto.getOrderId()) + .orElseThrow(() -> new RuntimeException("주문을 찾을 수 없습니다: " + requestDto.getOrderId())); + + // 2. DB에 저장된 금액과 요청된 결제 금액이 일치하는지 확인합니다. (금액 위변조 방지) + if (!order.getTotalAmount().equals(requestDto.getAmount().intValue())) { + throw new RuntimeException("주문 금액이 일치하지 않습니다."); + } + + // 3. 토스페이먼츠 결제 승인 API를 호출합니다. + JSONObject requestData = new JSONObject(); + requestData.put("orderId", requestDto.getOrderId()); + requestData.put("amount", requestDto.getAmount()); + requestData.put("paymentKey", requestDto.getPaymentKey()); - // 토스페이먼츠 결제 승인 API 호출 JSONObject response = sendRequest(requestData, API_SECRET_KEY, "https://api.tosspayments.com/v1/payments/confirm"); - - System.out.println("토스 응답: " + response.toJSONString()); - - if (!response.containsKey("error")) { - System.out.println("결제 승인 성공!"); - - // 결제 성공 - 주문 데이터 저장 + + // 4. 토스페이먼츠의 응답에 따라 후속 처리를 합니다. + if (response.get("status").equals("DONE")) { + // 5. 결제 성공: OrderService를 통해 주문 상태를 'PAID'로 변경하고 재고를 차감합니다. + Order confirmedOrder = orderService.confirmPayment(requestDto.getOrderId(), requestDto.getPaymentKey(), requestDto.getAmount()); + + // 6. 결제된 상품들을 장바구니에서 제거합니다. if (userDetails != null) { Member member = memberService.findByEmail(userDetails.getUsername()); - Cart cart = cartService.getCartWithItems(member); - - List selectedItemIds = null; - if (requestData.containsKey("selectedCartItems") && requestData.get("selectedCartItems") != null) { - String selectedItemsJsonString = (String) requestData.get("selectedCartItems"); - if (selectedItemsJsonString != null && !selectedItemsJsonString.isEmpty()) { - JSONParser parser = new JSONParser(); - try { - org.json.simple.JSONArray selectedItemsArray = (org.json.simple.JSONArray) parser.parse(selectedItemsJsonString); - selectedItemIds = new ArrayList<>(); - for (Object id : selectedItemsArray) { - selectedItemIds.add(((Long) id).intValue()); - } - } catch (ParseException e) { - System.err.println("Error parsing selectedCartItems: " + e.getMessage()); - selectedItemIds = new ArrayList<>(); // 오류 발생 시 안전하게 빈 리스트로 처리 - } - } - } - - // 주문 생성 - Order order = Order.builder() - .orderId(orderId) - .member(member) - .totalAmount(amount.intValue()) - .status("PAID") - .paymentKey(paymentKey) - .paidAt(LocalDateTime.now()) - .build(); - - List orderItems = new ArrayList<>(); - List itemsToRemove = new ArrayList<>(); - - if (selectedItemIds != null && !selectedItemIds.isEmpty()) { - for (CartItem cartItem : cart.getCartItems()) { - if (selectedItemIds.contains(cartItem.getId().intValue())) { - OrderItem orderItem = OrderItem.builder() - .order(order) - .product(cartItem.getProduct()) - .quantity(cartItem.getQuantity()) - .price(cartItem.getProduct().getPrice()) - .build(); - orderItems.add(orderItem); - itemsToRemove.add(cartItem); // 제거할 아이템 목록에 추가 - } - } - } else { - System.out.println("Warning: Payment confirmed but no selectedCartItems found. Cart will not be cleared."); - } - order.setOrderItems(orderItems); - - // 주문 저장 - System.out.println("주문 저장 시작..."); - Order savedOrder = orderService.saveOrder(order); - System.out.println("주문 저장 완료! orderId: " + savedOrder.getOrderId()); - - // 선택된 아이템만 장바구니에서 제거 - for (CartItem item : itemsToRemove) { - cartService.removeFromCart(item.getId()); - } - - response.put("message", "주문이 성공적으로 처리되었습니다."); - } else { - response.put("warning", "비로그인 상태에서 결제되었습니다. 주문 정보가 저장되지 않았습니다."); + List purchasedProductIds = confirmedOrder.getOrderItems().stream() + .map(orderItem -> orderItem.getProduct().getId()) + .collect(Collectors.toList()); + cartService.removeCartItemsByProductIds(member, purchasedProductIds); } + + return ResponseEntity.ok(response); } else { - System.out.println("ERROR: 결제 승인 실패!"); + // 7. 결제 실패: 토스페이먼츠가 돌려준 에러 메시지를 그대로 클라이언트에게 전달합니다. + return ResponseEntity.status(400).body(response); } - - int statusCode = response.containsKey("error") ? 400 : 200; - return ResponseEntity.status(statusCode).body(response); } - - @GetMapping("/payment/checkout") - public String checkout( - @RequestParam(required = false) String orderId, - @RequestParam(required = false) Integer amount, - @RequestParam(required = false) String orderName, - Model model) { + public String checkoutPage(Model model) { model.addAttribute("clientKey", CLIENT_KEY); - model.addAttribute("orderId", orderId != null ? orderId : "ORDER_" + System.currentTimeMillis()); - model.addAttribute("amount", amount != null ? amount : 50000); - model.addAttribute("orderName", orderName != null ? orderName : "IKEA 상품"); return "payment/checkout"; } - - - - - - - - - - private JSONObject parseRequestData(String jsonBody) { - try { - return (JSONObject) new JSONParser().parse(jsonBody); - } catch (ParseException e) { - //logger.error("JSON Parsing Error", e); - return new JSONObject(); - } - } + private JSONObject sendRequest(JSONObject requestData, String secretKey, String urlString) throws IOException, ParseException { + URL url = new URL(urlString); + HttpURLConnection connection = (HttpURLConnection) url.openConnection(); + connection.setRequestProperty("Authorization", "Basic " + Base64.getEncoder().encodeToString((secretKey + ":").getBytes(StandardCharsets.UTF_8))); + connection.setRequestProperty("Content-Type", "application/json"); + connection.setRequestMethod("POST"); + connection.setDoOutput(true); - private JSONObject sendRequest(JSONObject requestData, String secretKey, String urlString) throws IOException { - HttpURLConnection connection = createConnection(secretKey, urlString); try (OutputStream os = connection.getOutputStream()) { os.write(requestData.toString().getBytes(StandardCharsets.UTF_8)); } - try (InputStream responseStream = connection.getResponseCode() == 200 ? connection.getInputStream() : connection.getErrorStream(); + int responseCode = connection.getResponseCode(); + try (InputStream responseStream = (responseCode == 200) ? connection.getInputStream() : connection.getErrorStream(); Reader reader = new InputStreamReader(responseStream, StandardCharsets.UTF_8)) { return (JSONObject) new JSONParser().parse(reader); - } catch (Exception e) { - //logger.error("Error reading response", e); - JSONObject errorResponse = new JSONObject(); - errorResponse.put("error", "Error reading response"); - return errorResponse; } } - - private HttpURLConnection createConnection(String secretKey, String urlString) throws IOException { - URL url = new URL(urlString); - HttpURLConnection connection = (HttpURLConnection) url.openConnection(); - connection.setRequestProperty("Authorization", "Basic " + Base64.getEncoder().encodeToString((secretKey + ":").getBytes(StandardCharsets.UTF_8))); - connection.setRequestProperty("Content-Type", "application/json"); - connection.setRequestMethod("POST"); - connection.setDoOutput(true); - return connection; - } - - - } diff --git a/src/main/java/org/zerock/shoppay/Controller/ProductController.java b/src/main/java/org/zerock/shoppay/Controller/ProductController.java index 217e981..4e69fbf 100644 --- a/src/main/java/org/zerock/shoppay/Controller/ProductController.java +++ b/src/main/java/org/zerock/shoppay/Controller/ProductController.java @@ -14,21 +14,17 @@ import java.util.List; +import org.zerock.shoppay.repository.CategoryRepository; + @Controller @RequestMapping("/products") @RequiredArgsConstructor public class ProductController { private final ProductService productService; + private final CategoryRepository categoryRepository; - // 상품 목록 페이지 - @GetMapping - public String listProducts(Model model) { - List products = productService.getAllActiveProducts(); - model.addAttribute("products", products); - return "product/list"; - } - + // 상품 상세 페이지 @GetMapping("/{id}") public String productDetail(@PathVariable Long id, Model model) { @@ -38,49 +34,44 @@ public String productDetail(@PathVariable Long id, Model model) { return "product/detail"; } -// 카테고리별 상품 목록 (ID로 조회) -// @GetMapping("/category/{categoryId}") -// public String productsByCategory(@PathVariable Long categoryId, Model model) { -// List products = productService.getProductsByCategoryId(categoryId); -// model.addAttribute("products", products); -// model.addAttribute("categoryId", categoryId); -// return "product/list"; -// } - @GetMapping("/category/{categoryName}") - public String productsByCategory( - @PathVariable String categoryName, + @GetMapping + public String listProducts( + @RequestParam(required = false) String category, @RequestParam(defaultValue = "0") int page, @RequestParam(defaultValue = "12") int size, @RequestParam(defaultValue = "createdAt") String sort, @RequestParam(defaultValue = "DESC") String direction, Model model) { - + // 정렬 방향 설정 Sort.Direction sortDirection = direction.equalsIgnoreCase("ASC") ? Sort.Direction.ASC : Sort.Direction.DESC; - + // Pageable 객체 생성 Pageable pageable = PageRequest.of(page, size, Sort.by(sortDirection, sort)); - - // 페이지네이션된 상품 조회 - Page productPage = productService.getProductsByCategoryWithPagination(categoryName, pageable); - + + // 통합된 서비스 메서드 호출 + Page productPage = productService.findProducts(category, pageable); + List categories = categoryRepository.findAll(); + model.addAttribute("products", productPage.getContent()); model.addAttribute("productPage", productPage); - model.addAttribute("category", categoryName); + // category가 null일 경우 'All'을, 아니면 해당 카테고리 이름을 모델에 추가 + model.addAttribute("category", category != null ? category : "All"); + model.addAttribute("categories", categories); model.addAttribute("currentPage", page); model.addAttribute("totalPages", productPage.getTotalPages()); model.addAttribute("totalItems", productPage.getTotalElements()); model.addAttribute("size", size); model.addAttribute("sort", sort); model.addAttribute("direction", direction); - - // 페이지 번호 목록 생성 (현재 페이지 기준 앞뒤 5개) + + // 페이지 번호 목록 생성 int startPage = Math.max(0, page - 5); int endPage = Math.min(productPage.getTotalPages() - 1, page + 5); model.addAttribute("startPage", startPage); model.addAttribute("endPage", endPage); - - return "product/category"; // IKEA 스타일 카테고리 페이지 + + return "product/category"; // 템플릿 재사용 } // 상품 검색 diff --git a/src/main/java/org/zerock/shoppay/Controller/JmeterTest/StockTestConroller.java b/src/main/java/org/zerock/shoppay/JmeterTest/StockTestConroller.java similarity index 96% rename from src/main/java/org/zerock/shoppay/Controller/JmeterTest/StockTestConroller.java rename to src/main/java/org/zerock/shoppay/JmeterTest/StockTestConroller.java index bc38fb8..b6bb72d 100644 --- a/src/main/java/org/zerock/shoppay/Controller/JmeterTest/StockTestConroller.java +++ b/src/main/java/org/zerock/shoppay/JmeterTest/StockTestConroller.java @@ -1,4 +1,4 @@ -package org.zerock.shoppay.Controller.JmeterTest; +package org.zerock.shoppay.JmeterTest; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; diff --git a/src/main/java/org/zerock/shoppay/config/DataInitializer.java b/src/main/java/org/zerock/shoppay/config/DataInitializer.java new file mode 100644 index 0000000..189c290 --- /dev/null +++ b/src/main/java/org/zerock/shoppay/config/DataInitializer.java @@ -0,0 +1,61 @@ +package org.zerock.shoppay.config; + +import lombok.RequiredArgsConstructor; +import org.springframework.boot.CommandLineRunner; +import org.springframework.stereotype.Component; +import org.zerock.shoppay.Entity.Category; +import org.zerock.shoppay.repository.CategoryRepository; +import org.zerock.shoppay.repository.ProductRepository; +import org.zerock.shoppay.util.ImprovedDataGenerator; + +@Component +@RequiredArgsConstructor +public class DataInitializer implements CommandLineRunner { + + private final ProductRepository productRepository; + private final CategoryRepository categoryRepository; + private final ImprovedDataGenerator improvedDataGenerator; + + @Override + public void run(String... args) throws Exception { + // 카테고리 먼저 초기화 + if (categoryRepository.count() == 0) { + Category bed = Category.builder().name("Bed").build(); + Category kitchen = Category.builder().name("Kitchen").build(); + Category living = Category.builder().name("Living").build(); + Category newCategory = Category.builder().name("new").build(); + Category chair = Category.builder().name("chair").build(); + Category storeFurniture = Category.builder().name("store_furniture").build(); + Category stored = Category.builder().name("stored").build(); + Category deskChair = Category.builder().name("desk_chair").build(); + Category kitchinChair = Category.builder().name("kitchin_chair").build(); + Category outdoor = Category.builder().name("outdoor").build(); + Category plant = Category.builder().name("plant").build(); + Category deco = Category.builder().name("deco").build(); + Category lightning = Category.builder().name("lightning").build(); + + categoryRepository.save(bed); + categoryRepository.save(kitchen); + categoryRepository.save(living); + categoryRepository.save(newCategory); + categoryRepository.save(chair); + categoryRepository.save(storeFurniture); + categoryRepository.save(stored); + categoryRepository.save(deskChair); + categoryRepository.save(kitchinChair); + categoryRepository.save(outdoor); + categoryRepository.save(plant); + categoryRepository.save(deco); + categoryRepository.save(lightning); + + System.out.println("카테고리 13개가 추가되었습니다!"); + } + + // 그 다음 상품 초기화 + if (productRepository.count() == 0) { + System.out.println("향상된 데이터 생성기를 사용하여 50개의 상품을 생성합니다..."); + improvedDataGenerator.generateProducts(50); + System.out.println("상품 50개 생성이 완료되었습니다!"); + } + } +} diff --git a/src/main/java/org/zerock/shoppay/config/RequestLoggingFilter.java b/src/main/java/org/zerock/shoppay/config/RequestLoggingFilter.java new file mode 100644 index 0000000..e3b4804 --- /dev/null +++ b/src/main/java/org/zerock/shoppay/config/RequestLoggingFilter.java @@ -0,0 +1,30 @@ +package org.zerock.shoppay.config; + +import jakarta.servlet.*; +import jakarta.servlet.http.HttpServletRequest; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +@Order(1) // 모든 필터 중에서 가장 먼저 실행되도록 순서를 1로 지정 +public class RequestLoggingFilter implements Filter { + + private static final Logger log = LoggerFactory.getLogger(RequestLoggingFilter.class); + + @Override + public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) + throws IOException, ServletException { + + HttpServletRequest httpRequest = (HttpServletRequest) request; + + // 요청이 서버에 도착한 바로 그 순간의 URI를 로그로 출력합니다. + log.info(">>>>> INCOMING REQUEST URI: " + httpRequest.getRequestURI()); + + // 다음 필터로 요청을 전달합니다. + chain.doFilter(request, response); + } +} diff --git a/src/main/java/org/zerock/shoppay/config/SecurityConfig.java b/src/main/java/org/zerock/shoppay/config/SecurityConfig.java index df89e96..8de2f3b 100644 --- a/src/main/java/org/zerock/shoppay/config/SecurityConfig.java +++ b/src/main/java/org/zerock/shoppay/config/SecurityConfig.java @@ -10,6 +10,7 @@ import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer; @Configuration @EnableWebSecurity @@ -75,4 +76,11 @@ public AuthenticationManager authenticationManager( AuthenticationConfiguration authConfig) throws Exception { return authConfig.getAuthenticationManager(); } + + @Bean + public WebSecurityCustomizer webSecurityCustomizer() { + // 정적 리소스들은 시큐리티 필터를 거치지 않도록 설정 + return (web) -> web.ignoring() + .requestMatchers("/favicon.ico", "/css/**", "/js/**", "/images/**", "/.well-known/**"); + } } \ No newline at end of file diff --git a/src/main/java/org/zerock/shoppay/config/WebConfig.java b/src/main/java/org/zerock/shoppay/config/WebConfig.java index 8cf0edf..9b97c8a 100644 --- a/src/main/java/org/zerock/shoppay/config/WebConfig.java +++ b/src/main/java/org/zerock/shoppay/config/WebConfig.java @@ -1,12 +1,22 @@ package org.zerock.shoppay.config; import org.springframework.context.annotation.Configuration; +import org.springframework.http.MediaType; +import org.springframework.web.servlet.config.annotation.ContentNegotiationConfigurer; import org.springframework.web.servlet.config.annotation.ViewControllerRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; @Configuration public class WebConfig implements WebMvcConfigurer { - + + @Override + public void configureContentNegotiation(ContentNegotiationConfigurer configurer) { + configurer.favorPathExtension(false) + .favorParameter(false) + .ignoreAcceptHeader(false) + .defaultContentType(MediaType.APPLICATION_JSON); + } + @Override public void addViewControllers(ViewControllerRegistry registry) { // 결제 관련 경로 diff --git a/src/main/java/org/zerock/shoppay/dto/CheckoutItemDto.java b/src/main/java/org/zerock/shoppay/dto/CheckoutItemDto.java new file mode 100644 index 0000000..c3970da --- /dev/null +++ b/src/main/java/org/zerock/shoppay/dto/CheckoutItemDto.java @@ -0,0 +1,19 @@ +package org.zerock.shoppay.dto; + +import lombok.Getter; +import org.zerock.shoppay.Entity.CartItem; + +@Getter +public class CheckoutItemDto { + private final Long id; + private final int quantity; + private final int totalPrice; + private final ProductDto product; + + public CheckoutItemDto(CartItem cartItem) { + this.id = cartItem.getId(); + this.quantity = cartItem.getQuantity(); + this.totalPrice = cartItem.getTotalPrice(); + this.product = new ProductDto(cartItem.getProduct()); + } +} diff --git a/src/main/java/org/zerock/shoppay/dto/CreateOrderRequestDto.java b/src/main/java/org/zerock/shoppay/dto/CreateOrderRequestDto.java new file mode 100644 index 0000000..91d6ff1 --- /dev/null +++ b/src/main/java/org/zerock/shoppay/dto/CreateOrderRequestDto.java @@ -0,0 +1,12 @@ +package org.zerock.shoppay.dto; + +import lombok.Getter; +import lombok.Setter; + +import java.util.List; + +@Getter +@Setter +public class CreateOrderRequestDto { + private List cartItemIds; +} diff --git a/src/main/java/org/zerock/shoppay/dto/ErrorResponse.java b/src/main/java/org/zerock/shoppay/dto/ErrorResponse.java index 5753e21..ca1703b 100644 --- a/src/main/java/org/zerock/shoppay/dto/ErrorResponse.java +++ b/src/main/java/org/zerock/shoppay/dto/ErrorResponse.java @@ -1,5 +1,5 @@ package org.zerock.shoppay.dto; -// Java 14 이상에서 사용 가능한 record를 사용하여 불변 DTO를 간결하게 정의합니다. +//record를 이용한 에러 DTO public record ErrorResponse(String message) { } diff --git a/src/main/java/org/zerock/shoppay/dto/PaymentConfirmRequestDto.java b/src/main/java/org/zerock/shoppay/dto/PaymentConfirmRequestDto.java new file mode 100644 index 0000000..5047d26 --- /dev/null +++ b/src/main/java/org/zerock/shoppay/dto/PaymentConfirmRequestDto.java @@ -0,0 +1,12 @@ +package org.zerock.shoppay.dto; + +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +public class PaymentConfirmRequestDto { + private String paymentKey; + private String orderId; + private Long amount; +} diff --git a/src/main/java/org/zerock/shoppay/dto/ProductDto.java b/src/main/java/org/zerock/shoppay/dto/ProductDto.java new file mode 100644 index 0000000..23ca750 --- /dev/null +++ b/src/main/java/org/zerock/shoppay/dto/ProductDto.java @@ -0,0 +1,17 @@ +package org.zerock.shoppay.dto; + +import lombok.Getter; +import org.zerock.shoppay.Entity.Product; + +@Getter +public class ProductDto { + private final Long id; + private final String name; + private final int price; + + public ProductDto(Product product) { + this.id = product.getId(); + this.name = product.getName(); + this.price = product.getPrice(); + } +} diff --git a/src/main/java/org/zerock/shoppay/handler/GlobalControllerAdvice.java b/src/main/java/org/zerock/shoppay/handler/GlobalControllerAdvice.java new file mode 100644 index 0000000..39fc7d1 --- /dev/null +++ b/src/main/java/org/zerock/shoppay/handler/GlobalControllerAdvice.java @@ -0,0 +1,23 @@ +package org.zerock.shoppay.handler; + +import lombok.RequiredArgsConstructor; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.core.userdetails.UserDetails; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.zerock.shoppay.service.CartService; + +@ControllerAdvice +@RequiredArgsConstructor +public class GlobalControllerAdvice { + + private final CartService cartService; + + @ModelAttribute("cartItemCount") + public int cartItemCount(@AuthenticationPrincipal UserDetails userDetails) { + if (userDetails == null) { + return 0; // 비로그인 사용자는 장바구니 아이템 개수가 0 + } + return cartService.getCartItemCount(userDetails.getUsername()); + } +} diff --git a/src/main/java/org/zerock/shoppay/handler/GlobalExceptionHandler.java b/src/main/java/org/zerock/shoppay/handler/GlobalExceptionHandler.java index b277787..7ebaa2d 100644 --- a/src/main/java/org/zerock/shoppay/handler/GlobalExceptionHandler.java +++ b/src/main/java/org/zerock/shoppay/handler/GlobalExceptionHandler.java @@ -35,6 +35,16 @@ public ResponseEntity handleOptimisticLockConflict(OptimisticLock return ResponseEntity.status(HttpStatus.CONFLICT).body(errorResponse); } + // favicon.ico, .well-known 등 정적 리소스를 찾지 못하는 경우를 위한 핸들러 + @ExceptionHandler(org.springframework.web.servlet.resource.NoResourceFoundException.class) + public ResponseEntity handleNoResourceFound(org.springframework.web.servlet.resource.NoResourceFoundException e) { + log.debug("Resource not found: {}", e.getMessage()); // 로그 레벨을 DEBUG로 낮춰서 불필요한 경고 방지 + ErrorResponse errorResponse = new ErrorResponse("The requested resource was not found."); + return new ResponseEntity<>(errorResponse, HttpStatus.NOT_FOUND); + } + + //Global Catch-All handler로 포괄적인 예외 처리를 진행 + //GlobalExceptionHandler에서 명시되지 않는 예외 이외의 모든 예외는 해당 메서드를 통해 처리 - Exception.class가 모든 예외의 최위 클래스 @ExceptionHandler(Exception.class) public ResponseEntity handleGlobalException(Exception e) { // 중요한 정보: 실제 운영 환경에서는 전체 예외 스택 트레이스를 로깅하여 디버깅에 사용해야 합니다. diff --git a/src/main/java/org/zerock/shoppay/repository/CartRepository.java b/src/main/java/org/zerock/shoppay/repository/CartRepository.java index 1285cb7..626ba03 100644 --- a/src/main/java/org/zerock/shoppay/repository/CartRepository.java +++ b/src/main/java/org/zerock/shoppay/repository/CartRepository.java @@ -27,7 +27,5 @@ public interface CartRepository extends JpaRepository { // N+1 문제 발생 버전 (JPA 메서드 네이밍 사용) Optional findByMemberId(Long memberId); - - // 회원의 장바구니 존재 여부 확인 - boolean existsByMember(Member member); + } \ No newline at end of file diff --git a/src/main/java/org/zerock/shoppay/repository/ProductRepository.java b/src/main/java/org/zerock/shoppay/repository/ProductRepository.java index 3ed7b61..d628f6b 100644 --- a/src/main/java/org/zerock/shoppay/repository/ProductRepository.java +++ b/src/main/java/org/zerock/shoppay/repository/ProductRepository.java @@ -4,6 +4,7 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.JpaSpecificationExecutor; import org.springframework.data.jpa.repository.Query; import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Modifying; @@ -16,7 +17,7 @@ import java.util.Optional; @Repository -public interface ProductRepository extends JpaRepository { +public interface ProductRepository extends JpaRepository, JpaSpecificationExecutor { /* SELECT * FROM products WHERE is_active = true @@ -39,17 +40,12 @@ public interface ProductRepository extends JpaRepository { */ @Query("SELECT p FROM Product p WHERE p.category.name = :categoryName AND p.isActive = true") List findByCategoryNameAndIsActiveTrue(@Param("categoryName") String categoryName); - - /* - 카테고리명으로 활성 상품 조회 (페이지네이션) - */ - @Query("SELECT p FROM Product p WHERE p.category.name = :categoryName AND p.isActive = true") - Page findByCategoryAndActiveTrue(@Param("categoryName") String categoryName, Pageable pageable); @Modifying @Query(value = "UPDATE products SET stock = stock - 1 WHERE id = :id AND stock > 0", nativeQuery = true) int decreaseStockNative(@Param("id") Long id); + //재고 감소 비관적 락 @Lock(LockModeType.PESSIMISTIC_WRITE) @Query("select p from Product p where p.id = :id") Optional findByIdWithPessimisticLock(@Param("id") Long id); diff --git a/src/main/java/org/zerock/shoppay/repository/ProductSpecification.java b/src/main/java/org/zerock/shoppay/repository/ProductSpecification.java new file mode 100644 index 0000000..9b22647 --- /dev/null +++ b/src/main/java/org/zerock/shoppay/repository/ProductSpecification.java @@ -0,0 +1,32 @@ +package org.zerock.shoppay.repository; + +import org.springframework.data.jpa.domain.Specification; +import org.springframework.util.StringUtils; +import org.zerock.shoppay.Entity.Product; + +public class ProductSpecification { + + /** + * 항상 is_active = true 조건을 포함하기 위한 기본 Specification + */ + public static Specification isActive() { + return (root, query, criteriaBuilder) -> + criteriaBuilder.isTrue(root.get("isActive")); + } + + /** + * 카테고리 이름으로 필터링하는 Specification + * @param categoryName 필터링할 카테고리 이름 + * @return Specification + */ + public static Specification hasCategory(String categoryName) { + return (root, query, criteriaBuilder) -> { + // categoryName 파라미터가 없거나, 비어있거나, "All"이면 이 조건은 무시됩니다. + if (!StringUtils.hasText(categoryName) || "All".equalsIgnoreCase(categoryName)) { + return null; + } + // p.category.name = :categoryName 에 해당하는 조건입니다. + return criteriaBuilder.equal(root.get("category").get("name"), categoryName); + }; + } +} diff --git a/src/main/java/org/zerock/shoppay/service/CartService.java b/src/main/java/org/zerock/shoppay/service/CartService.java index 7687983..068bee1 100644 --- a/src/main/java/org/zerock/shoppay/service/CartService.java +++ b/src/main/java/org/zerock/shoppay/service/CartService.java @@ -11,7 +11,11 @@ import org.zerock.shoppay.repository.CartRepository; import org.zerock.shoppay.repository.ProductRepository; +import java.util.List; import java.util.Optional; +import java.util.stream.Collectors; + +import org.zerock.shoppay.service.MemberService; @Service @RequiredArgsConstructor @@ -21,6 +25,7 @@ public class CartService { private final CartRepository cartRepository; private final CartItemRepository cartItemRepository; private final ProductRepository productRepository; + private final MemberService memberService; // 회원의 장바구니 조회 (없으면 생성) @Transactional @@ -38,8 +43,7 @@ public Cart getOrCreateCart(Member member) { @Transactional public CartItem addToCart(Member member, Long productId, int quantity) { Cart cart = getOrCreateCart(member); - Product product = productRepository.findById(productId) - .orElseThrow(() -> new IllegalArgumentException("상품을 찾을 수 없습니다.")); + Product product = productRepository.findById(productId).orElseThrow(() -> new IllegalArgumentException("상품을 찾을 수 없습니다.")); // 재고 확인 if (product.getStock() < quantity) { @@ -93,7 +97,10 @@ public void updateCartItemQuantity(Long cartItemId, int quantity) { // 장바구니 아이템 삭제 @Transactional public void removeFromCart(Long cartItemId) { - cartItemRepository.deleteById(cartItemId); + cartItemRepository.findById(cartItemId).ifPresent(cartItem -> { + // orphanRemoval=true 이므로 부모의 컬렉션에서 제거하면 자식은 자동으로 삭제된다. + cartItem.getCart().getCartItems().remove(cartItem); + }); } // 장바구니 비우기 @@ -103,15 +110,32 @@ public void clearCart(Member member) { cartItemRepository.deleteAllByCart(cart); cart.clear(); } + + // 특정 상품 ID 목록을 장바구니에서 제거 + @Transactional + public void removeCartItemsByProductIds(Member member, List productIds) { + if (productIds == null || productIds.isEmpty()) { + return; + } + Cart cart = getOrCreateCart(member); + if (cart != null) { + List itemsToRemove = cart.getCartItems().stream() + .filter(item -> productIds.contains(item.getProduct().getId())) + .collect(Collectors.toList()); + + if (!itemsToRemove.isEmpty()) { + // orphanRemoval=true를 활용하여 Cart의 리스트에서 제거하는 것만으로 DB 삭제가 처리됩니다. + cart.getCartItems().removeAll(itemsToRemove); + } + } + } // 장바구니 조회 (아이템 포함) public Cart getCartWithItems(Member member) { - // 기존 Fetch Join 버전 (N+1 문제 해결) - /* - return cartRepository.findByMemberIdWithItems(member.getId()) - .orElseGet(() -> Cart.builder().member(member).build()); - */ - + // Fetch Join을 사용하여 N+1 문제 해결 +// return cartRepository.findByMemberIdWithItems(member.getId()) +// .orElseGet(() -> Cart.builder().member(member).build()); + // N+1 문제 발생 버전 (테스트용) System.out.println("========== N+1 문제 테스트 시작 =========="); Cart cart = cartRepository.findByMemberId(member.getId()) @@ -143,4 +167,13 @@ public int getCartTotalPrice(Member member) { Cart cart = cartRepository.findByMember(member).orElse(null); return cart != null ? cart.getTotalPrice() : 0; } + + // 사용자 이름(이메일)으로 장바구니 아이템 개수 조회 + public int getCartItemCount(String username) { + Member member = memberService.findByEmail(username); + if (member == null) { + return 0; + } + return getCartItemCount(member); + } } \ No newline at end of file diff --git a/src/main/java/org/zerock/shoppay/service/OrderService.java b/src/main/java/org/zerock/shoppay/service/OrderService.java index 932fb3b..39e8ce2 100644 --- a/src/main/java/org/zerock/shoppay/service/OrderService.java +++ b/src/main/java/org/zerock/shoppay/service/OrderService.java @@ -1,93 +1,116 @@ package org.zerock.shoppay.service; import lombok.RequiredArgsConstructor; -import org.json.simple.JSONObject; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import org.zerock.shoppay.Entity.Order; -import org.zerock.shoppay.Entity.OrderItem; -import org.zerock.shoppay.Entity.Product; +import org.zerock.shoppay.Entity.*; +import org.zerock.shoppay.repository.CartItemRepository; import org.zerock.shoppay.repository.OrderRepository; import org.zerock.shoppay.repository.ProductRepository; import java.time.LocalDateTime; +import java.util.List; import java.util.Optional; @Service @RequiredArgsConstructor @Transactional public class OrderService { - + private final OrderRepository orderRepository; private final ProductRepository productRepository; private final ProductService productService; - - // 주문 생성 (결제 전 상태) - public Order createOrder(String orderId, Long productId, Integer quantity, - String customerName, String customerEmail, String customerPhone) { - //상품 확인 - Product product = productRepository.findById(productId) - .orElseThrow(() -> new RuntimeException("Product not found")); - + private final CartItemRepository cartItemRepository; + + // 장바구니 상품들로 주문 생성 (결제 전, PENDING 상태) + @Transactional + public Order createOrderFromCart(Member member, List cartItemIds) { + if (cartItemIds == null || cartItemIds.isEmpty()) { + throw new IllegalArgumentException("주문할 상품을 선택해주세요."); + } + + List cartItems = cartItemRepository.findAllById(cartItemIds); + + if (cartItems.isEmpty() || cartItemIds.size() != cartItems.size()) { + throw new IllegalArgumentException("주문 상품 정보를 찾을 수 없거나 유효하지 않은 상품이 포함되어 있습니다."); + } + // 재고 확인 - if (product.getStock() < quantity) { - throw new RuntimeException("Insufficient stock"); + for (CartItem cartItem : cartItems) { + if (cartItem.getProduct().getStock() < cartItem.getQuantity()) { + throw new RuntimeException("재고가 부족한 상품이 있습니다: " + cartItem.getProduct().getName()); + } } - - // 주문 생성 + + // 주문 총액 계산 (서버에서 직접 계산) + int totalAmount = cartItems.stream() + .mapToInt(CartItem::getTotalPrice) + .sum(); + + // 주문 엔티티 생성 Order order = Order.builder() - .orderId(orderId) - .totalAmount(product.getPrice() * quantity) - .status("PENDING") - .customerName(customerName) - .customerEmail(customerEmail) - .customerPhone(customerPhone) - .build(); - - // 주문 아이템 추가 - OrderItem orderItem = OrderItem.builder() - .product(product) - .quantity(quantity) - .price(product.getPrice()) + .orderId("ORDER_" + member.getId() + "_" + System.currentTimeMillis()) // 고유 ID 생성 + .member(member) + .customerName(member.getName()) + .customerEmail(member.getEmail()) + .customerPhone(member.getPhone()) + .totalAmount(totalAmount) + .status("PENDING") // 결제 대기 상태 .build(); - - order.addOrderItem(orderItem); - + + // 주문 아이템 엔티티 생성 및 주문에 추가 + for (CartItem cartItem : cartItems) { + OrderItem orderItem = OrderItem.builder() + .product(cartItem.getProduct()) + .quantity(cartItem.getQuantity()) + .price(cartItem.getProduct().getPrice()) + .build(); + order.addOrderItem(orderItem); + } + + // 생성된 주문을 DB에 저장하고 반환 return orderRepository.save(order); } - - // 결제 완료 처리 - public Order confirmPayment(String orderId, String paymentKey, JSONObject paymentData) { + + // 결제 완료 처리 (금액 검증 포함) + @Transactional + public Order confirmPayment(String orderId, String paymentKey, Long amount) { Order order = orderRepository.findById(orderId) - .orElseThrow(() -> new RuntimeException("Order not found: " + orderId)); - - // 이미 처리된 주문인지 확인 + .orElseThrow(() -> new RuntimeException("주문을 찾을 수 없습니다: " + orderId)); + + // 1. 이미 처리된 주문인지 확인 if (!"PENDING".equals(order.getStatus())) { - throw new RuntimeException("Order already processed"); + throw new RuntimeException("이미 처리된 주문입니다."); } - - // 주문 상태 업데이트 + + // 2. 결제 금액 검증 (중요) + if (!order.getTotalAmount().equals(amount.intValue())) { + // TODO: 금액 위변조 시도가 의심되므로 결제 취소 API를 호출하는 로직 추가 필요 + throw new RuntimeException("주문 금액이 일치하지 않습니다. 결제 위변조가 의심됩니다."); + } + + // 3. 주문 상태 업데이트 order.setPaymentKey(paymentKey); order.setStatus("PAID"); order.setPaidAt(LocalDateTime.now()); - - // 재고 감소 + + // 4. 재고 감소 for (OrderItem item : order.getOrderItems()) { productService.decreaseStock(item.getProduct().getId(), item.getQuantity()); } - + return orderRepository.save(order); } - + // 주문 취소 public Order cancelOrder(String orderId) { Order order = orderRepository.findById(orderId) .orElseThrow(() -> new RuntimeException("Order not found")); - + if ("CANCELLED".equals(order.getStatus())) { throw new RuntimeException("Order already cancelled"); } - + // 재고 복구 if ("PAID".equals(order.getStatus())) { for (OrderItem item : order.getOrderItems()) { @@ -96,28 +119,18 @@ public Order cancelOrder(String orderId) { productRepository.save(product); } } - + order.setStatus("CANCELLED"); return orderRepository.save(order); } - + // 주문 조회 public Optional findById(String orderId) { return orderRepository.findById(orderId); } - + // 결제키로 주문 조회 public Optional findByPaymentKey(String paymentKey) { return orderRepository.findByPaymentKey(paymentKey); } - - // 주문 저장 (장바구니에서 주문 생성 시) - @Transactional - public Order saveOrder(Order order) { - // 재고 감소 - for (OrderItem item : order.getOrderItems()) { - productService.decreaseStock(item.getProduct().getId(), item.getQuantity()); - } - return orderRepository.save(order); - } -} \ No newline at end of file +} diff --git a/src/main/java/org/zerock/shoppay/service/ProductService.java b/src/main/java/org/zerock/shoppay/service/ProductService.java index 05da994..7072320 100644 --- a/src/main/java/org/zerock/shoppay/service/ProductService.java +++ b/src/main/java/org/zerock/shoppay/service/ProductService.java @@ -4,6 +4,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.domain.Specification; import org.springframework.orm.ObjectOptimisticLockingFailureException; import org.springframework.retry.annotation.Backoff; import org.springframework.retry.annotation.Recover; @@ -15,6 +16,7 @@ import org.zerock.shoppay.exception.OptimisticLockConflictException; import org.zerock.shoppay.exception.ProductNotFoundException; import org.zerock.shoppay.repository.ProductRepository; +import org.zerock.shoppay.repository.ProductSpecification; import java.util.List; import java.util.Optional; @@ -92,7 +94,7 @@ public void decreaseStock(Long productId, Integer quantity) { System.out.println("재고 감소 시도: " + productId); Product product = productRepository.findById(productId) .orElseThrow(() -> new ProductNotFoundException("상품을 찾을 수 없습니다: " + productId)); - //재고가 처음부터 부족한 로 - 재시도 안됨 + //재고가 처음부터 부족한 로직 - 재시도 안됨 if (product.getStock() < quantity) { throw new InsufficientStockException("재고가 부족합니다."); } @@ -100,13 +102,14 @@ public void decreaseStock(Long productId, Integer quantity) { product.setStock(product.getStock() - quantity); } - // decreaseStock 메소드의 모든 재시도가 실패했을 때 호출될 메소드 + //재고 감소 전부 실패시 자동 호출 @Recover public void recoverDecreaseStock(RuntimeException e, Long productId, Integer quantity) { System.err.println("최대 재시도 횟수 초과: " + productId + ", 이유: " + e.getMessage()); throw new OptimisticLockConflictException("다른 사용자와의 충돌로 인해 요청을 처리할 수 없습니다. 잠시 후 다시 시도해주세요."); } + //native SQL을 이용한 재고 감소 @Transactional public void decreaseStockWithNativeQuery(Long productId, Integer quantity) { int updatedRows = productRepository.decreaseStockNative(productId); @@ -121,14 +124,22 @@ public void decreaseStockWithPessimisticLock(Long productId, Integer quantity) { .orElseThrow(() -> new RuntimeException("Product not found")); if (product.getStock() < quantity) { - throw new RuntimeException("Insufficient stock"); + throw new InsufficientStockException("재고가 부족합니다."); } product.setStock(product.getStock() - quantity); } @Transactional(readOnly = true) - public Page getProductsByCategoryWithPagination(String categoryName, Pageable pageable) { - return productRepository.findByCategoryAndActiveTrue(categoryName, pageable); + public Page findProducts(String category, Pageable pageable) { + // 1. 기본적으로 is_active = true 조건을 설정합니다. + Specification spec = Specification.where(ProductSpecification.isActive()); + + // 2. category 파라미터가 있으면, 카테고리 필터 조건을 추가합니다. + // ProductSpecification.hasCategory가 null 또는 빈 문자열을 안전하게 처리합니다. + spec = spec.and(ProductSpecification.hasCategory(category)); + + // 3. 최종 조합된 조건으로 Repository에 쿼리를 요청합니다. + return productRepository.findAll(spec, pageable); } -} \ No newline at end of file +} diff --git a/src/main/java/org/zerock/shoppay/util/ImprovedDataGenerator.java b/src/main/java/org/zerock/shoppay/util/ImprovedDataGenerator.java index 0f7bdf4..e47fa22 100644 --- a/src/main/java/org/zerock/shoppay/util/ImprovedDataGenerator.java +++ b/src/main/java/org/zerock/shoppay/util/ImprovedDataGenerator.java @@ -1,9 +1,7 @@ package org.zerock.shoppay.util; import lombok.RequiredArgsConstructor; -import org.springframework.boot.context.event.ApplicationReadyEvent; import org.springframework.context.annotation.Profile; -import org.springframework.context.event.EventListener; import org.springframework.stereotype.Component; import org.zerock.shoppay.Entity.Category; import org.zerock.shoppay.Entity.Product; @@ -11,288 +9,122 @@ import org.zerock.shoppay.repository.ProductRepository; import java.util.*; -import java.util.stream.Collectors; @Component @RequiredArgsConstructor -@Profile("dev") +@Profile("dev") // 이 생성기는 'dev' 프로파일에서만 활성화됩니다. public class ImprovedDataGenerator { - + private final ProductRepository productRepository; private final CategoryRepository categoryRepository; + private final UnsplashImageService unsplashImageService; private final Random random = new Random(); - - // 카테고리 Enum으로 관리 - private enum CategoryType { - BED("Bed", "침대"), - KITCHEN("Kitchen", "주방"), - LIVING("Living", "거실"), - CHAIR("chair", "의자"), - DESK_CHAIR("desk_chair", "사무용 의자"), - KITCHEN_CHAIR("kitchin_chair", "주방 의자"), - OUTDOOR("outdoor", "야외"), - PLANT("plant", "식물"), - STORAGE("store_furniture", "수납"), - STORED("stored", "보관"), - DECO("deco", "장식"), - LIGHTING("lightning", "조명"), - NEW("new", "신제품"); - - private final String dbName; - private final String koreanName; - - CategoryType(String dbName, String koreanName) { - this.dbName = dbName; - this.koreanName = koreanName; - } - - public static CategoryType fromDbName(String dbName) { - for (CategoryType type : values()) { - if (type.dbName.equalsIgnoreCase(dbName)) { - return type; - } - } - return null; - } - } - - // 제품 데이터 구조체 - private static class ProductData { + + // 제품 생성을 위한 템플릿 구조체 + private static class ProductTemplate { final String swedishName; final String koreanName; - final List descriptionTemplates; + final String descriptionTemplate; final int minPrice; final int maxPrice; - final List imageUrls; - - ProductData(String swedishName, String koreanName, List descriptions, - int minPrice, int maxPrice, List imageUrls) { + + ProductTemplate(String swedishName, String koreanName, String description, int minPrice, int maxPrice) { this.swedishName = swedishName; this.koreanName = koreanName; - this.descriptionTemplates = descriptions; + this.descriptionTemplate = description; this.minPrice = minPrice; this.maxPrice = maxPrice; - this.imageUrls = imageUrls; } } - - // 카테고리별 제품 데이터 풀 - private final Map> productDataPool = new HashMap<>() {{ - // 침대 카테고리 - put(CategoryType.BED, Arrays.asList( - new ProductData("MALM", "말름", - Arrays.asList( - "침대프레임, 높은형, %s, %dx200 cm", - "수납침대, %d개 서랍, %s 마감", - "침대프레임+헤드보드, %s" - ), - 200000, 500000, - Arrays.asList( - "https://images.unsplash.com/photo-1505693416388-ac5ce068fe85?w=400&h=400&fit=crop", - "https://images.unsplash.com/photo-1540835296355-c04f7a063cbb?w=400&h=400&fit=crop", - "https://images.unsplash.com/photo-1522771739844-6a9f6d5f14af?w=400&h=400&fit=crop" - ) - ), - new ProductData("HEMNES", "헴네스", - Arrays.asList( - "데이베드프레임, 서랍 3개, %s", - "침대프레임, %s 스테인, %dx200 cm", - "침대프레임+수납상자 4개, %s" - ), - 250000, 600000, - Arrays.asList( - "https://images.unsplash.com/photo-1505693416388-ac5ce068fe85?w=400&h=400&fit=crop", - "https://images.unsplash.com/photo-1540835296355-c04f7a063cbb?w=400&h=400&fit=crop" - ) - ), - new ProductData("BRIMNES", "브림네스", - Arrays.asList( - "침대프레임+헤드보드, %s, %dx200 cm", - "데이베드프레임, 서랍 2개, %s", - "수납침대, %s 마감" - ), - 180000, 450000, - Arrays.asList( - "https://images.unsplash.com/photo-1522771739844-6a9f6d5f14af?w=400&h=400&fit=crop" - ) - ) - )); - - // 주방 카테고리 - put(CategoryType.KITCHEN, Arrays.asList( - new ProductData("METOD", "메토드", - Arrays.asList( - "주방수납장, %s, %dx%d cm", - "벽수납장, %s 도어", - "하부장, %s/%s" - ), - 100000, 400000, - Arrays.asList( - "https://images.unsplash.com/photo-1556909114-f6e7ad7d3136?w=400&h=400&fit=crop", - "https://images.unsplash.com/photo-1565538810643-b5bdb714032a?w=400&h=400&fit=crop", - "https://images.unsplash.com/photo-1556911220-bff31c812dba?w=400&h=400&fit=crop" - ) - ), - new ProductData("KUNGSFORS", "쿵스포르스", - Arrays.asList( - "주방 선반유닛, %s", - "주방 트롤리, %s", - "벽선반, %s/%s" - ), - 50000, 200000, - Arrays.asList( - "https://images.unsplash.com/photo-1556909114-f6e7ad7d3136?w=400&h=400&fit=crop" - ) - ) - )); - - // 거실 카테고리 - put(CategoryType.LIVING, Arrays.asList( - new ProductData("EKTORP", "엑토르프", - Arrays.asList( - "%d인용 소파, %s", - "코너소파, 4인용, %s", - "소파베드, %s" - ), - 300000, 800000, - Arrays.asList( - "https://images.unsplash.com/photo-1555041469-a586c61ea9bc?w=400&h=400&fit=crop", - "https://images.unsplash.com/photo-1493663284031-b7e3aefcae8e?w=400&h=400&fit=crop", - "https://images.unsplash.com/photo-1512212621149-107ffe572d2f?w=400&h=400&fit=crop" - ) - ), - new ProductData("KIVIK", "시비크", - Arrays.asList( - "%d인용 소파, %s", - "코너소파, 5인용, %s/%s", - "긴의자섹션, %s" - ), - 400000, 900000, - Arrays.asList( - "https://images.unsplash.com/photo-1493663284031-b7e3aefcae8e?w=400&h=400&fit=crop" - ) - ) - )); - - // 의자 카테고리 - put(CategoryType.CHAIR, Arrays.asList( - new ProductData("INGOLF", "잉올프", - Arrays.asList( - "의자, %s", - "바 스툴, 등받이, %dcm, %s", - "유아용 의자, %s" - ), - 50000, 150000, - Arrays.asList( - "https://images.unsplash.com/photo-1592078615290-033ee584e267?w=400&h=400&fit=crop", - "https://images.unsplash.com/photo-1549497538-303791108f95?w=400&h=400&fit=crop", - "https://images.unsplash.com/photo-1586023492125-27b2c045efd7?w=400&h=400&fit=crop" - ) - ) - )); - }}; - + + // 카테고리별 제품 템플릿 데이터 풀 + private final Map> productDataPool = Map.of( + "Bed", List.of( + new ProductTemplate("MALM", "말름", "%s 색상의 높은 침대프레임, %dx200 cm", 200000, 500000), + new ProductTemplate("HEMNES", "헴네스", "%s 스테인 처리된 데이베드프레임, 서랍 3개", 250000, 600000) + ), + "Kitchen", List.of( + new ProductTemplate("METOD", "메토드", "%s 도어의 주방 벽수납장, %dx%d cm", 100000, 400000), + new ProductTemplate("KUNGSFORS", "쿵스포르스", "%s 재질의 주방 트롤리", 50000, 200000) + ), + "Living", List.of( + new ProductTemplate("EKTORP", "엑토르프", "%s 색상의 %d인용 패브릭 소파", 300000, 800000), + new ProductTemplate("KIVIK", "시비크", "%s 색상의 코너소파, 5인용", 400000, 900000) + ), + "chair", List.of( + new ProductTemplate("INGOLF", "잉올프", "%s 색상의 %s 의자", 50000, 150000), + new ProductTemplate("ADDE", "아데", "심플한 디자인의 %s 식탁 의자", 20000, 50000) + ) + ); + private final String[] COLORS = {"화이트", "블랙", "그레이", "베이지", "브라운", "네이비", "그린", "레드"}; private final String[] MATERIALS = {"참나무", "자작나무", "대나무", "파티클보드", "스틸", "패브릭", "가죽"}; - - @EventListener(ApplicationReadyEvent.class) - public void generateBulkData() { - int targetCount = 500; + + // DataInitializer에서 호출될 메서드 + public void generateProducts(int totalProductsToCreate) { long currentCount = productRepository.count(); - - if (currentCount >= targetCount) { - System.out.println("이미 충분한 데이터가 존재합니다: " + currentCount + "개"); + if (currentCount > 0) { + System.out.println("이미 상품 데이터가 " + currentCount + "개 존재하여, 추가 데이터를 생성하지 않습니다."); return; } - - System.out.println("=== 데이터 생성 시작 ==="); - System.out.println("현재: " + currentCount + "개 → 목표: " + targetCount + "개"); - + + System.out.println("=== 향상된 데이터 생성 시작 (목표: " + totalProductsToCreate + "개) ==="); List categories = categoryRepository.findAll(); + if (categories.isEmpty()) { + System.out.println("오류: 카테고리가 존재하지 않아 상품을 생성할 수 없습니다."); + return; + } + List productsToSave = new ArrayList<>(); - - // 카테고리별로 제품 생성 + int productsPerCategory = totalProductsToCreate / categories.size(); + int remainder = totalProductsToCreate % categories.size(); + for (Category category : categories) { - CategoryType categoryType = CategoryType.fromDbName(category.getName()); - if (categoryType == null) continue; - - List dataPool = productDataPool.get(categoryType); - if (dataPool == null || dataPool.isEmpty()) { - dataPool = getDefaultProductData(); // 기본 데이터 사용 - } - - // 각 카테고리당 생성할 제품 수 - int productsPerCategory = (targetCount - (int)currentCount) / categories.size(); + int productsToCreateForThisCategory = productsPerCategory + (remainder-- > 0 ? 1 : 0); + if (productsToCreateForThisCategory == 0) continue; + + System.out.println("카테고리 '" + category.getName() + "'에 대한 이미지 " + productsToCreateForThisCategory + "개를 가져옵니다..."); + List imageUrls = unsplashImageService.getRandomImageUrls(category.getName(), productsToCreateForThisCategory); - for (int i = 0; i < productsPerCategory; i++) { - Product product = generateProductFromData(category, dataPool); + for (int i = 0; i < productsToCreateForThisCategory; i++) { + String imageUrl = imageUrls.get(i % imageUrls.size()); // 받아온 이미지 목록 내에서 순환 사용 + Product product = generateRandomProductForCategory(category, imageUrl); productsToSave.add(product); - - // 배치 사이즈 도달 시 저장 (메모리 효율성) - if (productsToSave.size() >= 50) { - productRepository.saveAll(productsToSave); - productsToSave.clear(); - System.out.println("... " + productRepository.count() + "개 생성 완료"); - } } } - - // 남은 제품 저장 - if (!productsToSave.isEmpty()) { - productRepository.saveAll(productsToSave); - } - - System.out.println("=== 생성 완료: 총 " + productRepository.count() + "개 ==="); + + productRepository.saveAll(productsToSave); + System.out.println("=== 데이터 생성 완료: 총 " + productRepository.count() + "개 상품 생성됨 ==="); } - - private Product generateProductFromData(Category category, List dataPool) { - ProductData data = dataPool.get(random.nextInt(dataPool.size())); - - // 이름 생성 (번호 추가로 중복 방지) - String name = String.format("%s %s #%d", - data.swedishName, data.koreanName, System.currentTimeMillis() % 10000); + + private Product generateRandomProductForCategory(Category category, String imageUrl) { + List templates = productDataPool.getOrDefault(category.getName(), + List.of(new ProductTemplate("GENERIC", "기본", "%s 색상의 %s 제품", 10000, 50000))); - // 설명 생성 (템플릿 활용) - String description = generateDescription(data.descriptionTemplates); + ProductTemplate template = templates.get(random.nextInt(templates.size())); + + // 이름은 스웨덴어 + 한글 이름으로 간결하게 생성 + String name = String.format("%s %s", template.swedishName, template.koreanName); + + // 설명은 '재질' 정보를 맨 앞에 추가하여 생성 + String originalDescription = String.format(template.descriptionTemplate.replace("%d", "%s"), + COLORS[random.nextInt(COLORS.length)], + String.valueOf(60 + random.nextInt(5) * 20), + String.valueOf(80 + random.nextInt(5) * 20)); - // 가격 생성 (900원 단위) - int price = data.minPrice + random.nextInt(data.maxPrice - data.minPrice); + String description = MATERIALS[random.nextInt(MATERIALS.length)] + " 재질, " + originalDescription; + + int price = template.minPrice + random.nextInt(template.maxPrice - template.minPrice); price = (price / 1000) * 1000 + 900; - - // 이미지 선택 - String imageUrl = data.imageUrls.get(random.nextInt(data.imageUrls.size())); - + return Product.builder() - .name(name) - .description(description) - .price(price) - .stock(random.nextInt(50) + 5) - .imageUrl(imageUrl) - .category(category) - .isActive(true) - .build(); - } - - private String generateDescription(List templates) { - String template = templates.get(random.nextInt(templates.size())); - - // 템플릿의 %s, %d 치환 - template = template.replaceFirst("%s", COLORS[random.nextInt(COLORS.length)]); - template = template.replaceFirst("%s", MATERIALS[random.nextInt(MATERIALS.length)]); - template = template.replaceFirst("%d", String.valueOf(2 + random.nextInt(4))); - template = template.replaceFirst("%d", String.valueOf(60 + random.nextInt(3) * 20)); - - return template; - } - - private List getDefaultProductData() { - // 기본 제품 데이터 (카테고리 매핑이 없을 때) - return Arrays.asList( - new ProductData("LACK", "락", - Arrays.asList("선반유닛, %s", "벽선반, %s"), - 30000, 100000, - Arrays.asList("https://images.unsplash.com/photo-1558618666-fcd25c85cd64?w=400&h=400&fit=crop") - ) - ); + .name(name) + .description(description) + .price(price) + .stock(random.nextInt(100) + 1) + .imageUrl(imageUrl) + .category(category) + .isActive(true) + .build(); } } \ No newline at end of file diff --git a/src/main/java/org/zerock/shoppay/util/UnsplashImageService.java b/src/main/java/org/zerock/shoppay/util/UnsplashImageService.java index 1ac1f19..1abfa7a 100644 --- a/src/main/java/org/zerock/shoppay/util/UnsplashImageService.java +++ b/src/main/java/org/zerock/shoppay/util/UnsplashImageService.java @@ -1,52 +1,104 @@ package org.zerock.shoppay.util; import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; -import org.springframework.web.client.RestTemplate; +import org.springframework.web.reactive.function.client.WebClient; +import reactor.core.publisher.Mono; -import java.util.Random; +import java.util.ArrayList; +import java.util.List; @Service public class UnsplashImageService { - - private static final String UNSPLASH_API_URL = "https://api.unsplash.com/photos/random"; - private static final String ACCESS_KEY = "lj2FDyGQV5ahGrq4vMkMJ1mPzmiCxFZkZluUcxKQJc0"; - - private final RestTemplate restTemplate = new RestTemplate(); - private final ObjectMapper objectMapper = new ObjectMapper(); - private final Random random = new Random(); - + + private final WebClient webClient; + private final String accessKey; + + // 생성자를 통해 application.properties에서 키를 주입받습니다. + public UnsplashImageService(@Value("${unsplash.api.access-key}") String accessKey) { + this.webClient = WebClient.builder() + .baseUrl("https://api.unsplash.com") + .build(); + this.accessKey = accessKey; + } + public String getImageUrl(String query) { try { - // Unsplash API 호출 - String url = String.format("%s?query=%s&client_id=%s&orientation=square", - UNSPLASH_API_URL, query, ACCESS_KEY); - - String response = restTemplate.getForObject(url, String.class); - - // JSON 파싱 - JsonNode root = objectMapper.readTree(response); - JsonNode urls = root.path("urls"); - - // regular 사이즈 이미지 URL 반환 (없으면 small) - if (urls.has("regular")) { - return urls.get("regular").asText(); - } else if (urls.has("small")) { - return urls.get("small").asText(); + // WebClient를 사용한 비동기 API 호출 + Mono responseMono = webClient.get() + .uri(uriBuilder -> uriBuilder + .path("/photos/random") + .queryParam("query", query) + .queryParam("client_id", accessKey) + .queryParam("orientation", "squarish") // 'square'보다는 'squarish'가 더 많은 결과를 줌 + .build()) + .retrieve() + .bodyToMono(JsonNode.class); + + // Mono에서 결과를 동기적으로 추출 (DataInitializer는 비동기일 필요 없음) + JsonNode root = responseMono.block(); + + if (root != null) { + JsonNode urls = root.path("urls"); + // regular 사이즈 이미지 URL 반환 (없으면 small) + if (urls.has("regular")) { + return urls.get("regular").asText(); + } else if (urls.has("small")) { + return urls.get("small").asText(); + } } - } catch (Exception e) { - System.err.println("Unsplash API 오류: " + e.getMessage()); + System.err.println("Unsplash API 오류: " + e.getMessage() + ". 백업 이미지를 사용합니다."); + // API 호출 실패 시, 카테고리별 백업 이미지 사용 + return getFallbackImageUrl(query); } - - // 실패시 placeholder 반환 + + // 모든 시도 실패 시 최종 placeholder 반환 return String.format("https://via.placeholder.com/400x400/f0f0f0/333?text=%s", query); } - + + public List getRandomImageUrls(String query, int count) { + List imageUrls = new ArrayList<>(); + try { + Mono responseMono = webClient.get() + .uri(uriBuilder -> uriBuilder + .path("/photos/random") + .queryParam("query", query) + .queryParam("count", count) + .queryParam("client_id", accessKey) + .queryParam("orientation", "squarish") + .build()) + .retrieve() + .bodyToMono(JsonNode.class); + + JsonNode root = responseMono.block(); + + if (root != null && root.isArray()) { + for (JsonNode photoNode : root) { + JsonNode urlsNode = photoNode.path("urls"); + if (urlsNode.has("regular")) { + imageUrls.add(urlsNode.get("regular").asText()); + } + } + } + } catch (Exception e) { + System.err.println("Unsplash API (bulk) 오류: " + e.getMessage() + ". 백업 이미지를 사용합니다."); + } + + // API가 실패하거나 충분한 이미지를 반환하지 못하면, 백업 이미지로 채웁니다. + int remaining = count - imageUrls.size(); + if (remaining > 0) { + for (int i = 0; i < remaining; i++) { + imageUrls.add(getFallbackImageUrl(query)); + } + } + return imageUrls; + } + // 카테고리별 미리 정의된 이미지 URL (백업용) - public String getFallbackImageUrl(String category) { - switch(category.toLowerCase()) { + private String getFallbackImageUrl(String category) { + switch (category.toLowerCase()) { case "bed": return "https://images.unsplash.com/photo-1505693416388-ac5ce068fe85?w=400&h=400&fit=crop"; case "kitchen": diff --git a/src/main/resources/templates/cart/cart.html b/src/main/resources/templates/cart/cart.html index 5e976d1..682f33a 100644 --- a/src/main/resources/templates/cart/cart.html +++ b/src/main/resources/templates/cart/cart.html @@ -583,14 +583,12 @@

장바구니가 비어있습니다

} }); - // 배송비 계산 - const deliveryFee = totalPrice >= 50000 ? 0 : 5000; - const finalTotal = totalPrice + deliveryFee; + // 배송비 로직 제거 + const finalTotal = totalPrice; // UI 업데이트 document.getElementById('selectedCount').textContent = itemCount; document.getElementById('selectedAmount').textContent = totalPrice.toLocaleString(); - document.getElementById('deliveryFee').textContent = deliveryFee === 0 ? '무료' : '₩' + deliveryFee.toLocaleString(); document.getElementById('totalAmount').textContent = finalTotal.toLocaleString(); // 주문 버튼 활성화/비활성화 @@ -648,13 +646,22 @@

장바구니가 비어있습니다

fetch('/cart/remove/' + cartItemId, { method: 'DELETE' }) - .then(response => response.json()) + .then(response => { + if (!response.ok) { + throw new Error('서버 응답이 올바르지 않습니다: ' + response.statusText); + } + return response.json(); + }) .then(data => { if (data.success) { location.reload(); } else { - alert(data.message); + alert('삭제 실패: ' + data.message); } + }) + .catch(error => { + console.error('삭제 중 오류 발생:', error); + alert('삭제 처리 중 오류가 발생했습니다. 개발자 콘솔을 확인해주세요.'); }); } } diff --git a/src/main/resources/templates/fragments/common-head.html b/src/main/resources/templates/fragments/common-head.html new file mode 100644 index 0000000..e69de29 diff --git a/src/main/resources/templates/fragments/header.html b/src/main/resources/templates/fragments/header.html new file mode 100644 index 0000000..868c072 --- /dev/null +++ b/src/main/resources/templates/fragments/header.html @@ -0,0 +1,61 @@ +
+ +
+
+
+ + + + +
+ 🔍 + + +
+ + + +
+
+
+
diff --git a/src/main/resources/templates/fragments/navigation.html b/src/main/resources/templates/fragments/navigation.html new file mode 100644 index 0000000..38e2f4c --- /dev/null +++ b/src/main/resources/templates/fragments/navigation.html @@ -0,0 +1,32 @@ +
+ + +
diff --git a/src/main/resources/templates/home/index.html b/src/main/resources/templates/home/index.html index b59acc0..3cde039 100644 --- a/src/main/resources/templates/home/index.html +++ b/src/main/resources/templates/home/index.html @@ -12,90 +12,12 @@ - -
-
-
- - - - -
- 🔍 - - -
- - - -
-
-
+
- \ No newline at end of file + diff --git a/src/main/resources/templates/order/checkout.html b/src/main/resources/templates/order/checkout.html index 8c7a9eb..fd7a4f7 100644 --- a/src/main/resources/templates/order/checkout.html +++ b/src/main/resources/templates/order/checkout.html @@ -446,19 +446,12 @@

결제 수단

-
💳
신용/체크카드
-
-
🟡
-
카카오페이
-
-
🔵
토스페이
-
🏦
계좌이체
@@ -473,10 +466,6 @@

결제 정보

상품 금액 0 -
- 배송비 - ₩5,000 -
총 결제 금액 @@ -560,7 +549,6 @@

결제 정보

// 주문 생성에 필요한 데이터 const orderPayload = { cartItemIds: cartItems.map(item => item.id), - // 추가적인 배송 정보 등도 포함 가능 }; try { @@ -578,9 +566,7 @@

결제 정보

sessionStorage.setItem('selectedCartItems', JSON.stringify(data.selectedItemIds)); // 3. 토스페이먼츠 결제창 호출 - const orderName = cartItems.length > 1 - ? `${cartItems[0].product.name} 외 ${cartItems.length - 1}건` - : cartItems[0].product.name; + const orderName = cartItems.length > 1 ? `${cartItems[0].product.name} 외 ${cartItems.length - 1}건` : cartItems[0].product.name; await payment.requestPayment({ method: selectedPayment, diff --git a/src/main/resources/templates/payment/success.html b/src/main/resources/templates/payment/success.html index daee21b..497624a 100644 --- a/src/main/resources/templates/payment/success.html +++ b/src/main/resources/templates/payment/success.html @@ -44,14 +44,10 @@

결제를 완료했어요

// 서버로 결제 승인에 필요한 결제 정보를 보내세요. async function confirm() { - // 세션 스토리지에서 선택된 카트 아이템 가져오기 - const selectedCartItems = sessionStorage.getItem('selectedCartItems'); - - var requestData = { + const requestData = { paymentKey: urlParams.get("paymentKey"), orderId: urlParams.get("orderId"), amount: urlParams.get("amount"), - selectedCartItems: selectedCartItems, // 선택된 카트 아이템 ID 추가 }; // 결제 확인 및 주문 처리 diff --git a/src/main/resources/templates/product/category.html b/src/main/resources/templates/product/category.html index 9a5cf9a..60014a0 100644 --- a/src/main/resources/templates/product/category.html +++ b/src/main/resources/templates/product/category.html @@ -4,6 +4,8 @@ 카테고리 - ShopPay + + - -
- +
+ + + + + @@ -377,6 +381,22 @@

침대

+ + +
@@ -438,21 +458,21 @@

침대

@@ -738,14 +700,7 @@

제품 정보

} } - // 썸네일 클릭 이벤트 - document.querySelectorAll('.thumbnail').forEach((thumb, index) => { - thumb.addEventListener('click', function() { - document.querySelectorAll('.thumbnail').forEach(t => t.classList.remove('active')); - this.classList.add('active'); - // TODO: 메인 이미지 변경 로직 - }); - }); + // 사이즈 옵션 선택 document.querySelectorAll('.size-option').forEach(option => { diff --git a/src/main/resources/templates/product/list.html b/src/main/resources/templates/product/list.html index 1d2fd56..926a821 100644 --- a/src/main/resources/templates/product/list.html +++ b/src/main/resources/templates/product/list.html @@ -1,149 +1,149 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + + + + + + 상품 목록 + + + +
+

상품 목록

+ + + +
+
+
+ 이미지 없음 + 상품 이미지 +
+
상품명
+
0원
+
재고: 0개
+ +
+
+ +
+

등록된 상품이 없습니다.

+
+
+ + \ No newline at end of file